Skip to content

Dev vs production mode

Z4J_ENVIRONMENT controls a small but consequential set of security defaults. Picking the right one is a one-time decision; getting it wrong is silent until something bad happens.

The value that relaxes anything is the exact string dev. Anything else, including development, is treated as production. That is the safe direction, but it is worth knowing before you spend an afternoon on it. On the local SQLite z4j serve path, leaving the variable unset lets the CLI choose dev unless the production-shaped auto-promotion below applies.

Setting dev production
Session + CSRF cookies Secure: false, no __Host- prefix Secure: true, __Host- prefix (browser-enforced isolation)
HSTS header not sent sent on every response when Z4J_PUBLIC_URL starts with https://
Host validation Z4J_ALLOWED_HOSTS may be empty Z4J_ALLOWED_HOSTS must be set; brain refuses to start otherwise
Z4J_PUBLIC_URL scheme any must start with https://; brain refuses to start otherwise
--debug-host-errors flag allowed (verbose 400s with internal IPs) refused at startup
Default bind host 127.0.0.1 on the SQLite serve path when Z4J_BIND_HOST is unset; with PostgreSQL the default stays 0.0.0.0 and z4j serve refuses to start unless the bind is loopback 0.0.0.0 (all interfaces)
Scheduler gRPC listener fails to start logged, the brain keeps serving the brain refuses to bind

Every difference is a security relaxation. Dev mode is meant for the laptop running pip install z4j && z4j serve, not for anything reachable from the network.

On the local SQLite z4j serve path, you don't have to set Z4J_ENVIRONMENT=production by hand. When the variable is unset, that CLI path auto-promotes when both of these are true:

  • Z4J_PUBLIC_URL starts with https://
  • Z4J_ALLOWED_HOSTS is set explicitly

Either one alone is ambiguous (you might be testing TLS locally or pre-populating an allow-list before flipping the switch). Both together express production intent for this CLI defaulting step. PostgreSQL and other production deployments should set the environment explicitly rather than relying on the SQLite quickstart's auto-detection.

You'll see the decision in the boot log:

z4j: auto-promoting Z4J_ENVIRONMENT=production (detected https Z4J_PUBLIC_URL + explicit Z4J_ALLOWED_HOSTS). Set Z4J_ENVIRONMENT=dev to override.

Bare pip install z4j && z4j serve (the SQLite path) binds to 127.0.0.1. That's the right default for laptop dev: nothing leaks beyond loopback, and the dashboard at http://localhost:7700/ works exactly as expected.

Dev mode cannot bind to a LAN-reachable address. For cross-device browser access, use production-shaped configuration with a TLS-terminating reverse proxy, or keep the brain on loopback and use an SSH tunnel.

Misconfiguration: the fail-closed startup refusal

Section titled “Misconfiguration: the fail-closed startup refusal”

z4j serve refuses to start when Z4J_ENVIRONMENT=dev AND the bind host is not loopback. That combination would expose dev-mode cookies (no Secure, no __Host- prefix), no HSTS, and verbose host-rejection responses to anyone who could reach the port. z4j prints the three ways out and exits with status 2:

z4j: REFUSING TO START.
Z4J_ENVIRONMENT=dev + bind '0.0.0.0' is unsafe:
in dev mode the brain skips Secure-cookie / HSTS /
host-header validation, so binding to a non-loopback
address would expose those weakened defaults to
whatever can reach this socket.
Pick one of:
1. Localhost-only dev (the default for ad-hoc work):
z4j serve --host 127.0.0.1
2. Docker / k8s dev stack on an internal network:
Z4J_ENVIRONMENT=production \
Z4J_PUBLIC_URL=http://localhost:7700 \
Z4J_ALLOWED_HOSTS='["localhost","127.0.0.1"]' \
Z4J_ALLOW_HTTP_PUBLIC_URL=true \
z4j serve --host 0.0.0.0
(Z4J_ALLOW_HTTP_PUBLIC_URL=true is the explicit
opt-in for production-shaped config without TLS;
only safe on a trusted internal network.)
3. Public production with TLS:
Z4J_ENVIRONMENT=production \
Z4J_PUBLIC_URL=https://tasks.example.com \
Z4J_ALLOWED_HOSTS='["tasks.example.com"]' \
z4j serve --host 0.0.0.0
Because Z4J_ENVIRONMENT is explicitly dev here, those
values will not auto-promote it. Set production as above,
or unset Z4J_ENVIRONMENT on the local SQLite serve path
to allow production-shaped auto-detection.
See: https://docs.z4j.com/operations/dev-vs-production

Production deployment behind a reverse proxy

Section titled “Production deployment behind a reverse proxy”

Cloudflare Tunnel, Caddy, nginx, and Traefik all share the same three-env-var pattern. Set them on your systemd unit (or whatever process supervisor runs the brain):

Terminal window
sudo systemctl edit z4j
[Service]
Environment=Z4J_ENVIRONMENT=production
Environment=Z4J_PUBLIC_URL=https://tasks.example.com
Environment=Z4J_ALLOWED_HOSTS=["tasks.example.com"]

Replace tasks.example.com with the public DNS name the reverse proxy uses to reach z4j. If multiple names land on this brain, JSON-encode the list: ["tasks.example.com","tasks-internal.example.com"].

Apply and verify:

Terminal window
sudo systemctl daemon-reload
sudo systemctl restart z4j
z4j doctor

z4j doctor should print all-green with no env-mode warning.

For a browser on another machine, pick one of these supported paths:

  • Recommended: add a reverse proxy that terminates TLS (Caddy is one binary plus a five-line config), then follow the production deployment recipe above.
  • Loopback-only: bind to 127.0.0.1 and use SSH tunneling for remote access: ssh -L 7700:127.0.0.1:7700 user@homelab.local, then open http://localhost:7700/ on your laptop.

Do not use Z4J_ENVIRONMENT=production plus Z4J_ALLOW_HTTP_PUBLIC_URL=true as a browser-facing LAN mode. That flag only skips the https:// requirement on Z4J_PUBLIC_URL; it changes nothing else. Production still emits __Host- session, CSRF, and trusted-device cookies with the Secure attribute, so a browser reaching the brain by a LAN address or hostname over plaintext HTTP cannot keep a session.

The one browser case that does work is http://localhost on the same machine, which browsers treat as a secure context; that is what the packaged Docker stack relies on, and it is why the startup banner offers the flag for an internal-network dev stack. Agents and API clients are unaffected either way. For any other machine, use TLS or an SSH tunnel.