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. z4j image is the same z4jdev/z4j across both Docker paths; the database is selected at runtime by Z4J_DATABASE_URL.
Pick your path
Section titled “Pick your path”| 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 bundled in image | Evaluation, small teams (2-20), single-host production |
| Docker (Postgres) | Two containers (brain + Postgres) | PostgreSQL 17+ | Self-hosted production, compliance-sensitive, horizontal scale |
All three paths ship the same feature set. Start with the lightest path that meets your needs; move up when you outgrow it. Data migrates cleanly between tiers (same schema, same audit chain).
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.
Pip (SQLite)
Section titled “Pip (SQLite)”pip install z4jz4j 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:
z4j check # validate config + DB connectivity + alembic headz4j status # user/project/agent/task counts, DB URL, versionz4j version # print installed z4j versionz4j createsuperuser # provision an admin without the setup URLz4j changepassword # reset a user password from the CLIz4j allowed-hosts ... # manage the persistent Host: header allow-list (see below)z4j migrate upgrade head # run alembic migrations explicitlyz4j audit verify # verify the HMAC-chained audit logz4j reset-setup # mint a fresh setup URL (e.g. expired token)z4j reset # destructive - wipe ALL data, keep schemaSee the CLI reference for the full command list and flags.
Reaching z4j by hostname or domain
Section titled “Reaching z4j by hostname or domain”z4j validates the HTTP Host: header on every request to defend against cache-poisoning attacks. Operators access z4j through several patterns - localhost, the server’s hostname, a LAN IP, a Tailscale node name, a public DNS name behind a reverse proxy. Each one needs to be in the allow-list.
z4j handles this through four layers, in precedence order (highest first):
| Layer | Source | When to use |
|---|---|---|
| 1 | Z4J_ALLOWED_HOSTS env var | Production - pin the exact set of names, no surprises. Replaces auto-detect entirely. |
| 2 | --allowed-host <name> CLI flag (repeatable) | Ad-hoc / testing - “for this one run, also accept this name”. |
| 3 | ~/.z4j/allowed-hosts file | Persistent operator config - add a custom domain once, it’s there forever. |
| 4 | Auto-detect | Default - localhost + the system hostname / FQDN / every bound LAN IP. Always merged unless layer 1 is set. |
What auto-detect catches
Section titled “What auto-detect catches”Out of the box, with no config, z4j accepts Host: headers matching:
localhost,127.0.0.1,[::1]socket.gethostname()(whatuname -nshows)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.xLAN IPs even on Debian-default setups)
That covers homelab / single-server / Tailscale / direct-LAN-access scenarios without any config.
Adding a custom domain (the most common case)
Section titled “Adding a custom domain (the most common case)”You’re behind a reverse proxy or have a public DNS name pointed at z4j (e.g. tasks.example.com):
z4j allowed-hosts add tasks.example.comz4j allowed-hosts list # confirm# Restart `z4j serve` to pick up the change.The host is persisted to ~/.z4j/allowed-hosts (one host per line, # comments allowed). Edits take effect on the next z4j serve start. You can manage the file by hand or via the CLI - they’re equivalent.
z4j allowed-hosts add tasks.example.com api.example.com # multiple at oncez4j allowed-hosts remove old-name.example.com # idempotentz4j allowed-hosts path # prints ~/.z4j/allowed-hostsProduction: pin via env var
Section titled “Production: pin via env var”In production deployments where the operator wants to know exactly what’s whitelisted (no auto-detect surprises), set Z4J_ALLOWED_HOSTS explicitly:
Z4J_ALLOWED_HOSTS='["brain.example.com","brain-internal.example.com"]' z4j serveThe env var replaces the auto-detect set. The CLI file is also ignored when the env var is set.
Boot banner
Section titled “Boot banner”z4j serve prints the resolved allow-list on startup so you can confirm what will be accepted:
z4j: serving on 0.0.0.0:7700, accepting Host headers: localhost, 127.0.0.1, [::1], your-server, your-server.lan, 192.168.1.42, tasks.example.comz4j: persisted from /root/.z4j/allowed-hosts: tasks.example.comz4j: 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 HTTP 400 with {"error":"invalid_host", ...}. The level of detail depends on the environment:
- dev mode (default for SQLite/pip path): full detail - includes the rejected host, the current allow-list, and a concrete fix command. Useful for local development where the operator IS the HTTP client.
- non-dev mode (Postgres production): minimal body -
{"error":"invalid_host","message":"Bad Request: invalid Host header.","request_id":"..."}. The verbose detail is suppressed to avoid leaking internal hostnames to crawlers / attackers / anyone who can hit z4j.
In both modes 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', ...]Sources
Section titled “Sources”- PyPI: https://pypi.org/project/z4j/
- GitHub: https://github.com/z4jdev/z4j
Docker (SQLite)
Section titled “Docker (SQLite)”The default Docker path. SQLite is bundled inside the image; no separate database container required.
git clone https://github.com/z4jdev/z4j.gitcd z4jcp .env.example .env # fill Z4J_SECRET + Z4J_SESSION_SECRETdocker compose up -ddocker compose logs -f z4j # capture the first-boot setup URLOr skip the interactive setup entirely:
Z4J_BOOTSTRAP_ADMIN_EMAIL=you@example.comZ4J_BOOTSTRAP_ADMIN_PASSWORD=<long random>Layer auto-HTTPS via the Caddy compose overlay:
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -dSources
Section titled “Sources”- Docker Hub: https://hub.docker.com/r/z4jdev/z4j
- GitHub (compose files +
.env.example): https://github.com/z4jdev/z4j - Image:
z4jdev/z4j:<X.Y.Z>(pin a specific version for reproducible deploys) orz4jdev/z4j:latest(always pulls the current release; pair withdigest-pinning in production) - Multi-arch:
linux/amd64andlinux/arm64. Built natively on GitHub Actions runners, no QEMU emulation
Docker (Postgres)
Section titled “Docker (Postgres)”Same image, pointed at external PostgreSQL. Production-grade: central backups, range partitioning on events, horizontal brain replicas behind a load balancer.
git clone https://github.com/z4jdev/z4j.gitcd z4jcp .env.example .env# Fill .env:# POSTGRES_PASSWORD=<long random># Z4J_SECRET=$(openssl rand -hex 32)# Z4J_SESSION_SECRET=$(openssl rand -hex 32)# Z4J_PUBLIC_URL=https://z4j.yourdomain.com# Z4J_ALLOWED_HOSTS=z4j.yourdomain.comdocker compose -f docker-compose.postgres.yml up -ddocker compose -f docker-compose.postgres.yml logs -f z4j
# Add Caddy auto-HTTPS:docker compose -f docker-compose.postgres.yml -f docker-compose.caddy.yml up -dThe image selects SQLite vs Postgres based on Z4J_DATABASE_URL at runtime. Same binary, same migrations, same audit chain. Graduate from SQLite to Postgres by pointing Z4J_DATABASE_URL at your Postgres instance and restarting.
Sources
Section titled “Sources”- Docker Hub: https://hub.docker.com/r/z4jdev/z4j
- GitHub: https://github.com/z4jdev/z4j
- Kubernetes: see the Kubernetes guide for the hand-rolled manifest.
Install agents in your app
Section titled “Install agents in your app”Your web app + Celery worker need the agent packages, not z4j. Pick your framework and your engine; each extra pulls the engine adapter AND its companion scheduler in one shot.
pip install z4j-django[celery] # Django + Celery + celery-beatpip install z4j-flask[rq] # Flask + RQ + rq-schedulerpip install z4j-fastapi[arq] # FastAPI + arq + arq-cronpip install z4j-bare[taskiq] # Any Python project + TaskIQ + taskiq-schedulerFull matrix of extras available on every framework adapter:
| Extra | Engine | Companion scheduler |
|---|---|---|
[celery] | Celery | celery-beat |
[rq] | RQ | rq-scheduler |
[dramatiq] | Dramatiq | APScheduler |
[huey] | Huey | huey-periodic |
[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:
Why the split matters
Section titled “Why the split matters”- 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(AGPL-3.0) is a metadata-only PyPI dist that depends onz4j. Exists so olderpip install z4jinvocations keep working transitively.
See License for the full split rationale.
Minimum requirements
Section titled “Minimum requirements”| Component | Minimum |
|---|---|
| Brain | Python 3.11+ (container ships 3.14), PostgreSQL 17+ (18.3 recommended), 512 MiB RAM |
Agent (z4j-bare) | Python 3.11+ (matches brain), 20 MiB overhead |
| Agent (framework / engine adapters) | Python 3.10+ |
| Network | Agent → Brain WebSocket (outbound from agent; brain does not need to reach the agent) |
Version compatibility
Section titled “Version compatibility”z4j packages ship at independent versions on a shared minor line. Pip resolves the newest matching version automatically when you pip install z4j-django[celery].
All agents and brains within the same major line talk over the same wire protocol. Cross-version mismatches inside a major surface as a dashboard banner; cross-major mismatches close the WebSocket with 4426 (Upgrade Required). 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.