Configuration
Zilean is configured entirely through environment variables. There is no config file. The environment variable reference has the complete table; this page covers how to think about it.
A minimum configuration
Section titled “A minimum configuration”DATABASE_URL=postgres://zilean:changeme@zilean-postgres:5432/zilean?sslmode=disableADMIN_USERNAME[email protected]ADMIN_PASSWORD_HASH=$2a$10$...SETTINGS_ENCRYPTION_KEY=<32-byte key, base64>TZ=America/Los_AngelesThat is enough to boot. Everything else has a working default.
Configuration is split by trust, not by convenience
Section titled “Configuration is split by trust, not by convenience”Services do not share one environment. There are four tiers, deliberately:
zilean-core and zilean-ui get the full .env. Core is the only service that
reads the admin credentials, and it reads them once at startup to seed the Super Admin.
zilean-worker gets a narrowed environment, one variable wider than the three
script workers below:
DATABASE_URL=postgres://...TZ=America/Los_AngelesSETTINGS_ENCRYPTION_KEY=<32-byte key, base64># WORKER_MAX_CONCURRENCY=20It needs SETTINGS_ENCRYPTION_KEY because it applies a job’s auth_config and any secret global variables to real
outgoing HTTP calls, and those secrets are encrypted at rest — decrypting it happens here,
not in core.
WORKER_MAX_CONCURRENCY is optional: it sets how many HTTP jobs the worker runs at once
per replica (20 when unset or empty, at most 200, and any other value stops the worker
at startup naming the variable and value), it applies to zilean-worker only, and it must
stay unquoted because env_file does not strip surrounding quotes.
zilean-mcp and zilean-webhook are zero-key services. Each gets a narrowed
environment and no encryption key at all:
# zilean-mcpenvironment: DATABASE_URL: ${DATABASE_URL} TZ: ${TZ:-UTC} MCP_ADDR: ${MCP_ADDR:-:8092}
# zilean-webhookenvironment: DATABASE_URL: ${DATABASE_URL} TZ: ${TZ:-UTC} WEBHOOK_ADDR: ${WEBHOOK_ADDR:-:8093} WEBHOOK_TRUST_PROXY_HEADERS: ${WEBHOOK_TRUST_PROXY_HEADERS:-false}Neither reads a job’s credentials: MCP goes through the registry’s redacted path, and
the webhook endpoint verifies a token by its sha256 digest. Both refuse to start, with a
non-zero exit and an error naming the variable, if SETTINGS_ENCRYPTION_KEY or
SCRIPT_SECRETS_KEY is set to a non-empty value in their environment, so a stray
env_file: [.env] fails loudly instead of quietly widening what a public-facing
container holds. Note that the environment: blocks above are interpolated, so they
depend on where .env lives — see the caution below.
The three script workers — bash, Node/TypeScript, and Groovy — get the narrowest environment of all:
DATABASE_URL=postgres://...TZ=America/Los_AngelesDATABASE_URL is the only variable any of them actually reads for function;
appboot.Open is fatal without it. TZ only affects their log timestamps — and is
forwarded into script jobs. They deliberately do not get
SETTINGS_ENCRYPTION_KEY, even though they already hold DATABASE_URL and so can read
an auth_config secret’s ciphertext directly: every script job has auth_type: none by
construction, so they never need to decrypt one, and withholding the key is what stops a
compromised script from decrypting some other job’s secret.
The reason for the split: these are the least-trusted containers, the ones that run operator-supplied code, and there is no reason for them to hold admin credentials or a key they have no legitimate use for. Derive the narrowed files from the real one rather than retyping a password:
grep -E '^(DATABASE_URL|TZ|SETTINGS_ENCRYPTION_KEY|WORKER_MAX_CONCURRENCY)=' .env > .env.workergrep -E '^(DATABASE_URL|TZ)=' .env > .env.workersScript workers may also set SCRIPT_VOLUMES_ROOT, an optional path to the directory that
holds script volumes. It is not a secret, so set it on the service rather than in
.env.workers. See Volumes.
Timezone
Section titled “Timezone”TZ decides the zone cron expressions are evaluated in — there is no per-job timezone,
and no WithLocation override, so the scheduler runs in the process’s time.Local.
Because this is easy to get wrong and invisible when it is, the admin API exposes what the server actually resolved:
curl https://<your-host>/api/meta# {"timezone":"America/Los_Angeles"}The admin UI displays this too. If it says UTC and you expected otherwise, TZ isn’t
reaching the container.
Email is a Settings page feature
Section titled “Email is a Settings page feature”SMTP isn’t set through the environment at all — it’s configured from Settings → SMTP in the admin UI, and takes effect immediately, with no restart. Fill in the host, port, credentials and from address, then use the page’s test-email button to confirm delivery before relying on it for invites. The test button reuses the saved password only while the host and port are unchanged; if you point it at a different host or port, re-enter the password.
Zilean refuses to connect to link-local (169.254.0.0/16, fe80::/10), unspecified and
multicast addresses — never a legitimate mail relay, and where cloud metadata services live.
Private and loopback relays (an SMTP server on your LAN, or on the same host) are allowed. If
you need stricter isolation, apply egress rules on the host.
Leave it unconfigured and the invite flow still works — the admin UI shows the invite link for an admin to copy and send by hand.
The SMTP password is encrypted at rest, keyed by the SETTINGS_ENCRYPTION_KEY environment
variable (required on zilean-core, and also on zilean-worker — see
Configuration is split by trust
above — for the same reason a job’s auth_config secret is encrypted). If you configure
SMTP, also set PUBLIC_BASE_URL — without it, invite emails contain a bare relative path
instead of a clickable link.
Notifications is a Settings page feature too
Section titled “Notifications is a Settings page feature too”Apprise/email notifications aren’t set through the environment either, and Zilean does
not bundle an Apprise server — run one yourself, reachable from wherever zilean-core
runs, then configure it from Settings → Notifications:
- Apprise server connection — the server’s URL and whether to verify its TLS
certificate. When a URL is set it must end in
/notify; the form and the server both reject anything else (the server answers400), whether or not Apprise is enabled. Saving with Apprise enabled and an empty URL is rejected withserver_url is required when Apprise is enabled; with Apprise off, an empty URL saves fine. The Test button probes the URL currently entered in the form, so you can confirm the connection before enabling Apprise or saving. - Notification targets — named Apprise/email destinations a job or metrics alarm can
notify:
- An Apprise target holds a Service URL (Discord, Slack, Telegram, and everything else Apprise supports) and is delivered through the server above.
- An email target is just an address, delivered through the SMTP settings described above — no separate email configuration.
- Alarms — an optional CPU%/memory alert per service (
core,ui,worker,mcp,webhook, and the three script workers), notifying a chosen set of targets when a service crosses the line and again when it recovers. These metrics alarms notify Apprise and email targets only: they are not event-driven, so a webhook target is not offered for one — see Webhooks is a Settings page feature too below. Per-job invocation alarms are configured from the job’s Activity modal and can notify webhook targets.
A job’s own Notify step (in the job wizard) picks which outcomes — success, failure, or both — notify which targets, or all of them. See Job Fields.
Standing up an Apprise server is one container:
docker run -d -p 8000:8000 caronc/appriseThe image is caronc/apprise — caronc/apprise-api is not a real image. See the
apprise-api repository for the server itself.
Webhooks is a Settings page feature too
Section titled “Webhooks is a Settings page feature too”Webhook targets have their own Settings → Webhooks page, separate from Notifications — they’re delivered independently of Apprise/email and don’t share that page’s Apprise server connection or SMTP dependency.
A webhook target is a URL Zilean POSTs a JSON payload to when a job reaches one of
the states its Notify on filter selects. The filter is per-target: Success,
Failure, Retry exhausted, and/or Invocation alarm. A target subscribed to
Failure also receives retry_exhausted events, matching the job-level
notify_on: failure gate; select Retry exhausted without Failure to receive only
exhausted retries. A target subscribed to Invocation alarm receives the deliveries
from that job’s invocation alarms.
Leaving every box unchecked stores all four (the default). A target saved with an
explicit filter keeps it, so tick Invocation alarm on any target that should receive
these deliveries. A webhook delivery is fire-once — a non-2xx response or a transport
error is logged and dropped, never retried — and redirects are not followed (a 3xx is a
failed delivery).
Webhook deliveries are signed when the target has a signing secret. The secret is
write-only: set it when adding the target, and the API reports only whether one is
stored (has_signing_secret), never its value. Leaving the field blank while editing
keeps the stored secret.
Receiving a webhook
Section titled “Receiving a webhook”Zilean sends one POST per matching terminal state. The JSON body carries only
non-secret fields:
{ "event": "failure", "job_id": "3f2b…", "job_name": "nightly-backup", "group_name": "Ops/Backups", "job_type": "http", "occurred_at": "2026-09-22T20:00:00Z", "snippet": "endpoint returned status 500"}The body above is a terminal-state delivery. An invocation_alarm delivery carries the
same job-identifying fields plus five flat string keys, present only on that event:
| Key | Value |
|---|---|
alarm_state |
breached or ok (a recovery). |
alarm_metric |
total or failed. |
alarm_count |
The count that triggered the delivery. |
alarm_threshold |
The alarm’s configured threshold. |
alarm_window_minutes |
The alarm’s window in minutes. |
…alongside these request headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Zilean-Event |
success, failure, retry_exhausted, or invocation_alarm |
X-Zilean-Delivery |
a unique id (UUID) per delivery, for de-duplication |
X-Zilean-Timestamp |
the signing timestamp, Unix seconds |
X-Zilean-Signature |
sha256=<hex hmac>, present only when a signing secret is configured |
To verify a delivery, recompute HMAC-SHA256 over the string
<X-Zilean-Timestamp>.<raw request body> using the shared signing secret, hex-encode
it, and compare it (constant time) against the value after sha256= in
X-Zilean-Signature. The timestamp is part of the signed string, so reject a delivery
whose timestamp falls outside your replay tolerance before trusting it. Because a
delivery is never retried, treat X-Zilean-Delivery as the idempotency key.
Settings that live in the database, not the environment
Section titled “Settings that live in the database, not the environment”Some configuration is deliberately not environment-driven, because it is meant to change without a restart:
- Job definitions — schedule, retry, loop, pooling, auth. All rows. That’s the whole point; see Introduction.
- Users, roles and permissions — database-backed, managed in the admin UI. See Users & permissions.
- Audit log retention —
DEFAULT_AUDIT_RETENTION_DAYSseeds theapp_settingsrow once on first startup. After that, changing the environment variable does nothing; a Super Admin changes the window in the admin UI. - Global variables — values shared across jobs, stored in
global_variables. - Script volumes — the named directories script jobs may be given, set from Settings →
Script volumes and effective on the next run. The host directory they live under and
SCRIPT_VOLUMES_ROOTare container settings that need a redeploy. See Volumes. - SMTP settings — host, port, credentials and from address, set from Settings → SMTP. See Email is a Settings page feature above.
- Apprise/email notification targets, the Apprise server connection, and metrics alarms — set from Settings → Notifications. See Notifications is a Settings page feature too above.
- Webhook targets — set from Settings → Webhooks. See Webhooks is a Settings page feature too above.
Once you’re running
Section titled “Once you’re running”- Your first job — register a job and watch it execute.
- Deploying — Compose topology, image targets, resource limits and reverse-proxy routing.