Install z4j
z4j has two moving parts:
- A brain - 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 the brain.
The brain deploys via one of three paths (pip, Docker-SQLite, Docker-Postgres). The agents install separately via pip into your app’s venv. The brain 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).
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-brain is also accepted as a back-compat alias - both call the same entry point):
z4j check # validate config + DB connectivity + alembic headz4j status # user/project/agent/task counts, DB URL, versionz4j version # print installed z4j-brain 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 the brain by hostname or domain
Section titled “Reaching the brain by hostname or domain”The brain validates the HTTP Host: header on every request to defend against cache-poisoning attacks. Operators access the brain 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, the brain 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 the brain (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-brain: 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-brain: persisted from /root/.z4j/allowed-hosts: tasks.example.comz4j-brain: 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 the brain.
In both modes the operator-facing INFO log line carries the full detail. Correlate with the response’s request_id via journalctl -u z4j-brain / 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-brain # capture the first-boot setup URLOr skip the interactive setup entirely:
Z4J_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:1.0.5(pin for reproducibility) orz4jdev/z4j:latest(track current) - 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-brain
# 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. Helm chart on the v1.1 roadmap.
Install agents in your app
Section titled “Install agents in your app”Your web app + Celery worker need the agent packages, not the brain. 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. The brain 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”- Brain (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 the brain 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.
- Umbrella
z4j(AGPL-3.0) is a convenience installer for the all-in-one evaluator case. Declaring it AGPL is honest -pip install z4jputs AGPL code (the brain) onto your disk.
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 | Python 3.11+ (matches brain), 20 MiB overhead |
| 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 1.0 minor line. Pip resolves the newest matching version automatically when you pip install z4j-django[celery].
All v1.0.x agents talk to all v1.0.x brains on wire-protocol v1 (single envelope HMAC). Cross-version mismatches surface as a dashboard banner (“agent version mismatch”). Feature parity is not guaranteed across major versions. See versioning and the changelog.