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.
What changes between dev and production
Section titled “What changes between dev and production”| 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.
Auto-promotion to production
Section titled “Auto-promotion to production”You don’t have to set Z4J_ENVIRONMENT=production by hand. z4j auto-promotes when both of these are true:
Z4J_PUBLIC_URLstarts withhttps://Z4J_ALLOWED_HOSTSis 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 prove production intent, so z4j honors it without making you set a third env var.
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.Localhost dev defaults to 127.0.0.1
Section titled “Localhost dev defaults to 127.0.0.1”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.
To bind to a LAN-reachable address while staying in dev mode (for cross-device testing on a trusted network), pass --host 0.0.0.0 explicitly and set Z4J_ENVIRONMENT=dev explicitly. Without both, z4j refuses; the auto-defaulting only fires when the operator hasn’t set the env var.
The recommended path for cross-device access is still production mode with a TLS-terminating reverse proxy in front of z4j, so cookies can use Secure.
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.0Production 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):
sudo systemctl edit z4j[Service]Environment=Z4J_ENVIRONMENT=productionEnvironment=Z4J_PUBLIC_URL=https://tasks.example.comEnvironment=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:
sudo systemctl daemon-reloadsudo systemctl restart z4jz4j doctorz4j doctor should print all-green with no env-mode warning.
Homelab on a trusted LAN, no reverse proxy
Section titled “Homelab on a trusted LAN, no reverse proxy”If z4j is reachable directly on your LAN with no TLS terminator in front, pick one:
- 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.1and use SSH tunneling for remote access:ssh -L 7700:127.0.0.1:7700 [email protected], then openhttp://localhost:7700/on your laptop. - Force dev mode on LAN bind: set
Z4J_ENVIRONMENT=devAND pass--host 0.0.0.0explicitly. z4j starts becauseZ4J_ENVIRONMENT=devis no longer auto-defaulted in this case. Cookies staySecure: false, so accept the LAN-eavesdropping risk before going this way.
Related
Section titled “Related”- Allowed hosts for how the host-validation list is built and persisted
- Service-user deployments for gunicorn / uvicorn under
www-data - Troubleshooting for the symptoms operators see when something goes wrong