Docker¶
A multi-arch (linux/amd64, linux/arm64) image is published to GitHub Container Registry on every stable release:
Tags: vX.Y.Z, vX.Y, and latest. Prerelease and nightly builds are not published as images.
The image runs the Leo daemon (web UI, cron, agent supervision) as a non-root leo user (uid/gid 1000). It bundles tmux, git, gh, curl, jq, ripgrep, openssh-client, Node.js LTS, Python 3, mise, and the claude, codex and opencode CLIs. Harness versions are the latest at release build time and are recorded as OCI labels (studio.blackpaw.leo.claude-version, …codex-version, …opencode-version). Their built-in auto-updaters are disabled (Codex via a seeded ~/.codex/config.toml); pull a newer image to update.
Quick start¶
docker run -d --name leo \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-… \
-v leo:/home/leo \
-p 8370:8370 \
ghcr.io/blackpaw-studio/leo
Open http://localhost:8370 and sign in with the web token:
services:
leo:
image: ghcr.io/blackpaw-studio/leo:latest
restart: unless-stopped
ports:
- "8370:8370"
volumes:
- leo:/home/leo
environment:
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
# Every host or IP you browse from (see below).
LEO_ALLOWED_HOSTS: localhost,127.0.0.1,leo.lan
volumes:
leo:
Publish the same port the daemon listens on
The web UI checks the Host header, including its port, against localhost and LEO_ALLOWED_HOSTS on the daemon port (8370). Map 8370:8370. A different host port (-p 9000:8370) makes the browser send Host: …:9000, and the request is rejected with 403.
The volume¶
Everything lives in one volume mounted at /home/leo: Leo config, state and chat database (~/.leo), ~/.claude, ~/.claude.json, Codex and opencode config, mise-installed toolchains, and agent workspaces. Keep the volume and the container is disposable.
On every start the entrypoint:
- writes a starter
~/.leo/leo.yamlonly if it is missing (web UI on, bound to0.0.0.0:8370,allowed_hostsfromLEO_ALLOWED_HOSTS, no tasks or templates). An existing file is never touched. - seeds
~/.claude.jsononly if it is missing, marking onboarding complete and trusting/home/leo(Claude applies that trust to its subdirectories), so agents do not stall on first-run screens. It also seeds~/.codex/config.toml(update check off) if that is missing. - runs the scripts in
~/.leo/init.d/(below). - starts
leo servicein the foreground undertini.
Add templates and tasks through the web UI or by editing leo.yaml, the same as on a host.
Environment variables¶
| Variable | Purpose |
|---|---|
LEO_ALLOWED_HOSTS | Comma-separated hostnames/IPs the web UI accepts (default localhost,127.0.0.1). Must include every host or IP you browse from, such as a LAN IP. It is only read when the starter config is first written; afterwards edit web.allowed_hosts in leo.yaml. |
CLAUDE_CODE_OAUTH_TOKEN | Claude Pro/Max subscription token from claude setup-token. |
ANTHROPIC_API_KEY | Claude API billing instead of a subscription. On every start the entrypoint adds the key's last 20 characters to customApiKeyResponses.approved in ~/.claude.json (nothing else is removed), so Claude does not stop on its "use this API key?" dialog. |
OPENAI_API_KEY | Codex with an OpenAI API key. Codex's TUI ignores the variable unless it is logged in, so when ~/.codex/auth.json is missing the entrypoint runs codex login --with-api-key with the key (nothing is logged). Existing credentials are never replaced. |
| opencode provider env | Whatever your opencode provider needs, per its documentation. |
The daemon passes its environment to the tmux server, so every agent inherits these.
Signing in to Claude¶
Pick one:
- Token: run
claude setup-tokenon any machine with a browser, then pass the result asCLAUDE_CODE_OAUTH_TOKEN. - Interactive login:
docker exec -it leo claude, then/login. Credentials are stored in~/.claude/.credentials.jsonon the volume and survive restarts.
For Codex, either set OPENAI_API_KEY (the entrypoint logs Codex in with it on first start, as described above) or run docker exec -it leo codex login for an interactive login. Both are stored in ~/.codex/auth.json on the volume and survive restarts; a login already there is kept even if OPENAI_API_KEY is set.
Init scripts and toolchains¶
Each executable file in ~/.leo/init.d/ runs in lexical order as the leo user on every container start, after config seeding and before the daemon. Non-executable files are skipped and named in the log. A script that exits non-zero stops startup, and the container exits with that code.
Scripts run on every start, so make them idempotent. Installs into $HOME persist on the volume:
mise is preinstalled. It keeps its data under ~/.local/share/mise (on the volume) and its shims are on PATH, so no root is needed. You can also run docker exec leo mise use -g node@20 once by hand.
System packages need a derived image
Init scripts never run as root, so apt-get install does not work there. Anything that needs root belongs in a FROM layer (below).
Extending the image¶
Add system packages or pre-baked toolchains with your own Dockerfile:
FROM ghcr.io/blackpaw-studio/leo:latest
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends golang-go build-essential \
&& rm -rf /var/lib/apt/lists/*
USER leo
Keep USER leo last; the entrypoint expects to run as the non-root user.
Limitations¶
- No sshd. The image does not run an SSH server, so remote attach, follow-lead and
leo agent move/copycannot reach a containerized host unless you add one in a derived image. Attach locally withdocker exec -it leo leo attach …. - No language toolchains (Go, Rust, build-essential) are built in; use mise or a
FROMlayer. - Agents share the container. There is no per-agent sandbox; agents are separate tmux sessions under the same user.