Docker Deployment
PRISM is distributed as a container image with Chrome and all dependencies pre-installed.
Image
Images are published to the Trident registry:
docker.trident-cache.com/prism:v1.4.0
Tags carry the v prefix, matching the release tag: v1.4.0, not 1.4.0.
latest tracks the newest release. Both amd64 and arm64 are in the same
multi-arch manifest, so Docker selects the right one for you.
docker pull docker.trident-cache.com/prism:v1.4.0
docker run --rm docker.trident-cache.com/prism:v1.4.0 --version
# trident-prism 1.3.3
PRISM is not published to ghcr.io. Earlier revisions of this page pointed
there; GHCR answers a repository it does not host with unauthorized rather
than not found, so the result looks like a credentials problem. If you see
unauthorized, check the registry hostname before your login.
Quick Start
docker run -d \
--name prism \
-p 4000:4000 \
-v ./config.toml:/etc/prism/config.toml:ro \
-v ./license.lic:/etc/prism/license.lic:ro \
docker.trident-cache.com/prism:v1.4.0
Docker Compose
version: "3.8"
services:
prism:
image: docker.trident-cache.com/prism:v1.4.0
ports:
- "4000:4000"
- "4001:4001" # Admin API (optional, bind to localhost in production)
volumes:
- ./config.toml:/etc/prism/config.toml:ro
- ./license.lic:/etc/prism/license.lic:ro
environment:
- RUST_LOG=info
restart: unless-stopped
deploy:
resources:
limits:
memory: 2G
cpus: "2.0"
shm_size: "1g" # Chrome needs shared memory for rendering
security_opt:
- no-new-privileges:true
:::info Chrome Sandbox in Docker PRISM runs Chrome with sandbox enabled. The Chrome sandbox requires user namespaces, which need one of these Docker configurations (in order of preference):
-
Docker 23.0+ with host kernel
kernel.unprivileged_userns_clone=1(most secure — no extra caps needed):security_opt:- no-new-privileges:trueMost modern hosts (Ubuntu 18.04+, Fedora 31+, Debian 12+) have this enabled by default.
-
Custom seccomp profile allowing
cloneandunsharesyscalls (avoidsSYS_ADMIN):security_opt:- no-new-privileges:true- seccomp:chrome-seccomp.jsonSee the Chromium seccomp profile for reference.
-
SYS_ADMINcapability (least preferred — broadens container privileges):security_opt:- no-new-privileges:truecap_add:- SYS_ADMINAvoid
seccomp:unconfined— it disables all seccomp filtering and significantly weakens container isolation. :::
Configuration
The container's entrypoint is prism --config /etc/prism/config.toml. To use a different path, override the command (e.g. command: ["prism", "--config", "/path/to/config.toml"]).
The license path is set inside the config file ([license] path = "..."), not via an environment variable.
Environment Variables
| Variable | Description | Default |
|---|---|---|
RUST_LOG | Log level (trace, debug, info, warn, error) — overrides [logging] level | info |
Volume Mounts
| Container Path | Purpose |
|---|---|
/etc/prism/config.toml | Configuration file (path passed via --config) |
/etc/prism/license.lic | License file (path set in [license] path of config) |
Shared Memory
Chrome requires shared memory (/dev/shm) for rendering. Docker's default 64MB is insufficient and will cause Chrome to crash. Either:
- Set
shm_size: "1g"in your compose file (recommended) - Mount the host's shared memory:
-v /dev/shm:/dev/shm
Resource Limits
Recommended minimums:
| Resource | Minimum | Recommended |
|---|---|---|
| Memory | 1 GB | 2 GB |
| CPU | 1 core | 2 cores |
| Shared Memory | 512 MB | 1 GB |
Each Chrome tab uses approximately 50-150 MB of memory depending on page complexity. With the default 8 tabs, plan for at least 1.5 GB for Chrome alone.
Health Check
The shipped image already defines one, probing /ready every 30 seconds. If you
override it, keep both of those.
services:
prism:
# ...
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:4001/ready"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
Probe /ready, not /health. /health does a real CDP round-trip — it
creates a page in the browser and closes it — which is a reasonable occasional
liveness signal and a poor thing to do on a short timer, because it competes
with actual renders for the browser. /ready reads atomics only.
start_period should cover Chrome launch. Sixty seconds is comfortable; a cold
or loaded host can exceed the default retry grace and mark a perfectly healthy
container unhealthy.
:::warning A health check that hangs can wedge the whole host
This is worth understanding rather than copying, because it is a property of Docker and containerd, not of PRISM — any container with a slow probe hits it.
A health check that answers before its timeout costs nothing: the docker exec exits and is reaped. A health check that outlives its timeout is
killed, and on Docker 29.2.1 / containerd 2.2.1 every kill strands that exec's
state on the /run tmpfs, where nothing ever reaps it:
| Leaked per killed exec | Location | Exhausts |
|---|---|---|
<exec-id>.pid | /run/containerd/io.containerd.runtime.v2.task/moby/<container>/ | space — a 4 KB tmpfs page each |
<exec-id>-stdout, <exec-id>-stderr | /run/docker/containerd/<container>/ | inodes — FIFOs use no blocks |
Five clean execs leak nothing; five killed execs leak five. So a probe that hangs is self-reinforcing — it times out, the kill strands three inodes, and it repeats on the next interval, forever.
One host reached 198k pid files — at a page each, the whole 776 MB of /run
— alongside 396k FIFOs holding 41% of the inode table. Once /run is
exhausted, containerd cannot write container init state and no container on
the machine can start, including ones with nothing to do with PRISM:
OCI runtime create failed: unable to store init state: no space left on device
That points at a disk which may be half empty, because the exhausted filesystem
is the /run tmpfs. Check both dimensions — space and inodes — since the
FIFOs fill the second without touching the first:
df -h /run # space: the .pid files
df -i /run # inodes: the FIFOs
The interval is not the fix. A longer one paces the leak; it does not stop
it. The fix is a probe that cannot hang, which is why the shipped image polls
/ready (atomics only) rather than /health.
To clear an existing backlog, the cheapest and safest move is to recreate the affected container — removing it makes the daemon drop its entire state directory, FIFOs included, with no risk of deleting something live.
Where that is not an option, clean up in place. Both paths must be swept; a
script that handles only the .pid files leaves the larger, inode-consuming
half behind:
#!/bin/sh
# Reap exec state stranded by killed health checks.
# -mmin +60 leaves any exec still in flight alone.
set -eu
# .pid files — `init.pid` is the live container process, never remove it.
find /run/containerd/io.containerd.runtime.v2.task/moby \
-mindepth 2 -maxdepth 2 -type f -name '*.pid' \
! -name 'init.pid' -mmin +60 -delete 2>/dev/null || true
# stdio FIFOs — the container's own stdio is named after the container ID,
# so skip any FIFO whose name starts with the directory it sits in.
for d in /run/docker/containerd/*/; do
id=$(basename "$d")
find "$d" -maxdepth 1 -type p -mmin +60 \
! -name "$id-*" -delete 2>/dev/null || true
done
Run it hourly from a systemd timer or cron. Note that it deletes by age only — it cannot tell a stranded FIFO from a live one, which is why the container-ID exclusion and the 60-minute floor both matter. :::
Networking
In production, bind the admin API port (4001) to localhost only:
ports:
- "4000:4000" # Proxy - exposed to reverse proxy
- "127.0.0.1:4001:4001" # Admin API - localhost only
If PRISM and your origin run in the same Docker network:
services:
prism:
image: docker.trident-cache.com/prism:v1.4.0
volumes:
- ./config.toml:/etc/prism/config.toml:ro
- ./license.lic:/etc/prism/license.lic:ro
shm_size: "1g"
networks:
- app
spa:
image: your-spa:latest
networks:
- app
networks:
app:
Then set the origin in your config to the service name:
[server]
origin = "http://spa:3000"