Docker
Run the whole toolchain with no local Node install — including Studio, and one image across every environment.
The image is Node 22 (deliberately not 24 — see the Dockerfile) plus the built packages.
pnpm run docker:build # docker build -t handbook:local .Running commands
HANDBOOK_SOURCE=/src and HANDBOOK_WORK=/work are baked into the image, so you only
mount volumes — no --source or --work needed:
# free, no key
docker run --rm -v "$PWD:/src:ro" -v handbook-work:/work handbook:local analyze
# with an endpoint
docker run --rm --env-file .env \
-v "$PWD:/src:ro" -v handbook-work:/work \
handbook:local generate --detail deep
# render, then get the output back out
docker run --rm -v "$PWD:/src:ro" -v handbook-work:/work \
handbook:local render --html --agent-site --llms-txt
docker run --rm -v handbook-work:/work -v "$PWD/out:/out" \
--entrypoint cp handbook:local -R /work/handbook /out/Mounting the source read-only (:ro) is a good habit for everything except apply.
Environment variables
Docker's own --env-file layers on top of the toolchain's .env loading — both
apply, and an OPENAI_* variable passed that way is visible exactly as a shell export
would be.
docker run --rm --env-file .env -v "$PWD:/src:ro" -v handbook-work:/work \
handbook:local generateOne image, every environment
.env* files are never baked into the image — see .dockerignore. Select an
environment at run time:
docker run --rm --env-file .env.prod -e HANDBOOK_ENV=prod \
-v "$PWD:/src:ro" -v handbook-work:/work \
handbook:local generateOr mount a config file:
docker run --rm \
-v "$PWD:/src:ro" -v handbook-work:/work \
-v "$PWD/handbook.config.prod.yaml:/cfg.yaml:ro" \
handbook:local --config /cfg.yaml generateStudio
pnpm run docker:studio # docker compose up --build studioThen open http://localhost:4860.
Only localhost works — not a LAN IP, not the container name
Studio's CSRF defence checks the Host request header, not the socket. A container
must bind 0.0.0.0 for the published port to be reachable at all
(HANDBOOK_STUDIO_HOST=0.0.0.0 in the compose file), but that does not widen who may
talk to it. Browsing from the host still sends Host: localhost:4860 and passes;
a request naming a LAN IP or the studio container hostname is refused with 403
by design.
Remote access is a deliberately unimplemented, separate feature — it would need an explicit allowlist — not a bug in this defence.
services:
studio:
build: .
command: studio
environment:
HANDBOOK_STUDIO_HOST: 0.0.0.0
ports:
- '127.0.0.1:4860:4860'
volumes:
- ./:/src:ro
- handbook-work:/workPublishing the port as 127.0.0.1:4860:4860 rather than 4860:4860 keeps it off your
LAN interface as well, which is belt-and-braces on top of the Host-header check.
Volumes
| Path | Contents | Suggestion |
|---|---|---|
/src | Your source tree | Mount :ro for everything except apply |
/work | Handbooks artifacts | A named volume, so it survives between runs |
In CI
jobs:
handbook:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t handbook:ci .
- run: |
docker run --rm \
-e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \
-v "$PWD:/src:ro" -v "$PWD/work:/work" \
handbook:ci generate --detail brief
- run: |
docker run --rm -v "$PWD:/src:ro" -v "$PWD/work:/work" \
handbook:ci render --html --agent-site --llms-txt
- uses: actions/upload-artifact@v4
with: { name: handbook, path: work/handbook }analyze, render, skill and validate need no key at all, so a fork-safe workflow
can run those on every pull request and reserve generate for main.
Why Node 22 and not 24
One bundled tree-sitter grammar (Swift) aborts the process on V8 ≥ 13. Node 22 sits below
that boundary, so the image needs no special flags. On Node 24 the adapter refuses at
discovery and tells you to pass --liftoff-only; pinning the image to 22 avoids the
question entirely. See Language support.