Runner Container
The runner container image (ghcr.io/pizzaface/pizzapi-runner) packages the standalone pizza runner binary as a small, non-root Debian image. It’s a remote coding machine — it dials out to a relay over HTTP/WebSocket and spawns agent sessions against mounted workspaces. It exposes no inbound ports.
Pin a version tag — ghcr.io/pizzaface/pizzapi-runner:0.5.61, matching your CLI/relay version. A :latest tag exists but isn’t reproducible; don’t use it in a config you expect to stay stable across upgrades. Version tags begin with the first release that shipped this image, so check the package’s tag list and pin one that exists (the version numbers in the examples below are illustrative).
Quick start
Section titled “Quick start”-
Start the container with Compose — no API key needed yet:
compose.yml services:runner:image: ghcr.io/pizzaface/pizzapi-runner:0.5.61restart: unless-stoppedstop_grace_period: 30senvironment:PIZZAPI_RELAY_URL: https://pizza.example.comPIZZAPI_RUNNER_NAME: docker-runnervolumes:- runner-data:/home/pizza/.pizzapi- ./projects:/workspacevolumes:runner-data:Terminal window docker compose up -dOr the equivalent
docker run:Terminal window docker run -d --name pizzapi-runner \--restart unless-stopped --stop-timeout 30 \-e PIZZAPI_RELAY_URL=https://pizza.example.com \-e PIZZAPI_RUNNER_NAME=docker-runner \-v runner-data:/home/pizza/.pizzapi \-v "$(pwd)/projects:/workspace" \ghcr.io/pizzaface/pizzapi-runner:0.5.61 -
Approve it. A container with no resolvable credential and a known relay URL auto-pairs on boot: it prints an approval URL + QR code to its logs (re-printed roughly every 60s while it waits) and polls for approval.
Terminal window docker compose logs -f runnerOpen the printed URL in a browser where you’re already signed in to the relay, and approve. The runner then saves the minted key into the data volume and continues starting — no restart needed.
-
Confirm it registered:
Terminal window docker compose exec runner pizza runner status# or, for the docker run form:docker exec pizzapi-runner pizza runner statusOr check the relay’s web UI — the runner shows up under its display name (
PIZZAPI_RUNNER_NAME, defaulting to the container’s hostname), and the key it used shows up in Settings → API Keys asrunner-<name>, individually revocable.
Prefer to skip the approval step entirely — automated provisioning, fleet rollout, PIZZAPI_PAIRING=0 — see Credentials below for the Docker/K8s-secrets alternative.
If your relay is itself a Compose service, point at it by service name instead of a public URL:
environment: PIZZAPI_RELAY_URL: http://server:7492localhost inside the container always means the container itself — not your host, and not the relay (the relay is never this same container).
Data volume
Section titled “Data volume”Mount /home/pizza/.pizzapi as one persistent volume — everything that makes the runner this runner lives there:
runner.json— runner identity (runnerId) and secret (runnerSecret) used to re-authenticate with the relay, plus the process lockconfig.json,settings.json,models.jsonauth.json— provider credentials (OAuth tokens, saved API keys)- session transcripts and attachments
- the usage database/cache
- installed packages, plugins, agents, skills, and global runner services
Persist the whole directory, not a subset — it’s one coupled unit. Treat it as secret-bearing: it holds runnerSecret and provider tokens. Never bake it into an image or commit it.
If you don’t persist it: every container recreation gets a new runnerId (the relay sees a brand-new runner registering, not the same one reconnecting), all provider auth is gone (re-run pizza setup / OAuth login inside the container), and every session transcript and attachment is lost.
Workspaces
Section titled “Workspaces”Mount project directories under /workspace (or wherever PIZZAPI_WORKSPACE_ROOTS points — the image defaults it to /workspace):
volumes: - ./projects/client-a:/workspace/client-a - ./projects/client-b:/workspace/client-b:roPIZZAPI_WORKSPACE_ROOTSis comma-separated:/workspace/client-a,/workspace/client-b.- Spawn requests (
spawn_session, the web UI’s session picker) must use container paths —/workspace/client-a, not the host path you mounted from. - Read-only mounts (
:ro) work, but agents can’t edit those files. - A spawn request with a
cwdoutside every configured root is rejected:Requested cwd is outside allowed workspace root(s): <path>. - If you unset
PIZZAPI_WORKSPACE_ROOTSentirely, the runner is unscoped — any path on the container filesystem is allowed. Don’t do this in a shared container.
UID/GID ownership
Section titled “UID/GID ownership”The image runs as a fixed non-root user, pizza (uid/gid 1000:1000), by default. Named volumes (like runner-data above) pick up that ownership automatically the first time Docker populates them from the image. Bind mounts are the common failure case: if ./projects on the host is owned by a different UID than 1000, the container’s pizza user can’t write to it.
To match a host UID/GID, run the container as root and let the entrypoint remap and drop privileges — don’t just set a raw user: override:
services: runner: image: ghcr.io/pizzaface/pizzapi-runner:0.5.61 user: "0:0" # required — the entrypoint needs root to remap, then drops it environment: PUID: "1000" PGID: "1000" PIZZAPI_CHOWN_WORKSPACE: "1" # recursively chown mounted workspace roots to PUID:PGID volumes: - runner-data:/home/pizza/.pizzapi - ./projects:/workspaceThe entrypoint detects it’s running as uid 0, remaps the baked-in pizza account to PUID/PGID, chowns $HOME and .pizzapi, optionally chowns the workspace roots (PIZZAPI_CHOWN_WORKSPACE=1 — a recursive chown, can be slow on large repos), then drops privileges via setpriv before exec’ing tini -- pizza runner. The daemon itself never runs as root.
Credentials
Section titled “Credentials”Pairing (recommended)
Section titled “Pairing (recommended)”A container with no resolvable relay credential and a known PIZZAPI_RELAY_URL auto-pairs on boot instead of failing: it mints a device-claim token, prints an approval URL + QR code to stdout (re-printed roughly every 60s while pending), and polls the relay. Approving it from an already-authenticated browser mints a fresh, dedicated API key — named runner-<name>, where <name> is PIZZAPI_RUNNER_NAME or the container’s hostname if unset — and the runner saves it (plus the relay URL) into config.json in the data volume and continues starting, no restart required.
That key shows up individually in the relay’s Settings → API Keys, labeled by name, and can be revoked on its own without touching any other runner’s credentials — unlike a hand-copied token shared across machines.
Opt out with PIZZAPI_PAIRING=0 if you’d rather the container fail fast with an actionable error than sit there waiting for a human to click a link (e.g. for the secrets-file path below, or any automated provisioning where no one will be watching the logs).
Re-pairing after revoking a key. Revoking the minted key in the web UI doesn’t make the runner forget it — the stale key sits in config.json in the data volume until something replaces it. Run the same headless flow on demand instead of hand-editing the volume:
docker compose exec runner pizza runner pair --forceThis prints a fresh approval URL + QR to the container’s stdout (docker compose logs -f runner to see it if you’re not attached), waits for approval, and overwrites apiKey/relayUrl in config.json on success. Without --force it refuses — non-zero exit, existing credential untouched — so it’s safe to run speculatively to check what’s configured. It also warns (and, without --force, refuses outright) if PIZZAPI_API_KEY or another credential env var is set on the service, since that would keep shadowing the freshly-paired key at runtime — remove it from Compose’s environment: in that case. A currently-running daemon keeps using its old credential in memory even after a successful pair; the command says so and tells you to docker compose restart runner (or pizza runner stop + start again) to pick up the new key. See pizza runner pair for the full flag/exit-code reference.
The old manual fallback (remove config.json from the volume, then restart so auto-pairing kicks in) still works if you’d rather not run a command inside the container:
docker compose exec runner rm /home/pizza/.pizzapi/config.jsondocker compose restart runnerDocker/K8s secrets (alternative)
Section titled “Docker/K8s secrets (alternative)”For automated provisioning where pairing’s human-approval step doesn’t fit — CI, fleet rollout — mount credentials as files and point env vars at them instead:
| Variable | Behavior |
|---|---|
<NAME>_FILE (e.g. PIZZAPI_API_KEY_FILE, ANTHROPIC_API_KEY_FILE, GH_TOKEN_FILE) | Populates <NAME> from the trimmed contents of the file at that path, but only for credential-shaped names — ones ending in _KEY or _TOKEN (which covers _API_KEY). An already-set <NAME> wins over its _FILE counterpart. Any other *_FILE var (e.g. an app’s own NTFY_AUTH_FILE) is left completely alone. |
PIZZAPI_AUTH_FILE | Seeds <data volume>/auth.json (mode 0600) from the file at that path, but only when auth.json doesn’t already exist. It never overwrites an existing one — OAuth refresh writes back to auth.json on every token refresh, so clobbering it on every restart would destroy already-refreshed credentials. |
The deliberate _KEY/_TOKEN scoping matters: a blanket *_FILE → bare-name expansion would silently repurpose any unrelated app’s own FOO_FILE convention (pointing at a config path, a binary DB, whatever) the moment it happened to share a name with something the runner reads — restricting it to credential-shaped suffixes keeps the behavior predictable.
services: runner: environment: PIZZAPI_API_KEY_FILE: /run/secrets/pizzapi_api_key PIZZAPI_AUTH_FILE: /run/secrets/pizzapi_auth_json secrets: - pizzapi_api_key - pizzapi_auth_jsonsecrets: pizzapi_api_key: file: ./secrets/pizzapi_api_key.txt pizzapi_auth_json: file: ./secrets/auth.jsonOther notes
Section titled “Other notes”macOS Keychain-stored credentials do not cross into the container — it’s Linux. If you relied on keychain fallback on your Mac, you need an explicit env var, _FILE secret, or auth.json entry instead.
Sidecars & derived images
Section titled “Sidecars & derived images”Sidecars (databases, Ollama, MCP HTTP servers) share the Compose network and get addressed by service DNS name:
services: runner: environment: DATABASE_URL: postgres://user:pass@postgres:5432/app OLLAMA_HOST: http://ollama:11434 postgres: image: postgres:16 ollama: image: ollama/ollamaThe base image deliberately ships no gh, Node, Python, or compilers — only what PizzaPi itself needs (git, ssh, ripgrep, bash, bubblewrap, curl, tini). Add a toolchain in a derived image:
# DockerfileFROM ghcr.io/pizzaface/pizzapi-runner:0.5.61USER rootRUN apt-get update && apt-get install -y --no-install-recommends \ nodejs npm gh \ && rm -rf /var/lib/apt/lists/*USER pizzaStdio MCP servers and custom runner services need their executable dependencies (interpreters, CLIs) baked into a derived image the same way — the base image won’t have them.
Runner services & tunnels
Section titled “Runner services & tunnels”Built-in runner services — terminal, file explorer, git, process, memory, time, tunnel — run inside the container unmodified. Their ports stay internal to the container; the browser reaches them through the relay’s existing /_tunnel WebSocket proxy (the same mechanism create_tunnel uses for dev-server previews). You don’t need any ports: entries in Compose for this to work.
SSH & git identity
Section titled “SSH & git identity”Prefer forwarding an SSH agent socket over mounting private keys:
services: runner: environment: SSH_AUTH_SOCK: /ssh-agent volumes: - ${SSH_AUTH_SOCK}:/ssh-agent:roFor git identity, either mount your host config read-only:
volumes: - ~/.gitconfig:/home/pizza/.gitconfig:roor point git’s own GIT_CONFIG_GLOBAL at a file inside the persisted volume so identity survives recreation without a host mount:
environment: GIT_CONFIG_GLOBAL: /home/pizza/.pizzapi/gitconfigthen run git config --global user.name "..." && git config --global user.email "..." once inside the container. The default /home/pizza/.gitconfig location is not part of the persisted .pizzapi volume and won’t survive a recreation unless you use one of the two options above.
Sandbox posture
Section titled “Sandbox posture”The container is the machine boundary, not a per-session boundary — every session in the container shares its filesystem and process namespace unless PizzaPi’s own sandbox is enabled and configured.
The image defaults to PIZZAPI_SANDBOX=none. That’s not a convenience default: bubblewrap (the Linux sandbox backend) can’t create user namespaces under Docker’s default seccomp profile, even running as root. Verified on Docker 29.5.3.
To opt in to the nested-bubblewrap sandbox:
services: runner: security_opt: - "seccomp=unconfined" environment: PIZZAPI_SANDBOX: basicThis is a trade, not a free win: seccomp=unconfined widens the syscalls available to everything in the container, weakening the container’s own isolation from the host in exchange for the nested sandbox being able to isolate sessions from each other inside it. Verify it on your actual host before relying on it — see Agent Sandbox for what basic mode actually restricts.
Health, logs, upgrades
Section titled “Health, logs, upgrades”The image ships a HEALTHCHECK that runs pizza runner status every 30s (5s timeout, 45s start period, 3 retries). Exit 0 means the process is alive and registered with the relay — not just alive:
docker inspect --format='{{.State.Health.Status}}' pizzapi-runner
# for scripting / your own monitoringdocker exec pizzapi-runner pizza runner status --json{ "healthy": true, "runnerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "runnerName": "docker-runner", "relayUrl": "https://pizza.example.com", "connected": true, "pid": 47, "startedAt": "2025-01-15T10:30:00.000Z", "cliVersion": "0.5.61"}When unhealthy, reason explains why — "no runner state file found", "process not running", "not registered with relay", a pending-pairing message with the approval URL, or "relay rejected credentials — check PIZZAPI_API_KEY / re-pair" — see the CLI Reference.
Other operational notes:
-
restart: unless-stopped+stop_grace_period: 30s—docker stopsends SIGTERM totini(PID 1), which forwards it through the supervisor to the daemon, the same clean-shutdown path as runningpizza runner stop. Give it the full 30s window to disconnect and release its lock before Docker sends SIGKILL. -
Logs are the runner’s stdout/stderr (
docker logs pizzapi-runner). Rotation is Docker’s/your logging driver’s job, not the image’s. -
Relay restarts are survivable and need no action. If the relay goes away, the runner reconnects with backoff, re-registers under the same identity, and preserves its already-initialized runner services rather than tearing them down. Expect the container to report
unhealthyfor a healthcheck interval or two while that happens — don’t wire an external supervisor to restart it on the first failed check. -
A fresh container waiting on pairing approval is also legitimately
unhealthy, for however long it takes a human to click the approval link — minutes, potentially, not one healthcheck interval. This is intentional: theHEALTHCHECK(--start-period=45s --retries=3) is left as-is rather than widened for pairing, because Docker’s healthcheck only ever reports status here — nothing in this image restarts or kills the container for failing it, so a long unhealthy window costs nothing. Don’t wire an autoheal/restart-on-unhealthy supervisor on top of this image; the moment one exists, an unattended pairing window turns into a restart loop instead of a runner patiently waiting for a click. -
Resource limits (
mem_limit,cpus, …) apply to the whole container — every session and its child processes share them. Size for your heaviest concurrent session load, not one session. -
Upgrade by pulling a new version tag and recreating the container; the data volume carries identity, auth, and transcripts forward:
Terminal window docker compose pull runnerdocker compose up -d runner
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Fix |
|---|---|---|
| Container unhealthy | Unreachable relay, or wrong PIZZAPI_RELAY_URL scheme | docker exec <ctr> pizza runner status for the exact reason; check the URL scheme matches the relay’s actual setup |
Logs show [relay] rejected our API key at ... — check PIZZAPI_API_KEY ... or re-pair this runner; status shows connected false / relay rejected credentials — check PIZZAPI_API_KEY / re-pair | Invalid or revoked PIZZAPI_API_KEY (or a stale key left in config.json from a prior pairing). The daemon watches for socket.io’s unauthorized connect error and logs this loudly and immediately (rate-limited to once/~60s during retries), rather than the old silent hang | Issue a fresh key — re-pair (see Credentials) if you’re using auto-pairing, or a new runner token otherwise. Note an apiKey in a host config.json is not automatically the one your shell has in PIZZAPI_API_KEY — confirm which one you’re passing |
Container sits unhealthy right after a fresh start, with an approval URL in the logs | Expected — auto-pairing is waiting for a human to approve it | Open the printed URL (or re-run docker compose logs -f runner to see it again); see Credentials → Pairing. Not a bug, and nothing should be restarting the container for this |
Permission denied writing to /home/pizza/.pizzapi | Bind-mounted (not named) volume owned by a different host UID | Use a named volume, chown the host dir to 1000:1000, or use the PUID/PGID remap path |
| Spawn request rejected: “outside allowed workspace root(s)“ | cwd isn’t under any PIZZAPI_WORKSPACE_ROOTS entry, or you passed a host path instead of a container path | Use the container-side mount path; add the missing root to PIZZAPI_WORKSPACE_ROOTS |
command not found for a language toolchain, gh, etc. | Base image intentionally excludes it | Build a derived image |
Sandbox not active despite PIZZAPI_SANDBOX=basic | Missing security_opt: ["seccomp=unconfined"] | Add the security_opt (see Sandbox posture) — and re-check the trade-off before you do |
What this image is not
Section titled “What this image is not”- Not the relay/server — that’s a separate image; see Self-Hosting.
- Not one container per session — one runner container hosts many concurrent sessions.
- No Docker socket, no Docker-outside-of-Docker — the runner never needs
/var/run/docker.sock. - No host path, host keychain, or host
localhostaccess — everything the container sees is what you explicitly mounted or networked in. - No universal language-toolchain image — bring what your project needs via a derived image.