Skip to content

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

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
Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
docker compose up -d
docker compose logs -f z4j # capture the first-boot setup URL

Do 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)”
Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
cp .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 -d
docker compose -f docker-compose.postgres.yml logs -f z4j

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

Layer the Caddy file on top of either compose stack:

Terminal window
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 -d

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

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:

Terminal window
-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.

  • 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: SIGTERM triggers graceful shutdown.

Migrations run automatically on container start (idempotent; safe on every boot). To run them manually:

Terminal window
docker exec -it z4j z4j migrate upgrade head
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:7700/api/v1/health"]
interval: 30s
timeout: 5s
retries: 3

Structured JSON to stdout (one event per line). Aggregate with your log shipper (Loki, Vector, Fluentd, CloudWatch).

Watch stdout on first boot to capture the setup banner:

Terminal window
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.

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 downgrade that 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.