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.12.1 (released 2026-10-05)
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.
Two more services start with the brain: scheduler-certs, a one-shot that
mints the mTLS material of the scheduler channel into the z4j_scheduler_pki
volume and exits, and scheduler, the same image running z4j-scheduler serve
with the single leader backend. Both volumes belong in a backup. The
z4j-scheduler page
describes the certificates, their renewal and how to bring your own PKI.
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 all six as# required, so it refuses to start rather than defaulting any of 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 three named volumes: z4j_pg_data for the PostgreSQL data directory, z4j_brain_state for the brain's own state at /data, and z4j_scheduler_pki for the CA and the two certificates of the scheduler channel. All three are needed for a complete recovery, and none is named after the container that uses it. The stack also runs two scheduler replicas that elect a leader through PostgreSQL; docker compose -f docker-compose.postgres.yml up -d --scale scheduler=3 adds a third. 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_DOMAIN and the ACME contact address from Z4J_ACME_EMAIL; the overlay refuses to start without both. It provisions a Let's Encrypt cert automatically, and Z4J_PUBLIC_URL defaults to https://$Z4J_DOMAIN unless you set it. 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.12.1
docker logs -f z4j # capture setup URL
The image bakes Z4J_PUBLIC_URL=http://localhost:7700 and Z4J_ALLOWED_HOSTS=["localhost","127.0.0.1"], so this container answers only requests addressed to localhost; browsing it by the VM's name or address gets 400 Bad Request: invalid Host header. Either publish the port on loopback only (-p 127.0.0.1:7700:7700) and reach it through an SSH tunnel, or add -e Z4J_PUBLIC_URL=http://<vm-host>:7700 -e Z4J_ALLOWED_HOSTS='["<vm-host>","localhost","127.0.0.1"]' (keep the loopback entries so the image's own healthcheck still passes).
For Postgres add:
-e Z4J_DATABASE_URL='postgresql+asyncpg://z4j:pw@db:5432/z4j?sslmode=require' \ -e Z4J_AUDIT_CHAIN_SECRET=$(openssl rand -hex 32) \The image runs with Z4J_ENVIRONMENT=production, and against PostgreSQL the brain then refuses to start in two cases. The URL must carry exactly one sslmode, set to require, verify-ca or verify-full (the verify modes also need sslrootcert); only Z4J_REQUIRE_DB_SSL=false, for a database on a private network, relaxes that. And Z4J_AUDIT_CHAIN_SECRET must be set explicitly, because the packaged secret.env bootstrap covers self-contained SQLite only.
For TLS, set Z4J_PUBLIC_URL=https://... and put a reverse proxy in front (Caddy, nginx, Cloudflare Tunnel, Traefik). z4j speaks plain HTTP internally; have the proxy forward X-Forwarded-For and list it in Z4J_TRUSTED_PROXIES so the brain attributes requests to the real client address.
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). - Entry point:
z4j serve(FastAPI via Uvicorn). - Signal handling:
SIGTERMtriggers graceful shutdown.
Verifying the signature
Section titled “Verifying the signature”The release workflow signs the multi-arch index and every architecture digest keylessly with cosign, and attaches build provenance. Verify before you deploy:
cosign verify docker.io/z4jdev/z4j:<tag> \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp '^https://github\.com/z4jdev/z4j/\.github/workflows/publish-docker\.yml@'Keyless signatures carry no long-lived key: the certificate identity is the workflow that built the image, which is what the two flags pin.
The same workflow attaches two attestations to the published index: build
provenance, and an SPDX SBOM that syft generates from the bytes the registry
serves (the linux/amd64 manifest; the arm64 manifest is built from the same
sdist with the same locked versions). Both are pushed to the registry beside
the image and recorded in GitHub's attestation log, where
gh attestation verify oci://docker.io/z4jdev/z4j:<tag> --owner z4jdev checks
them. The SBOM file is also an artifact of the publishing workflow run.
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”The image carries no wget, no curl and no package installer (the build removes uv and pip), so you cannot install an extra into a running container; build a derived image instead. What it does ship: the PostgreSQL driver, the scheduler channel and the S3 export sink, so Z4J_EXPORT_SINK=s3 (see audit exports) works from the published image without a derived build. This is the check the shipped Compose files use, and the image's own HEALTHCHECK runs the same command:
healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:7700/api/v1/health',timeout=3).status==200 else 1)"] interval: 30s timeout: 5s retries: 3 start_period: 30sStructured 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_EMAIL=you@example.comZ4J_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.