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.
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 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.
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; print the alembic revisionz4j status # user/project/agent/task counts, DB URL, versionz4j version # print installed z4j versionz4j createsuperuser # provision the first admin without opening 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 migrations after bootstrap or explicit secret configurationz4j audit verify # verify the HMAC-chained audit logz4j reset-setup --force # delete pending tokens, preserve audit evidence; restart servez4j reset --force # destructive - wipe runtime 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 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. |
What auto-detect catches
Section titled “What auto-detect catches”Out of the box, with no config, the SQLite/dev path adds:
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) 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.
Adding a local development hostname
Section titled “Adding a local development hostname”On the pip/SQLite development path, use the persistent file when local name resolution exposes z4j under a hostname that auto-detection missed:
z4j allowed-hosts add tasks.lanz4j 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.
z4j allowed-hosts add tasks.lan api.tasks.lan # 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”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.lanz4j: persisted from /home/alice/.z4j/allowed-hosts: tasks.lanz4j: 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', ...]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 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.
git clone https://github.com/z4jdev/z4j.gitcd 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 -ddocker compose logs -f z4j # capture the first-boot setup URLOr 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.
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): https://github.com/z4jdev/z4j
- Image:
z4jdev/z4j(pin the immutable release tag from the release notes, or pin its digest, for reproducible production deploys) - Multi-arch:
linux/amd64andlinux/arm64
Docker (Postgres)
Section titled “Docker (Postgres)”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.
git clone https://github.com/z4jdev/z4j.gitcd z4jumask 077cat > .env <<EOFPOSTGRES_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.comZ4J_ALLOWED_HOSTS=["z4j.yourdomain.com"]EOFdocker compose -f docker-compose.postgres.yml up -ddocker 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 -dThe 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.
Sources
Section titled “Sources”- 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-arrayZ4J_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.requireencrypts but does not authenticate the database server unlesssslrootcertis also supplied;verify-cavalidates the chain without checking the hostname, andverify-fullvalidates both the certificate chain and the hostname.
Install agents in your app
Section titled “Install agents in your app”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.
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 z4j-taskiq z4j-taskiqscheduler # Bare Python + TaskIQExtras 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:
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-brain(AGPL-3.0) is a metadata-only PyPI dist, frozen at its legacy version, that depends onz4j. It keeps olderpip install z4j-braininvocations working.z4jitself is the full brain package.
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+ (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) |
Version compatibility
Section titled “Version compatibility”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.