Run a worker
Pull the worker image through the license gate and run it, with the flags Chromium actually needs.
Browsers run on your machines. The thing that launches them is a worker — the
Local API — and it ships as a container image you pull with your organization key.
There is no source tree to check out and no docker login: the registry gate
authorizes on the pull path alone.
This page gets one worker running and verified. Connect is what to read next, once it answers.
Pull the image
Your organization key, lowercased, is the first path segment. The Connect page in the dashboard shows both forms of the key.
docker pull registry.veilbrowser.net/<org-key-lower>/local-api:latestOne key, two roles
The same key is the registry path segment and the runtime VEIL_KEY. If a pull
returns unauthorized or denied, the key in the path is wrong or revoked — see
Image pulls fail.
A worker needs a release origin
The image ships the API and the runtime Chromium needs, but not Chromium itself
and not the profile bundles. A worker downloads those on demand from a release
origin, which is a surface the cluster manager serves. Point a worker at one with
RELEASE_ORIGIN_URL.
That makes the shape of your first install the important decision:
One box, everything on it
The single-host template runs the cluster manager (which is the release origin) and one worker together. Start here if you do not already have a manager.
Add a worker to an existing origin
A standalone docker run, pointed at a release origin you already operate. Use it to add capacity.
A worker with no release origin starts but cannot launch
Nothing fails at boot: /health answers, the fleet view shows the worker healthy,
and then every launch is refused with RESOURCE_NOT_FOUND naming a browser version,
because the host has no builds on disk. If that is what you are seeing, the release
origin is the thing to fix.
Run a standalone worker
This is the whole command. Every flag below is load-bearing; the paragraphs after it say why.
docker run -d --name veil-worker \
--restart unless-stopped \
--privileged \
--shm-size=1g \
--tmpfs /tmp:size=2g,mode=1777 \
-p 38923:38923 \
-v veil-install:/var/veil/install \
-e API_KEY="$WORKER_API_KEY" \
-e VEIL_KEY="$VEIL_ORG_KEY" \
-e SAAS_API_URL=https://api.veilbrowser.net \
-e RELEASE_ORIGIN_URL=https://release.your-cluster.example \
-e VEIL_BOOTSTRAP_RELEASES=1 \
-e VEIL_WORKER_ID=worker-1 \
-e VEIL_MAX_BROWSERS=8 \
registry.veilbrowser.net/<org-key-lower>/local-api:latestThe two keys
VEIL_KEY is your organization key. The worker sends it to the cloud to
authorize each launch and to report sessions, so without it launches fail closed.
API_KEY is the inbound credential: the X-API-Key your own automation sends to
this worker's REST surface. They are deliberately separate — one faces the cloud, one
faces your callers.
Set API_KEY explicitly. It falls back to VEIL_KEY, and with neither the worker
generates a key at boot and prints it, which means it changes on every restart and
every client breaks.
The Chromium sandbox
Chromium needs kernel features a default container does not grant. This is the single most common cause of a worker that starts and then crashes every browser.
| Requirement | Why |
|---|---|
--privileged, or --cap-add=SYS_ADMIN with the image's seccomp profile | The user-namespace sandbox needs clone flags the default seccomp profile blocks |
--shm-size=1g (at least) | Docker's 64 MB default causes renderer crashes under any real page load |
A writable, roomy /tmp | X11 sockets and per-session Chromium scratch directories |
--privileged is the blunt instrument and the one that always works.
--cap-add=SYS_ADMIN --security-opt seccomp=/etc/veil/seccomp/chrome.json is the
narrower grant, using the profile baked into the image. On Ubuntu 24.04 and newer,
also allow unprivileged user namespaces on the host:
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0Managed container platforms cannot do this
Fly.io, Render and DigitalOcean App Platform expose none of these controls, so they cannot run a worker. Run workers on hosts or a Kubernetes cluster you control.
The install volume
/var/veil/install holds the browser builds, the profile bundles, the session
database and the logs — everything the worker accumulates. Mount a named volume so an
image roll does not re-download hundreds of megabytes. It is also the reason a worker
holds no state you have to back up: the volume is a cache, rebuildable from the
release origin.
The port
The published image listens on 38923, not the 3000 a from-source run defaults
to. -p 38923:38923 is the direct form; behind the cluster manager's load balancer
you do not publish it at all, because the edge reaches workers on the internal
network.
Capacity
VEIL_MAX_BROWSERS defaults to 0, meaning unlimited, so a worker will accept
launches until it runs out of memory. Set it to what the host can hold. At the cap the
worker returns 503 before the WebSocket upgrade, which is the signal the load
balancer uses to try another worker.
VEIL_DISPLAY_COUNT (default 4) sizes the pool of KasmVNC X servers, and is
therefore the separate ceiling on concurrent headful sessions.
Verify it
GET /health needs no credentials and is what the fleet poller and the container
healthcheck both use:
curl -fsS http://localhost:38923/health{
"status": "ok",
"timestamp": "2026-08-01T12:00:00.000Z",
"version": "1.2.2",
"service": "veil-local-api",
"contract": { "localApi": 1, "saasApi": 1 }
}Then confirm the worker actually has browsers on disk, which is the check that catches a missing or unreachable release origin:
curl -fsS http://localhost:38923/profiles/versions \
-H "X-API-Key: $WORKER_API_KEY"An empty list means the bundle sync has not succeeded. Look at docker logs veil-worker for the bootstrap output, and confirm the worker can reach
RELEASE_ORIGIN_URL.
Finally, launch something:
curl -X POST http://localhost:38923/sessions/profile \
-H "X-API-Key: $WORKER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "profileId": "<profile-uuid>" }'Keeping it running
Browser builds and profile bundles live in the volume, not the image. Re-sync them from the cluster manager console (worker page → Sync now) without restarting anything.
The image itself is replaced by recreating the container. A container cannot do that to itself, so a worker on another host gets an updater-agent sidecar and the manager drives it from Infrastructure → Updates, draining live browsers first. Workers on the manager's own machine need no agent — they are rolled over the Docker socket. See Upgrades.