Skip to content

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


  1. Start the container with Compose — no API key needed yet:

    compose.yml
    services:
    runner:
    image: ghcr.io/pizzaface/pizzapi-runner:0.5.61
    restart: unless-stopped
    stop_grace_period: 30s
    environment:
    PIZZAPI_RELAY_URL: https://pizza.example.com
    PIZZAPI_RUNNER_NAME: docker-runner
    volumes:
    - runner-data:/home/pizza/.pizzapi
    - ./projects:/workspace
    volumes:
    runner-data:
    Terminal window
    docker compose up -d

    Or 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
  2. 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 runner

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

  3. Confirm it registered:

    Terminal window
    docker compose exec runner pizza runner status
    # or, for the docker run form:
    docker exec pizzapi-runner pizza runner status

    Or 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 as runner-<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:7492

localhost inside the container always means the container itself — not your host, and not the relay (the relay is never this same container).


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 lock
  • config.json, settings.json, models.json
  • auth.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.


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:ro
  • PIZZAPI_WORKSPACE_ROOTS is 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 cwd outside every configured root is rejected: Requested cwd is outside allowed workspace root(s): <path>.
  • If you unset PIZZAPI_WORKSPACE_ROOTS entirely, the runner is unscoped — any path on the container filesystem is allowed. Don’t do this in a shared container.

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:/workspace

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


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:

Terminal window
docker compose exec runner pizza runner pair --force

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

Terminal window
docker compose exec runner rm /home/pizza/.pizzapi/config.json
docker compose restart runner

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:

VariableBehavior
<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_FILESeeds <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_json
secrets:
pizzapi_api_key:
file: ./secrets/pizzapi_api_key.txt
pizzapi_auth_json:
file: ./secrets/auth.json

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 (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/ollama

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

# Dockerfile
FROM ghcr.io/pizzaface/pizzapi-runner:0.5.61
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm gh \
&& rm -rf /var/lib/apt/lists/*
USER pizza

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


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.


Prefer forwarding an SSH agent socket over mounting private keys:

services:
runner:
environment:
SSH_AUTH_SOCK: /ssh-agent
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro

For git identity, either mount your host config read-only:

volumes:
- ~/.gitconfig:/home/pizza/.gitconfig:ro

or 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/gitconfig

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


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

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


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:

Terminal window
docker inspect --format='{{.State.Health.Status}}' pizzapi-runner
# for scripting / your own monitoring
docker 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: 30sdocker stop sends SIGTERM to tini (PID 1), which forwards it through the supervisor to the daemon, the same clean-shutdown path as running pizza 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 unhealthy for 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: the HEALTHCHECK (--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 runner
    docker compose up -d runner

SymptomLikely causeFix
Container unhealthyUnreachable relay, or wrong PIZZAPI_RELAY_URL schemedocker 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-pairInvalid 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 hangIssue 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 logsExpected — auto-pairing is waiting for a human to approve itOpen 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/.pizzapiBind-mounted (not named) volume owned by a different host UIDUse 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 pathUse 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 itBuild a derived image
Sandbox not active despite PIZZAPI_SANDBOX=basicMissing security_opt: ["seccomp=unconfined"]Add the security_opt (see Sandbox posture) — and re-check the trade-off before you do

  • 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 localhost access — 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.