Docker install (local + VPS)
Run a beebox in a container. The same image and compose.yaml serve a box locally as a developer install and, unchanged, on a cheap VPS as the cloud install. Everything lives in beebox/docker/.
The image bakes in the host requirements that make a from-source install fiddly: Node 24, the system binaries the agent expects (pandoc, imagemagick/magick, poppler-utils, the Excel reader python3-openpyxl + xlsx2csv, fclones on amd64, plus git/git-lfs), the native Claude Code CLI, and the beebox engine itself. You supply a box (a git repo you own, bind-mounted at ./data/box) and a Claude login.
Authentication is Claude subscription login (claude auth login), the same as everywhere else in this project. ANTHROPIC_API_KEY is ignored by design.
Prerequisites
- Docker with Compose v2 (
docker compose version≥ 2.24 — theenv_filerequired: falseform is used). - A clone of this repo (the image is built from source; there is no published image yet).
All commands below run from beebox/docker/.
Local
cd beebox/docker # 1. Build the image and initialize a box into ./data/box (one time). docker compose run --rm box bbx init /data/box # 2. Authenticate Claude (one time): open the printed URL in a browser, paste # back the code it shows. Credentials persist in a named volume. docker compose run --rm box claude auth login # 3. Start the server. docker compose up -d # 4. Open the box. open http://localhost:3210/box/
The box is served at /box/ — the slug is the basename of the box directory (/data/box → box), which is the one root (shapeVersion 3): package machinery and the underscore content areas together; the entrypoint serves it for you.
The port maps loopback-only (127.0.0.1:3210:3210) by default, so nothing outside the host can reach it until you opt in (see VPS).
First run
On the first docker compose up, the entrypoint runs a one-time box-local pnpm install (a v2 box is a package and needs its own dependencies) before serving. Give it a minute; subsequent starts are immediate. Follow along with docker compose logs -f box.
If you start the server before initializing a box, the container exits nonzero and prints the exact bbx init command to run — it never serves an empty volume.
PWA install
Once the box is open in Chrome/Edge/Safari, use the browser's "Install app" / "Add to Home Screen" to get a standalone window and (with VAPID keys set) push notifications. See .env.example for BBX_VAPID_*.
Updating
The image is built from source, so an update is a pull + rebuild:
git pull docker compose build --pull docker compose up -d
Your box (./data/box) and Claude credentials (the named volume) are untouched by a rebuild.
The box converges on start. Before serving, the container runs bbx migrate --sweep (card data) and bbx docs refresh (the box's generated agent docs, card rules, and managed skills) against /data/box, so an engine update does not leave the box on the old shape. A box with nothing pending prints nothing; the sweep commits each migration it applies to your box's git history, skips a box whose tree is dirty until next start, and stops at a migration that needs a human. None of that can stop the server coming up — watch docker compose logs box for what it did. See migrations.md.
Set BBX_SKIP_CONVERGE=1 to turn it off and run docker compose run --rm box bbx migrate --sweep yourself instead.
VPS (cloud install)
The same compose project runs on any small VPS (a $5/month box is plenty). Two ways to expose it: a public domain with automatic TLS (Caddy), or a private tailnet with zero open ports (Tailscale).
Copy .env.example (in the repo, one level up) to beebox/docker/.env and set what you need. For a public deployment set at least:
PUBLIC_URL=https://box.example.com # your domain BBX_DOMAIN=box.example.com # used by the Caddy profile
Public domain + automatic TLS (Caddy)
Point an A/AAAA record for your domain at the VPS.
Copy the Caddy config and set the domain:
cp Caddyfile.example Caddyfile # BBX_DOMAIN in .env is substituted into it at runtime
Bring up the box and the Caddy front door:
docker compose run --rm box bbx init /data/box # once docker compose run --rm box claude auth login # once docker compose --profile public up -d
Caddy binds 80/443, fetches a Let's Encrypt certificate for BBX_DOMAIN automatically, and reverse-proxies to the box (WebSocket upgrades included). The box stays on its loopback mapping inside the compose network — Caddy is the only public listener. Without --profile public, the caddy service is not started at all.
Checklist: proving a fresh public deployment
The compose/Caddy wiring is exercised by docker/smoke-vps-install.sh, but that run uses Caddy's internal CA. A real Let's Encrypt issuance needs a real domain and public 80/443. On a fresh Debian/Ubuntu VPS with Docker installed (curl -fsSL https://get.docker.com | sh) and an A record box.example.com already pointing at it:
git clone <repo-url> beebox-mono && cd beebox-mono/beebox/docker cat > .env <<'ENV' PUBLIC_URL=https://box.example.com BBX_DOMAIN=box.example.com ENV cp Caddyfile.example Caddyfile docker compose run --rm box bbx init /data/box # ~1 min after the image builds docker compose --profile public up -d docker compose logs -f box # wait for "listening"; note the one-time setup URL
Expected, in order:
docker compose logs caddyshowscertificate obtained successfullyfor the domain within ~30s (an ACMEhttp-01/tls-alpn-01challenge over the public 80/443). Afailed to get certificateline withdnsorconnection refusedmeans the A record or a firewall, not the compose file.curl -sI https://box.example.com/ | head -1from your laptop (no-k) printsHTTP/2 200or a302to the login page — a trusted cert.curl -sI http://box.example.com/ | head -1prints a308redirect to https (Caddy's default).- The setup URL from the box log opens in your browser at the real domain (it is built from
PUBLIC_URL), and creating the owner account lands you in the box. docker compose down && docker compose --profile public up -dcomes back without a second issuance (caddy-datavolume kept the cert).
To record the run, keep the certificate obtained log line, the two curl -sI first lines, and docker compose exec box claude --version.
Tailscale-only (no open ports)
Status: not yet exercised end-to-end. The smoke harness (
docker/smoke-docker.sh,docker/smoke-vps-install.sh) does not cover this path. Live proof is tracked inissues/features/2026-07-19-installation-remaining-work.mditem 2 — treat the steps below as unverified until that item records a run.
To reach the box privately over a tailnet with nothing exposed to the public internet, the box keeps its normal loopback mapping and Tailscale Serve fronts it with TLS. Which topology applies depends on where bbx runs: on a host with tailscaled, bbx tailscale setup drives the setup; in the Docker install, a sidecar container does.
Host-daemon (from-source or VPS-host install, bbx on the same machine as tailscaled):
Install Tailscale on the host and run
tailscale up.Run
bbx tailscale setup— it auto-detects the hub port from the hub config (~/.config/bbx/hub.json); pass--target <port>only for a non-standard/standalonebbx serve. It inspects the real Tailscale and serve state and tells you the single next step — logging in, approving the machine, enabling tailnet HTTPS — looping until the box is reachable athttps://<host>.<tailnet>.ts.net/.bbx tailscale statusis the read-only version of the same inspection;bbx tailscale stopremoves the mapping.Run
bbx tailscale setupas the SAME OS account the box server runs as (the service account in prod, not root or an admin). The exposure record it writes (~/.config/bbx/tailscale-exposure.json, orBBX_TAILSCALE_EXPOSURE_FILE) is bookkeeping forbbx tailscale status/stop— drift detection and scoped teardown of the serve mapping. It does NOT gate server startup (there is no unauthenticated mode left to guard); a setup run under a different user just records the mapping wherestatus/stopwon't find it.On the host-daemon path setup still fails closed: it refuses to expose a server that doesn't report an authenticated posture at its
/auth/me(Tailscale membership is never treated as authentication). Currentbbx serve/bbx hubare always authenticated — there is no unauthenticated mode anymore — so this refusal is a guard against pointing setup at the wrong port or at a legacy/foreign server.
Docker container (bbx runs inside the container, which has no tailscaled): bbx tailscale setup cannot drive a host daemon it can't reach — run inside the container it reports binary-absent, which is your cue to use the sidecar topology instead. compose.tailscale.yaml is that sidecar, following Tailscale's own sidecar container shape: a tailscale service holding the tailnet identity (TS_AUTHKEY) and a Serve config (tailscale-serve.json) that terminates HTTPS on the tailnet and proxies to box:3210 over the compose network. The box service keeps its 127.0.0.1:3210:3210 mapping unchanged; only the sidecar is tailnet-facing.
In the Tailscale admin console, create an auth key (Settings → Keys; reusable is convenient, tagged if you use ACL tags) and put it in
beebox/docker/tailscale.env— its own file, read only by the sidecar (.envis also fed to the box service, so the key must not go there):cp tailscale.env.example tailscale.env # then set TS_AUTHKEY=tskey-auth-...
In
.env, set the box's public URL now — the first-run setup link is minted from it on the first start, so it has to be right before step 3:PUBLIC_URL=https://beebox.<your-tailnet>.ts.net TS_HOSTNAME=beebox # optional; the machine name on the tailnet
Make sure HTTPS certificates are enabled for the tailnet (Serve needs them).
Bring up the box with the overlay on top of the base file — never the overlay alone:
docker compose run --rm box bbx init /data/box # once docker compose run --rm box claude auth login # once docker compose -f compose.yaml -f compose.tailscale.yaml up -d docker compose -f compose.yaml -f compose.tailscale.yaml logs -f tailscale
The sidecar logs its registration; if the key needs device approval the log says so and the admin console shows the pending machine.
Open
https://<TS_HOSTNAME>.<your-tailnet>.ts.net/from any device on the tailnet; the setup link indocker compose logs boxpoints at the same origin.
docker compose config with both files is validated; the sidecar has not yet been run against a live tailnet (the installation issue's ledger tracks that run). To change the Serve config, edit tailscale-serve.json and restart the sidecar (a single-file bind mount is not always picked up by Serve's reload).
The sidecar path has NO bbx-side guard, by construction. The sidecar applies TS_SERVE_CONFIG directly; bbx tailscale setup never runs and no exposure intent is written, so setup's posture refusal never gets a chance to inspect this topology. Authentication is always on, so the box behind the sidecar still requires a login — but keep the box loopback only (127.0.0.1:3210:3210) so the sidecar stays the only tailnet-facing path, and rely on the box's own always-on auth wall as the protection here.
Exposing a local dev environment
The steps above are for a single deployed box (bbx serve/bbx hub). A from-source checkout running the shared dev router (pnpm dev, monorepo root) is a different target: bbx tailscale setup --target <router-port> (the router's port, e.g. 3210) exposes the whole router — every worktree and box it's serving — over the tailnet through one authenticated front door, rather than a single box. Setup verifies the router's auth gate is actually live (an anonymous request over Serve must get a 401) before recording the exposure, and refuses to expose an ungated router. See bin/CLAUDE.md for the router's auth model and docs/implemented-plans/expose-dev-router.md for the full design.
Box login (on by default)
The box requires a login. Create the first (owner) account with docker compose run --rm box bbx auth create-user, or open the first-run setup URL the server prints to its log. This uses the built-in local password method — no external service. Credentials are scrypt-hashed in ~/.bbx-auth.json (mode 0600) inside the box volume.
Once that owner exists, use a box's Admin page to create a 15-minute, single-use member invite. It can be pinned to an email or left open for the recipient to enter one, and the recipient sets their own password. Signed-in local users can change their password from Settings. If a member forgets it, the owner can issue a reset link beside that member in Allowed Users; the member chooses the replacement and their existing sessions are revoked. Owner recovery still uses bbx auth set-password in the container. Invites, resets, and credentials are global to this installation, while each accepted invite grants access only to the box that issued it.
Google OAuth (optional additional method). To also allow Google sign-in (e.g. from more than one device), set a Google OAuth client:
GOOGLE_OAUTH_CLIENT_ID=... GOOGLE_OAUTH_CLIENT_SECRET=...
(Same vars as .env.example; the box's Google connectors reuse them.)
Claude auth: interactive vs headless
docker compose run --rm box claude auth login is the primary path. The container has no browser, so the CLI prints a sign-in URL (https://claude.com/cai/oauth/authorize?...) and then waits at Paste code here if prompted >:
- Open the URL on any machine with a browser and approve the sign-in.
- Anthropic's page then shows a one-time code (it does not redirect back to the container). Copy it.
- Paste it into the waiting terminal and press Enter. The CLI exits on success; the login persists in the
claude-authnamed volume.
docker compose run allocates a TTY when your terminal has one, which is what the paste step needs; over a bare SSH pipe or in a script it hangs — use the token fallback instead. A mistyped code prints Invalid code and keeps waiting; Ctrl-C and run the command again. Confirm with docker compose run --rm box claude auth status ("loggedIn": true).
If interactive login is awkward on a headless server, use the token fallback:
On a machine with a browser, run
claude setup-tokenand copy the token.Put it in
beebox/docker/.env:CLAUDE_CODE_OAUTH_TOKEN=...
docker compose up -d. The token is passed via the env-file; no interactive step needed.
Either way, the doctor-style check is docker compose run --rm box claude auth status. Serving pages needs no login; running an agent (chat, reactor) does.
Data and volumes
./data/box— the box package (a git repo you own): cards, config, runtime state, and box-authored code. Bind-mounted, so it lives on the host and survives everything Docker does. Back this up.claude-auth(named volume) — the Claude login credentials. Survivesdocker compose downand rebuilds.
Warning:
docker compose down -vdeletes named volumes, includingclaude-auth. You will need toclaude auth loginagain. Plaindocker compose down(no-v) is safe.
Ownership note (bind-mount UID)
The container runs as a fixed non-root user (UID 1000) that owns /app and /data. The image sets git config --global --add safe.directory /data/box, so git won't complain about the bind-mounted repo even when host and container UIDs differ. If your host user is not UID 1000 and you hit an ownership error, override the service user in a compose override (user: "<uid>:<gid>").
Verifying the whole path
beebox/docker/smoke-docker.sh builds the image and runs the full lifecycle (empty-volume refusal → bbx init → serve → HTTP 200 → teardown) on a throwaway compose project and a non-3210 host port. Run it after changing anything under beebox/docker/.