VeilBrowserDocs

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.

terminal
docker pull registry.veilbrowser.net/<org-key-lower>/local-api:latest

One 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:

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.

terminal
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:latest

The 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.

RequirementWhy
--privileged, or --cap-add=SYS_ADMIN with the image's seccomp profileThe 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 /tmpX11 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:

terminal
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

Managed 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:

terminal
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:

terminal
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:

terminal
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.

Next steps

Was this page helpful?

On this page