Postgres DSN. Startup is fatal without it (DATABASE_URL is required). The migration runner rewrites a postgres:///postgresql:// scheme to pgx5:// internally.
ADMIN_USERNAME
core
Login email of the bootstrap Super Admin seeded on first boot. Must contain @ or core refuses to start.
ADMIN_PASSWORD_HASH
core
bcrypt hash for that Super Admin. Generate with docker run --rm httpd:2.4-alpine htpasswd -bnBC 10 "" '...' | cut -d: -f2.
SETTINGS_ENCRYPTION_KEY
core, worker
32-byte key (base64) encrypting secrets at rest: the SMTP password (see Configuration), a job’s auth_config secret (bearer token / basic password / API key), a webhook notification target’s signing secret, and the value of any global variable marked secret. worker needs it too, to apply auth_config and secret variables to real outgoing HTTP calls — the three script workers, zilean-mcp and zilean-webhook deliberately do not get it, and the last two refuse to start if it is set (see Which service needs what below). Generate one the same way you generate other secrets for this deploy; rotating it without re-saving the SMTP settings (and any job with an encrypted auth secret) afterward makes the stored values undecryptable.
SCRIPT_SECRETS_KEY
script workers
32-byte key (base64), same encoding as SETTINGS_ENCRYPTION_KEY but not a fresh random value — it’s a sub-key derived fromSETTINGS_ENCRYPTION_KEY (HKDF-SHA256). Generate it with the command in Generating SCRIPT_SECRETS_KEY below. It decrypts only the global variables explicitly opted into scripts (see Script jobs) — never auth_config, the SMTP password, or a secret that hasn’t opted in. Each of the three script-worker binaries fails fast at startup if it’s unset. zilean-mcp and zilean-webhook do the opposite: they refuse to start if it is set.
The key is fully determined by SETTINGS_ENCRYPTION_KEY: HKDF-SHA256 over the decoded
master key, with the fixed salt zilean-script-secrets-v1 and info zilean-script-secrets,
producing 32 bytes. Derive it with OpenSSL 3, passing in the deploy’s existing
SETTINGS_ENCRYPTION_KEY:
Set the output as SCRIPT_SECRETS_KEY for the three script workers. Because it’s derived,
rotating SETTINGS_ENCRYPTION_KEY means regenerating SCRIPT_SECRETS_KEY from the new
value too.
HTTP listen address for the admin API and /prometheus/metrics.
UI_ADDR
:8090
ui
HTTP listen address for the admin SPA, River UI and the login routes.
MCP_ADDR
:8092
mcp
HTTP listen address for the MCP endpoint at /mcp, plus an unauthenticated /healthz.
MCP_HEALTH_URL
http://zilean-mcp:8092/healthz
core
Optional. Where core probes the MCP service so Settings → MCP can warn when it isn’t reachable. Set it only if you renamed the service or changed MCP_ADDR.
WEBHOOK_ADDR
:8093
webhook
HTTP listen address for the public inbound webhook endpoint at /api/webhooks/{job_id}/{token}, plus an unauthenticated /healthz.
The four job-executing services (worker and the three script workers) run no HTTP
server and have no listen address.
Externally reachable base URL — scheme + host, no trailing slash — used to build absolute invite-accept links.
SMTP itself is not an environment variable — it is configured from Settings → SMTP
in the admin UI and takes effect immediately, with no restart. See
Configuration.
Exactly "1" inserts one sample job (smoke-test-httpbin, GET https://httpbin.org/get, cron */5 * * * *) at startup. Re-seeds on every restart while set — unset it once you have real jobs.
DEMO_MODE
unset
core, ui
Exactly "1" turns on demo mode. Core then refuses writes to script jobs (bash, javascript, typescript, groovy), workflow script blocks, SMTP settings and test email, notification targets, MCP settings, password changes, user management, role management, and sending invites with a 403 (... is not available in the demo); seeds sample demo data idempotently at startup; and the admin UI shows a demo banner (the flag reaches the UI through /api/meta). Any other value leaves it off.
DEMO_DISPLAY_PASSWORD
unset
core, ui
Display-only text shown with the admin username in the demo banner and on the login page. Never used for authentication, and ignored unless DEMO_MODE=1. Never set it on a real deployment.
DEMO_ECHO_BASE_URL
http://127.0.0.1:8099
core
Base URL of the echo server that the seeded demo jobs call. Only meaningful with DEMO_MODE=1.
DEFAULT_AUDIT_RETENTION_DAYS
180
core
Seeds app_settings.audit_retention_daysonce, at first startup. No effect afterwards — a Super Admin changes it in the admin UI. Unset, invalid, or < 1 falls back to 180 with a warning; never fatal.
TZ
container system time (UTC)
all services; forwarded into script jobs
The IANA zone that cron expressions are evaluated in, and what the admin UI reports as “Scheduler TZ”. Also one of only two variables forwarded into a script job’s child environment (the other is PATH).
UI_TRUST_PROXY_HEADERS
false
ui
Optional. When true, the in-process per-IP limiters guarding login and invite acceptance key off the leftmost X-Forwarded-For entry, falling back to the connection’s remote address (also when the header is absent or its entry is blank or invalid). Enable it only when a reverse proxy in front actually sets and sanitizes X-Forwarded-For — overwriting rather than appending to the client-supplied value — otherwise a forged header trivially bypasses the limit. Leave it false only if every client that reaches zilean-ui connects directly; behind a reverse proxy with it unset, every client keys to the proxy’s address and shares one bucket, so an attacker making about 10 POSTs a minute could lock every admin out of sign-in. IPv4 clients are limited per address and IPv6 clients per /64 prefix.
UI_LOGIN_RATE_PER_MINUTE
10
ui
Optional. The sustained rate, in POSTs per minute, allowed by each in-process limiter guarding login and invite acceptance. Must be a positive integer; any other value makes ui fail at startup with a message naming the variable and the value. Both limiters honor it and keep separate buckets.
UI_LOGIN_RATE_BURST
5
ui
Optional. The burst size allowed by each in-process limiter guarding login and invite acceptance. Must be a positive integer; any other value makes ui fail at startup with a message naming the variable and the value. Both limiters honor it and keep separate buckets.
WEBHOOK_TRUST_PROXY_HEADERS
false
webhook
Optional. When true, the in-process per-IP rate limiter keys off the leftmost X-Forwarded-For entry, falling back to the connection’s remote address if the header is absent or its leftmost entry is blank or not a valid IP. IPv4 clients are limited per address and IPv6 clients per /64 prefix. Enable it only when a reverse proxy in front actually sets and sanitizes that header — otherwise a forged header trivially bypasses the limit. Default false keys off the real TCP peer address, so a client cannot spoof its way around the limiter.
WORKER_MAX_CONCURRENCY
20
worker
Optional. How many HTTP jobs zilean-worker runs at once — River’s MaxWorkers for the default queue — per replica. Unset or empty means 20. Must be an integer from 1 to 200; any other value makes the worker exit at startup with a message naming the variable and the value (it is not clamped). Only the count is configurable: the queues the worker subscribes to are fixed, and the script workers’ concurrency is fixed too. Keep it unquoted in an env_file.
Optional. The path, inside the container, of the directory mounted from the host that holds every script volume. Resolved once at startup. It cannot be /. When it is unset, the directory is missing, or it is not writable by UID 65532, the worker still starts and runs every job that declares no volume; a job that declares one fails without retrying. Changing it or the mount needs a redeploy of the script workers.
core and worker — SETTINGS_ENCRYPTION_KEY. Worker needs it to decrypt a job’s
auth_config secret and any secret global variable before applying them to a real outgoing HTTP call.
mcp only — MCP_ADDR, alongside DATABASE_URL and TZ. The MCP service deliberately
does not receive SETTINGS_ENCRYPTION_KEY or SCRIPT_SECRETS_KEY, so it cannot read a
job’s auth_config secret or a secret global variable. It refuses to start, naming the
variable, if either key is set in its environment.
webhook only — WEBHOOK_ADDR, and optionally WEBHOOK_TRUST_PROXY_HEADERS. The
webhook service deliberately does not receive SETTINGS_ENCRYPTION_KEY or
SCRIPT_SECRETS_KEY either, and refuses to start if either is set — the
same posture as mcp: it verifies a token by its sha256 digest and reads a job through the
registry’s redacted path, so it never holds a job’s credentials, even as ciphertext.
ui only — UI_ADDR, and optionally UI_TRUST_PROXY_HEADERS,
UI_LOGIN_RATE_PER_MINUTE and UI_LOGIN_RATE_BURST. The UI service needs no
admin credentials; its auth is entirely database-backed.
worker — DATABASE_URL, TZ and SETTINGS_ENCRYPTION_KEY (see above), plus the
optional WORKER_MAX_CONCURRENCY. Those are the entire contents of .env.worker.example.
script workers — DATABASE_URL (functional), TZ (log timestamps, plus
forwarded to scripts) and SCRIPT_SECRETS_KEY (see above), deliberately withoutSETTINGS_ENCRYPTION_KEY itself. That is the entire contents of .env.workers.example.
The least-trusted containers never hold the master key or admin credentials; the derived
SCRIPT_SECRETS_KEY is scope-limited to the secrets explicitly opted into scripts. The
optional SCRIPT_VOLUMES_ROOT is set on these three services only. See
Script jobs for the explicit
per-variable, per-job opt-in that can place a secret variable’s value in a script’s
environment.