Skip to content

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.

.env
DATABASE_URL=postgres://zilean:changeme@zilean-postgres:5432/zilean?sslmode=disable
ADMIN_USERNAME[email protected]
ADMIN_PASSWORD_HASH=$2a$10$...
SETTINGS_ENCRYPTION_KEY=<32-byte key, base64>
TZ=America/Los_Angeles

That 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:

.env.worker
DATABASE_URL=postgres://...
TZ=America/Los_Angeles
SETTINGS_ENCRYPTION_KEY=<32-byte key, base64>
# WORKER_MAX_CONCURRENCY=20

It 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-mcp
environment:
DATABASE_URL: ${DATABASE_URL}
TZ: ${TZ:-UTC}
MCP_ADDR: ${MCP_ADDR:-:8092}
# zilean-webhook
environment:
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:

.env.workers
DATABASE_URL=postgres://...
TZ=America/Los_Angeles

DATABASE_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:

Terminal window
grep -E '^(DATABASE_URL|TZ|SETTINGS_ENCRYPTION_KEY|WORKER_MAX_CONCURRENCY)=' .env > .env.worker
grep -E '^(DATABASE_URL|TZ)=' .env > .env.workers

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

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:

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

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 answers 400), whether or not Apprise is enabled. Saving with Apprise enabled and an empty URL is rejected with server_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:

Terminal window
docker run -d -p 8000:8000 caronc/apprise

The image is caronc/apprise — caronc/apprise-api is not a real image. See the apprise-api repository for the server itself.

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.

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_DAYS seeds the app_settings row 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_ROOT are 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.
  • Your first job — register a job and watch it execute.
  • Deploying — Compose topology, image targets, resource limits and reverse-proxy routing.