Skip to content

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.

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

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 (z4j-brain is also accepted as a back-compat alias - both call the same entry point):

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

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

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

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-brain # capture the first-boot setup URL

Or skip the interactive setup entirely:

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

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-brain
# 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 the brain. 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. 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:

  • 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 z4j puts AGPL code (the brain) onto your disk.

See License for the full split rationale.

ComponentMinimum
BrainPython 3.11+ (container ships 3.14), PostgreSQL 17+ (18.3 recommended), 512 MiB RAM
AgentPython 3.11+ (matches brain), 20 MiB overhead
NetworkAgent → Brain WebSocket (outbound from agent; brain does not need to reach the agent)

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.