Skip to content

Install z4j

z4j has two moving parts:

  • z4j -- dashboard, API, migrations, audit log. One Python process or one Docker image.
  • Agents -- thin pip packages that run inside your app and stream task-queue events to z4j.

z4j deploys via one of three paths (pip, Docker-SQLite, Docker-Postgres). The agents install separately via pip into your app's venv. The z4j image is the same z4jdev/z4j across both Docker paths. The shipped PostgreSQL Compose file supplies structured Z4J_DATABASE_* values, while custom deployments can use either those fields or Z4J_DATABASE_URL.

Path Runtime Database Best for
Pip (SQLite) One Python process SQLite at ~/.z4j/z4j.db Homelab, solo dev, CI ephemerals, air-gapped Python
Docker (SQLite) One container SQLite in the /data volume Evaluation, homelab, light single-host use
Docker (Postgres) Two containers (brain + Postgres) PostgreSQL 17+ Self-hosted production, multi-project, horizontal scale

All three paths expose the same dashboard and API surface, but they do not have the same storage and scaling characteristics. SQLite forces one brain worker and does not have PostgreSQL's LISTEN/NOTIFY, range partitions, or specialised indexes. Start with the lightest path that meets your needs, but treat a move between SQLite and PostgreSQL as a new installation: changing backends does not copy data or audit history.

New to z4j? The z4j.com landing covers the high-level feature tour, and the comparison page explains how z4j stacks up against Flower, rq-dashboard, and Datadog.

Terminal window
pip install z4j
z4j serve
# Open http://localhost:7700 and follow the setup URL printed to stderr.

First boot auto-mints HMAC secrets to ~/.z4j/secret.env, runs alembic migrations, creates ~/.z4j/z4j.db, and prints a one-time setup URL to stderr.

The CLI is z4j:

Terminal window
z4j check # validate config + DB connectivity; print the alembic revision
z4j status # user/project/agent/task counts, DB URL, version
z4j version # print installed z4j version
z4j createsuperuser # provision the first admin without opening the setup URL
z4j changepassword # reset a user password from the CLI
z4j allowed-hosts ... # manage the persistent Host: header allow-list (see below)
z4j migrate upgrade head # run migrations after bootstrap or explicit secret configuration
z4j audit verify # verify the HMAC-chained audit log
z4j reset-setup --force # delete pending tokens, preserve audit evidence; restart serve
z4j reset --force # destructive - wipe runtime data, keep schema

See the CLI reference for the full command list and flags.

z4j validates the HTTP Host: header on ordinary HTTP requests that carry one, to defend against cache-poisoning attacks. The unauthenticated /api/v1/health and /api/v1/health/ready probes, requests with no Host: header, and WebSocket connections bypass this middleware. Keep equivalent host validation at your reverse proxy rather than treating this allow-list as the only boundary.

The sources combine as follows:

Source Behaviour
Z4J_ALLOWED_HOSTS Replaces auto-detect and the persistent file. Required for PostgreSQL production. Use a JSON array.
--allowed-host <name> Repeatable and added to the resolved environment value for this run.
$Z4J_HOME/allowed-hosts Merged only on the SQLite path when Z4J_ALLOWED_HOSTS is unset. Default path: ~/.z4j/allowed-hosts.
Auto-detect Runs only on that same SQLite/unset-env path.

Out of the box, with no config, the SQLite/dev path adds:

  • localhost, 127.0.0.1, [::1]
  • socket.gethostname() (what uname -n shows)
  • socket.getfqdn() (full DNS name, e.g. Tailscale's <host>.<tailnet>.ts.net)
  • Every IP returned by socket.gethostbyname_ex(hostname) (multi-interface boxes with a proper /etc/hosts)
  • The primary outbound interface IP (UDP-socket trick - picks up 192.168.x.x LAN IPs even on Debian-default setups)
  • testserver (dev mode only)

This only controls validation. Dev mode still binds to 127.0.0.1 and refuses --host 0.0.0.0; direct LAN access requires production-shaped configuration with an explicit public URL and Z4J_ALLOWED_HOSTS. Any host is also accepted on the two health endpoints, and an HTTP/1.0 request with no Host: header is not rejected.

On the pip/SQLite development path, use the persistent file when local name resolution exposes z4j under a hostname that auto-detection missed:

Terminal window
z4j allowed-hosts add tasks.lan
z4j allowed-hosts list # confirm
# Restart `z4j serve` to pick up the change.

The host is persisted to $Z4J_HOME/allowed-hosts (default ~/.z4j/allowed-hosts; one host per line). Edits take effect on the next z4j serve start. Comments are accepted when reading, but the next CLI add or remove rewrites the complete file and discards comments and blank-line grouping.

This file is ignored when Z4J_ALLOWED_HOSTS is set and on the PostgreSQL path. For a public domain or reverse-proxy deployment, use production-shaped configuration instead: set an HTTPS Z4J_PUBLIC_URL and set Z4J_ALLOWED_HOSTS to a JSON array containing every accepted host.

Terminal window
z4j allowed-hosts add tasks.lan api.tasks.lan # multiple at once
z4j allowed-hosts remove old-name.example.com # idempotent
z4j allowed-hosts path # prints ~/.z4j/allowed-hosts

In production deployments where the operator wants to know exactly what's whitelisted (no auto-detect surprises), set Z4J_ALLOWED_HOSTS explicitly:

Terminal window
Z4J_ALLOWED_HOSTS='["brain.example.com","brain-internal.example.com"]' z4j serve

The env var replaces the auto-detect set. The CLI file is also ignored when the env var is set.

The first startup line prints settings.allowed_hosts. A later persisted from line reports what is on disk even when that file is being ignored. In dev mode the middleware also accepts its local defaults, including testserver, without adding all of them to the first line.

z4j: serving on 127.0.0.1:7700, accepting Host headers: localhost, 127.0.0.1, [::1], your-server, your-server.lan, 192.168.1.42, tasks.lan
z4j: persisted from /home/alice/.z4j/allowed-hosts: tasks.lan
z4j: to add more, run `z4j allowed-hosts add <name>` (persists across restarts).

What happens when a request fails the check

Section titled “What happens when a request fails the check”

The middleware returns the same minimal HTTP 400 body by default in every environment:

{"error":"invalid_host","message":"Bad Request: invalid Host header.","request_id":"..."}

For local diagnosis, z4j serve --debug-host-errors adds the rejected host, allow-list, and fix command to the response. The CLI refuses that option outside dev mode.

The operator-facing INFO log line carries the full detail. Correlate with the response's request_id via journalctl -u z4j / docker logs / your log shipper:

INFO z4j: rejected request - Host header 'evil.example.com' is not in the allow-list. Persist it via `z4j allowed-hosts add evil.example.com` or restart with `z4j serve --allowed-host evil.example.com`. Current allow-list: ['localhost', '127.0.0.1', ...]

The default Docker path. SQLite support is bundled into the image; no separate database container is required. The database, generated secrets, and logs live in the named z4j_data volume mounted at /data, not in the image. Preserve and back up that volume.

Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
# No .env file is required for loopback-only evaluation. The Compose defaults
# use http://localhost:7700 and allow only localhost/127.0.0.1. The brain
# generates independent secrets and persists them in /data/secret.env.
docker compose up -d
docker compose logs -f z4j # capture the first-boot setup URL

Or skip the interactive setup entirely:

Z4J_BOOTSTRAP_ADMIN_PASSWORD=<long random>

For automatic HTTPS, first set Z4J_DOMAIN and Z4J_ACME_EMAIL in .env, point the domain's A/AAAA record at this host, and make ports 80 and 443 reachable. The overlay removes the brain's own localhost port binding, and Compose refuses to start it if either variable is missing.

Terminal window
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d

Same image, pointed at PostgreSQL. The events table is range-partitioned on this path. The shipped Compose file runs one brain; a multi-replica deployment needs your own manifest and session affinity for the agent WebSocket.

Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
umask 077
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 48)
Z4J_SECRET=$(openssl rand -hex 48)
Z4J_SESSION_SECRET=$(openssl rand -hex 48)
Z4J_AUDIT_CHAIN_SECRET=$(openssl rand -hex 48)
Z4J_PUBLIC_URL=https://z4j.yourdomain.com
Z4J_ALLOWED_HOSTS=["z4j.yourdomain.com"]
EOF
docker compose -f docker-compose.postgres.yml up -d
docker compose -f docker-compose.postgres.yml logs -f z4j
# Add Caddy auto-HTTPS:
# First also set Z4J_DOMAIN and Z4J_ACME_EMAIL in .env and open ports 80/443.
docker compose -f docker-compose.postgres.yml -f docker-compose.caddy.yml up -d

The image runs the same binary and migration chain on both databases. The PostgreSQL Compose file supplies structured Z4J_DATABASE_HOST, Z4J_DATABASE_PORT, Z4J_DATABASE_USER, Z4J_DATABASE_PASSWORD, and Z4J_DATABASE_NAME values, from which z4j derives Z4J_DATABASE_URL. The resulting schemas are intentionally dialect-specific: PostgreSQL additionally gets range partitions, extensions, triggers, and specialised indexes. Do not plan a cutover around a portable schema.

Switching the backend does not move your data. It selects a different database and migrates that one to head, so a fresh PostgreSQL instance comes up with no users, projects, schedules, or audit history. z4j backup and z4j restore are per-backend and cannot load a SQLite archive into PostgreSQL. Treat this as a new installation and keep the SQLite volume. Extract data with the cursor-paginated JSON API before cutover. In particular, the audit file export is capped at 50,000 newest rows per project and cannot export an older slice, and there is no general import on the other side.

  • Docker Hub: https://hub.docker.com/r/z4jdev/z4j
  • GitHub: https://github.com/z4jdev/z4j
  • Kubernetes: the Kubernetes guide is a starting manifest. Add Z4J_AUDIT_CHAIN_SECRET, JSON-array Z4J_ALLOWED_HOSTS, and a PostgreSQL URL with verified TLS before deploying it. Mount the provider CA in the brain container and use, for example, ?sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca.pem. require encrypts but does not authenticate the database server unless sslrootcert is also supplied; verify-ca validates the chain without checking the hostname, and verify-full validates both the certificate chain and the hostname.

Your web app and queue workers need the agent packages, not z4j. On z4j-django, z4j-flask, and z4j-fastapi, each engine extra installs the engine adapter and its companion scheduler. z4j-bare has no engine extras; install its adapter packages directly.

Terminal window
pip install z4j-django[celery] # Django + Celery + celery-beat
pip install z4j-flask[rq] # Flask + RQ + rq-scheduler
pip install z4j-fastapi[arq] # FastAPI + arq + arq-cron
pip install z4j-bare z4j-taskiq z4j-taskiqscheduler # Bare Python + TaskIQ

Extras available on the Django, Flask, and FastAPI framework adapters:

Extra Engine Companion scheduler
[celery] Celery celery-beat
[rq] RQ rq-scheduler
[dramatiq] Dramatiq APScheduler
[huey] Huey huey-periodic (read-only decorator discovery)
[arq] arq arq-cron
[taskiq] TaskIQ taskiq-scheduler
[all] every engine every scheduler (CI / kitchen sink)

Every agent package is Apache 2.0. Nothing you import into your app carries any copyleft obligation. z4j runs elsewhere (separate Docker container, separate host) and your agents connect to it over a WebSocket.

See the framework quickstart for your stack:

  • z4j (AGPL-3.0) runs as infrastructure. Most organisations deploy it on an isolated host or container. Nothing in your application code links against it -- agents talk to z4j over the network.
  • Agents (Apache 2.0) live inside your application process. They need to be freely usable in any context -- proprietary code, closed-source deployment, regulated environments. Apache 2.0 is the lowest-friction permissive license with a patent grant.
  • Compatibility shim z4j-brain (AGPL-3.0) is a metadata-only PyPI dist, frozen at its legacy version, that depends on z4j. It keeps older pip install z4j-brain invocations working. z4j itself is the full brain package.

See License for the full split rationale.

Component Minimum
Brain Python 3.11+ (container ships 3.14), PostgreSQL 17+ (the shipped Compose file runs the current 18 line); size memory and storage from measurements of your workload
Agent (z4j-bare) Python 3.11+ (matches brain); measure in-process overhead with your adapters and workload
Agent (framework / engine adapters) Python 3.11+
Network Agent → Brain WebSocket (outbound from agent; brain does not need to reach the agent)

Every package in a release wave ships at the same version. Each package floors its z4j dependencies at that wave and uses a <2 ceiling, so a fresh install resolves a coherent wave. That ceiling is the major, not the minor: pip can accept mixed-minor installations, so upgrade or pin all z4j packages together.

Close code 4426 means the agent advertised an unsupported wire protocol, not an incompatible package major. The current brain accepts protocol 2. Package-version skew remains connected: a sufficiently old agent produces a brain warning and a per-agent version badge, not a dashboard-wide banner or a 4426 close. See versioning and the changelog for the current numbers.

For the per-adapter compatibility matrix (minimum Django / Flask / FastAPI / Celery / RQ / Dramatiq / Huey / arq / TaskIQ versions, upper caps where breaking-majors exist, Python floors, and copy-pasteable pip install pins), see reference / compatibility.