Docker
The official image is z4jdev/z4j on Docker Hub. Multi-arch: linux/amd64 and linux/arm64. Pin to :{pkg.latest} for reproducible deploys; use :latest for "always the newest stable".
Sources
- Docker Hub: https://hub.docker.com/r/z4jdev/z4j
- GitHub (compose files +
.env.example): https://github.com/z4jdev/z4j - Current release: v1.10.0 (released 2026-08-28)
Three compose recipes
Section titled “Three compose recipes”The z4jdev/z4j GitHub repo ships three compose files in the root. They use the same image - the runtime mode is selected by env vars, not by tag. Pick one:
| Compose file | Database | TLS | Use case |
|---|---|---|---|
docker-compose.yml |
SQLite (bundled in image, persisted to volume) | none | Evaluation, homelab, single-team installs |
docker-compose.postgres.yml |
PostgreSQL 18 (sidecar) | none | Production self-host - small to large teams |
docker-compose.caddy.yml |
(overlay, layer on either of the above) | Caddy auto-HTTPS | Public-facing - get a real cert via Let's Encrypt |
Recipe 1 - Evaluation (SQLite)
Section titled “Recipe 1 - Evaluation (SQLite)”git clone https://github.com/z4jdev/z4j.gitcd z4jdocker compose up -ddocker compose logs -f z4j # capture the first-boot setup URLDo not copy the production-oriented .env.example for this localhost recipe:
its placeholder Z4J_PUBLIC_URL=https://z4j.example.com and
Z4J_ALLOWED_HOSTS=["z4j.example.com"] replace the Compose localhost defaults,
so the printed setup URL points at that placeholder and requests to localhost
are rejected. The SQLite Compose file also does not pass Z4J_SECRET or
Z4J_SESSION_SECRET from .env; on first boot the brain generates independent
values and persists them in the named volume.
The container sets Z4J_HOME=/data, so it creates /data/z4j.db, runs Alembic
to head, and prints the setup URL. The z4j_data volume mounted at /data
holds the database and secret.env; there is no ~/.z4j/z4j.db inside this
image.
Recipe 2 - Production self-host (PostgreSQL)
Section titled “Recipe 2 - Production self-host (PostgreSQL)”git clone https://github.com/z4jdev/z4j.gitcd z4jcp .env.example .env# Fill ALL of these before starting. The Compose file declares the three# secrets as required, so it refuses to start rather than defaulting them:# POSTGRES_PASSWORD# Z4J_SECRET openssl rand -hex 48# Z4J_SESSION_SECRET openssl rand -hex 48# Z4J_AUDIT_CHAIN_SECRET openssl rand -hex 48 (independent of the others)# Z4J_PUBLIC_URL https://z4j.example.com# Z4J_ALLOWED_HOSTS ["z4j.example.com"]docker compose -f docker-compose.postgres.yml up -ddocker compose -f docker-compose.postgres.yml logs -f z4jThis stack uses two named volumes: z4j_pg_data for the PostgreSQL data directory, and z4j_brain_state for the brain's own state at /data. Both are needed for a complete recovery, and neither is named after the container that uses it. Copy the names from the Compose file rather than guessing, because Docker creates a named volume on demand: point a backup at one that does not exist and it succeeds, having archived nothing. Take regular snapshots; see backups.
Recipe 3 - Add auto-HTTPS (Caddy overlay)
Section titled “Recipe 3 - Add auto-HTTPS (Caddy overlay)”Layer the Caddy file on top of either compose stack:
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d# or:docker compose -f docker-compose.postgres.yml -f docker-compose.caddy.yml up -dCaddy reads your domain from Z4J_PUBLIC_URL and provisions a Let's Encrypt cert automatically. DNS A/AAAA records must point at the host first.
Single-container (no compose)
Section titled “Single-container (no compose)”Quick smoke test on a VM with no compose installed:
docker run -d --name z4j \
-p 7700:7700 \
-v z4j-data:/data \
-e Z4J_SECRET=$(openssl rand -hex 32) \
-e Z4J_SESSION_SECRET=$(openssl rand -hex 32) \
z4jdev/z4j:1.10.0
docker logs -f z4j # capture setup URL
For Postgres add:
-e Z4J_DATABASE_URL=postgresql+asyncpg://z4j:pw@db:5432/z4j \For TLS, set Z4J_PUBLIC_URL=https://... and put a reverse proxy in front (Caddy, nginx, Cloudflare Tunnel, Traefik). z4j speaks plain HTTP internally; X-Forwarded-Proto is respected.
Image layout
Section titled “Image layout”- Base:
python:3.14-slim-trixie(Debian 13). - Multi-arch:
linux/amd64,linux/arm64. Built on GitHub Actions native runners (no QEMU emulation). - Size: ~63 MiB compressed, ~250 MiB uncompressed.
- Entry point:
z4j serve(FastAPI via Uvicorn). - Signal handling:
SIGTERMtriggers graceful shutdown.
Running migrations
Section titled “Running migrations”Migrations run automatically on container start (idempotent; safe on every boot). To run them manually:
docker exec -it z4j z4j migrate upgrade headHealthcheck
Section titled “Healthcheck”healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:7700/api/v1/health"] interval: 30s timeout: 5s retries: 3Structured JSON to stdout (one event per line). Aggregate with your log shipper (Loki, Vector, Fluentd, CloudWatch).
First boot
Section titled “First boot”Watch stdout on first boot to capture the setup banner:
docker compose logs -f z4j | grep -A 10 "first-boot setup"Or skip the interactive setup entirely with bootstrap env vars:
Z4J_BOOTSTRAP_ADMIN_PASSWORD=<long random>z4j provisions the admin and the setup banner is suppressed.
Upgrades and rollback
Section titled “Upgrades and rollback”Neither is repeated here, because this page carried a shorter version that disagreed with the real ones on the two points that decide whether the operation works. Where to go, and what you will actually find:
- Upgrading, and rolling the schema back: upgrades has
runnable per-stack blocks for both directions, including the one-shot
migration job and the
migrate downgradethat reverses it. - Rolling data back: upgrade and rollback carries the pip procedure and, for Docker, the constraints rather than a recipe. There is deliberately no copy-and-run Docker restore procedure, because three review rounds found three different sets of defects in the one that used to exist, and a rollback is read once, under pressure, by someone who cannot check it. Expect constraints to plan around, not steps to paste.
Worth knowing before you read either:
- The image tag comes from a Compose variable, and the two shipped files read different ones. Bumping the tag without setting the variable your file actually reads leaves you on the old image, and the upgrade reports success.
- Rolling back is not just redeploying the previous tag. Once a release has migrated the database, the older brain refuses to start against the newer schema rather than running on it, so the schema has to be rolled back first, using the newer image, which is the only one carrying the revision being reversed.