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.

Settingdevproduction
Session + CSRF cookiesSecure: false, no __Host- prefixSecure: true, __Host- prefix (browser-enforced isolation)
HSTS headernot sentsent on every HTTPS response
Host validationZ4J_ALLOWED_HOSTS may be emptyZ4J_ALLOWED_HOSTS must be set; brain refuses to start otherwise
Z4J_PUBLIC_URL schemeanymust start with https://; brain refuses to start otherwise
--debug-host-errors flagallowed (verbose 400s with internal IPs)refused at startup
Default bind host127.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.

You don’t have to set Z4J_ENVIRONMENT=production by hand. z4j 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 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.

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

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.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.
  • Force dev mode on LAN bind: set Z4J_ENVIRONMENT=dev AND pass --host 0.0.0.0 explicitly. z4j starts because Z4J_ENVIRONMENT=dev is no longer auto-defaulted in this case. Cookies stay Secure: false, so accept the LAN-eavesdropping risk before going this way.