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. z4j image is the same z4jdev/z4j across both Docker paths; the database is selected at runtime by Z4J_DATABASE_URL.

PathRuntimeDatabaseBest for
Pip (SQLite)One Python processSQLite at ~/.z4j/z4j.dbHomelab, solo dev, CI ephemerals, air-gapped Python
Docker (SQLite)One containerSQLite bundled in imageEvaluation, 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.

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 + alembic head
z4j status # user/project/agent/task counts, DB URL, version
z4j version # print installed z4j version
z4j createsuperuser # provision an admin without 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 alembic migrations explicitly
z4j audit verify # verify the HMAC-chained audit log
z4j reset-setup # mint a fresh setup URL (e.g. expired token)
z4j reset # destructive - wipe ALL data, keep schema

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

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):

LayerSourceWhen to use
1Z4J_ALLOWED_HOSTS env varProduction - 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 filePersistent operator config - add a custom domain once, it’s there forever.
4Auto-detectDefault - localhost + the system hostname / FQDN / every bound LAN IP. Always merged unless layer 1 is set.

Out of the box, with no config, z4j accepts Host: headers matching:

  • 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)

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):

Terminal window
z4j allowed-hosts add tasks.example.com
z4j 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.

Terminal window
z4j allowed-hosts add tasks.example.com api.example.com # 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.

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.com
z4j: persisted from /root/.z4j/allowed-hosts: tasks.example.com
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 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', ...]

The default Docker path. SQLite is bundled inside the image; no separate database container required.

Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
cp .env.example .env # fill Z4J_SECRET + Z4J_SESSION_SECRET
docker compose up -d
docker compose logs -f z4j # capture the first-boot setup URL

Or skip the interactive setup entirely:

Z4J_BOOTSTRAP_ADMIN_EMAIL=you@example.com
Z4J_BOOTSTRAP_ADMIN_PASSWORD=<long random>

Layer auto-HTTPS via the Caddy compose overlay:

Terminal window
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
  • 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) or z4jdev/z4j:latest (always pulls the current release; pair with digest-pinning in production)
  • Multi-arch: linux/amd64 and linux/arm64. Built natively on GitHub Actions runners, no QEMU emulation

Same image, pointed at external PostgreSQL. Production-grade: central backups, range partitioning on events, horizontal brain replicas behind a load balancer.

Terminal window
git clone https://github.com/z4jdev/z4j.git
cd z4j
cp .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.com
docker compose -f docker-compose.postgres.yml up -d
docker 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 -d

The 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.

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.

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[taskiq] # Any Python project + TaskIQ + taskiq-scheduler

Full matrix of extras available on every framework adapter:

ExtraEngineCompanion scheduler
[celery]Celerycelery-beat
[rq]RQrq-scheduler
[dramatiq]DramatiqAPScheduler
[huey]Hueyhuey-periodic
[arq]arqarq-cron
[taskiq]TaskIQtaskiq-scheduler
[all]every engineevery 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 (AGPL-3.0) is a metadata-only PyPI dist that depends on z4j. Exists so older pip install z4j invocations keep working transitively.

See License for the full split rationale.

ComponentMinimum
BrainPython 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+
NetworkAgent → Brain WebSocket (outbound from agent; brain does not need to reach the agent)

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.