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 HTTPS response
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 (loopback only) 0.0.0.0 (all interfaces)

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 both safe paths and exits non-zero:

z4j: REFUSING TO START.
Z4J_ENVIRONMENT=dev + bind '0.0.0.0' is unsafe:
cookies are not Secure, no HSTS, no host-header
validation. Dev defaults are localhost-only.
Pick one:
1. Localhost-only dev:
z4j serve --host 127.0.0.1
2. Public production:
Z4J_ENVIRONMENT=production \
Z4J_PUBLIC_URL=https://tasks.example.com \
Z4J_ALLOWED_HOSTS='["tasks.example.com"]' \
z4j serve --host 0.0.0.0

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 [email protected], 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 bypasses the public-URL startup check for non-browser test environments. Production still emits __Host- session, CSRF, and trusted-device cookies with the Secure attribute, so a browser over plaintext HTTP cannot establish a working authenticated session. Use TLS or an SSH tunnel.