Skip to content

Installation

Zilean runs as eight service containers, published as images you pull and run with Docker Compose — no build step required.

  • Postgres. The reference deployment uses postgres:17.10-alpine. No minimum version is formally declared; the application’s own SQL deliberately avoids date_bin so it does not require Postgres 14+.
  • Docker and Docker Compose.

Every tagged release publishes multi-arch (linux/amd64 + linux/arm64) images to GHCR:

Image Contains
ghcr.io/patrickpingol/zilean-slim zilean-core, zilean-ui, zilean-worker, zilean-mcp, zilean-webhook — command: selects which binary runs
ghcr.io/patrickpingol/zilean-scripts-bash zilean-script-worker-bash
ghcr.io/patrickpingol/zilean-scripts-node zilean-script-worker-node
ghcr.io/patrickpingol/zilean-scripts-groovy zilean-script-worker-groovy

Each is tagged vX.Y.Z, vX.Y, vX, and latest. Pin to a vX.Y.Z tag for anything but throwaway testing — latest moves.

Deploy only the script runtimes you actually use. A script job whose runtime has no deployed worker queues indefinitely with nothing to pick it up — visible in River UI as stuck in scheduled/available.

Zilean’s schema and River’s schema are applied by different tools, but both run automatically from zilean-core startup — there is nothing to run by hand.

River’s own tables (river_job, river_queue, …) are migrated by zilean-core itself, via River’s Go migration API (rivermigrate) rather than the river CLI. This runs on every startup and is idempotent — an already-current schema is a no-op.

Zilean’s tables (scheduled_jobs, worker_metrics, job_call_stats, global_variables, users/roles/sessions/invites, audit_log, app_settings) are embedded in the zilean-core image and applied by golang-migrate the moment core starts.

  • Tracked in the schema_migrations table.
  • zilean-core is the only service that applies migrations (both River’s and Zilean’s own) — one owner, so several containers never race for the advisory lock.
  • Startup logs say which case you’re in: migrations already up to date (with the version) or migrations applied (from → to) for Zilean’s schema, and similarly for River’s.
  • A dirty migration state (Zilean’s schema only) aborts startup with schema is marked dirty at version N; resolve by hand before starting.

The first Super Admin account is seeded from environment variables on first boot. Generate its bcrypt hash with htpasswd (from the public httpd image, so there’s nothing to install locally):

Terminal window
docker run --rm httpd:2.4-alpine htpasswd -bnBC 10 "" 'the real password' | cut -d: -f2

It prints a bcrypt hash, ready to paste into .env:

ADMIN_PASSWORD_HASH=$2y$10$...

zilean-core and zilean-worker both refuse to start without SETTINGS_ENCRYPTION_KEY — a 32-byte key, base64-encoded, that encrypts secrets at rest (the SMTP password and a job’s auth_config secret). Generate one:

Terminal window
openssl rand -base64 32

At minimum, create a .env file:

DATABASE_URL=postgres://zilean:changeme@zilean-postgres:5432/zilean?sslmode=disable
ADMIN_USERNAME[email protected]
ADMIN_PASSWORD_HASH=$2y$10$...
SETTINGS_ENCRYPTION_KEY=...

See Configuration for everything else, and the environment variable reference for the full table.

Create a docker-compose.yml alongside your .env:

services:
zilean-postgres:
image: postgres:17.10-alpine
restart: unless-stopped
env_file: [.env]
volumes:
- ./postgres/data:/var/lib/postgresql/data
zilean-core:
image: ghcr.io/patrickpingol/zilean-slim:v1.0.0
command: ["/usr/local/bin/zilean-core"]
restart: unless-stopped
env_file: [.env]
depends_on:
- zilean-postgres
zilean-ui:
image: ghcr.io/patrickpingol/zilean-slim:v1.0.0
command: ["/usr/local/bin/zilean-ui"]
restart: unless-stopped
env_file: [.env]
depends_on:
- zilean-core
zilean-worker:
image: ghcr.io/patrickpingol/zilean-slim:v1.0.0
command: ["/usr/local/bin/zilean-worker"]
restart: unless-stopped
env_file: [.env]
depends_on:
- zilean-core
zilean-mcp:
image: ghcr.io/patrickpingol/zilean-slim:v1.0.0
command: ["/usr/local/bin/zilean-mcp"]
restart: unless-stopped
# Zero-key service: a narrowed environment, not env_file. It never
# receives SETTINGS_ENCRYPTION_KEY or SCRIPT_SECRETS_KEY and refuses to
# start if either is set.
environment:
DATABASE_URL: ${DATABASE_URL}
TZ: ${TZ:-UTC}
MCP_ADDR: ${MCP_ADDR:-:8092}
depends_on:
- zilean-core
zilean-webhook:
image: ghcr.io/patrickpingol/zilean-slim:v1.0.0
command: ["/usr/local/bin/zilean-webhook"]
restart: unless-stopped
# Zero-key service: a narrowed environment, not env_file. It never
# receives SETTINGS_ENCRYPTION_KEY or SCRIPT_SECRETS_KEY and refuses to
# start if either is set.
environment:
DATABASE_URL: ${DATABASE_URL}
TZ: ${TZ:-UTC}
WEBHOOK_ADDR: ${WEBHOOK_ADDR:-:8093}
WEBHOOK_TRUST_PROXY_HEADERS: ${WEBHOOK_TRUST_PROXY_HEADERS:-false}
depends_on:
- zilean-core
# Deploy only the script runtimes you actually use — see "Images" above.
zilean-script-worker-bash:
image: ghcr.io/patrickpingol/zilean-scripts-bash:v1.0.0
restart: unless-stopped
env_file: [.env]
# Always-on hardening — subtractive only, so it needs no host
# prerequisite and applies whether or not the bwrap sandbox is enabled.
# The root filesystem is read-only, so the image cannot be modified;
# /tmp is the only writable path — a size-capped, non-executable tmpfs
# that each run gets its own working directory under, so relative-path
# writes work. Every capability is dropped, and no-new-privileges
# blocks setuid/file-capability privilege gain on exec. The size is a
# suggested cap; see the note below on how it couples to mem_limit.
read_only: true
tmpfs:
- /tmp:size=128m,noexec,nosuid,nodev
cap_drop:
- ALL
# The two entries below are needed only for the bwrap sandbox, and only
# once SCRIPT_SANDBOX_ENABLED=1 is also set in .env (see "Recommended:
# enable script sandboxing" below). Both are relaxations of the
# *container's* own confinement — neither loosens what a sandboxed
# script can reach, which is governed by bwrap's bind list and
# namespaces. no-new-privileges is not one of those relaxations: it is
# part of the always-on hardening above and needs nothing from the host.
# seccomp:unconfined - bwrap's --unshare-user needs
# unshare(CLONE_NEWUSER), which Docker's
# default seccomp profile blocks. One syscall
# family.
# systempaths:unconfined - bwrap mounts a fresh /proc, which the kernel
# refuses while Docker's masked /proc paths are
# mounted over it.
security_opt:
- seccomp:unconfined
- systempaths:unconfined
- no-new-privileges:true
depends_on:
- zilean-core
zilean-script-worker-node:
image: ghcr.io/patrickpingol/zilean-scripts-node:v1.0.0
restart: unless-stopped
env_file: [.env]
# Always-on hardening — subtractive only, so it needs no host
# prerequisite and applies whether or not the bwrap sandbox is enabled.
# The root filesystem is read-only, so the image cannot be modified;
# /tmp is the only writable path — a size-capped, non-executable tmpfs
# that each run gets its own working directory under, so relative-path
# writes work. Every capability is dropped, and no-new-privileges
# blocks setuid/file-capability privilege gain on exec. The size is a
# suggested cap; see the note below on how it couples to mem_limit.
read_only: true
tmpfs:
- /tmp:size=256m,noexec,nosuid,nodev
cap_drop:
- ALL
# The two entries below are needed only for the bwrap sandbox, and only
# once SCRIPT_SANDBOX_ENABLED=1 is also set in .env (see "Recommended:
# enable script sandboxing" below). Both are relaxations of the
# *container's* own confinement — neither loosens what a sandboxed
# script can reach, which is governed by bwrap's bind list and
# namespaces. no-new-privileges is not one of those relaxations: it is
# part of the always-on hardening above and needs nothing from the host.
# seccomp:unconfined - bwrap's --unshare-user needs
# unshare(CLONE_NEWUSER), which Docker's
# default seccomp profile blocks. One syscall
# family.
# systempaths:unconfined - bwrap mounts a fresh /proc, which the kernel
# refuses while Docker's masked /proc paths are
# mounted over it.
security_opt:
- seccomp:unconfined
- systempaths:unconfined
- no-new-privileges:true
depends_on:
- zilean-core
zilean-script-worker-groovy:
image: ghcr.io/patrickpingol/zilean-scripts-groovy:v1.0.0
restart: unless-stopped
env_file: [.env]
# Always-on hardening — subtractive only, so it needs no host
# prerequisite and applies whether or not the bwrap sandbox is enabled.
# The root filesystem is read-only, so the image cannot be modified;
# /tmp is the only writable path — a size-capped, non-executable tmpfs
# that each run gets its own working directory under, so relative-path
# writes work. Every capability is dropped, and no-new-privileges
# blocks setuid/file-capability privilege gain on exec. The size is a
# suggested cap; see the note below on how it couples to mem_limit.
read_only: true
tmpfs:
- /tmp:size=256m,noexec,nosuid,nodev
cap_drop:
- ALL
# The two entries below are needed only for the bwrap sandbox, and only
# once SCRIPT_SANDBOX_ENABLED=1 is also set in .env (see "Recommended:
# enable script sandboxing" below). Both are relaxations of the
# *container's* own confinement — neither loosens what a sandboxed
# script can reach, which is governed by bwrap's bind list and
# namespaces. no-new-privileges is not one of those relaxations: it is
# part of the always-on hardening above and needs nothing from the host.
# seccomp:unconfined - bwrap's --unshare-user needs
# unshare(CLONE_NEWUSER), which Docker's
# default seccomp profile blocks. One syscall
# family.
# systempaths:unconfined - bwrap mounts a fresh /proc, which the kernel
# refuses while Docker's masked /proc paths are
# mounted over it.
security_opt:
- seccomp:unconfined
- systempaths:unconfined
- no-new-privileges:true
depends_on:
- zilean-core

Script volumes are opt-in. The compose file above does not mount a volumes directory and does not set SCRIPT_VOLUMES_ROOT, so no script job can reach persistent storage, and nothing changes unless you add the mount. To let jobs be given named directories that survive container recreation, add a bind mount and the variable to each script worker service (zilean-script-worker-bash, -node and -groovy, whichever you run):

environment:
SCRIPT_VOLUMES_ROOT: /volumes
volumes:
- ./script-volumes:/volumes

Merge these into the environment and volumes keys the service already has. Create the host directory before the workers first start, owned by the user the script worker containers run as (UID 65532). Docker creates a missing bind-mount directory owned by root, which the worker cannot write to:

Terminal window
mkdir -p ./script-volumes
sudo chown -R 65532:65532 ./script-volumes

The root directory itself must be writable by UID 65532 and mounted read-write (not :ro). The worker probes the root once, at startup. A root that is unset, missing, or unwritable is treated as not mounted: the worker still starts and runs jobs that declare no volume, but reports volumes_root_mounted as false (logging a warning when the value is set but unusable), and every job that declares a volume fails. After fixing ownership or permissions, restart the worker (docker compose restart zilean-script-worker-bash) so it re-probes the root. The mount and SCRIPT_VOLUMES_ROOT are container settings, so adding or changing them needs a redeploy of the script workers. Defining volumes and giving them to jobs is covered in Script jobs. SCRIPT_VOLUMES_ROOT cannot be /.

The Bash, Node, and Groovy workers can each confine every script run with bwrap (bubblewrap): filesystem and pid-namespace isolation so a script sees only its own runtime binaries and a writable temp directory scoped to that one run. This is opt-in and off by default — set in .env:

Terminal window
SCRIPT_SANDBOX_ENABLED=1

on top of the security_opt entries in the Compose snippet above (and the host prerequisite in the next section). The hardening in that snippet is always on and needs no host prerequisite — bwrap is what adds per-job separation on top of it, not what switches the hardening on. If you skip this, every Bash/JavaScript/TypeScript/Groovy script job still runs — without per-job isolation, with real consequences worth knowing before you decide:

  • One script’s temp files are visible to every other concurrently-running script job on the same worker, not just its own.
  • A script can read the whole container filesystem it runs in — there is nothing stopping it from walking outside its own working directory. Its writes are confined to /tmp by the always-on hardening, but its reads are not.

That hardening limits what a script can do to the container — it cannot modify the image, gain privileges, or persist writes outside /tmp (other than to a script volume you have mounted) — but it does not separate one script from another. That separation is what bwrap adds.

Leaving SCRIPT_SANDBOX_ENABLED unset is a deliberate choice to accept that, not a default that assumes it away — enable it unless you have a specific reason not to.

Check the host’s user-namespace policy first

Section titled “Check the host’s user-namespace policy first”

bwrap needs an unprivileged user namespace to do its confinement. Some distributions restrict those — Ubuntu 23.10 and newer ship the restriction on by default:

Terminal window
cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns

If that prints 1, the namespace is created with no capabilities in it and bwrap cannot confine anything on this host — no security_opt setting can compensate. Set it to 0 (sysctl -w kernel.apparmor_restrict_unprivileged_userns=0, plus a /etc/sysctl.d/ entry so it survives reboot), or give those three containers an AppArmor profile granting userns create. If the file prints 0 or does not exist, there is nothing to do.

A restricted host does not fail your script jobs: with SCRIPT_SANDBOX_ENABLED=1 set, each worker runs a real bwrap self-test once at startup, and if it fails (this sysctl being the most common reason), it logs a clear warning and falls back to running scripts without per-job confinement (the container hardening still applies) rather than breaking them. Check docker compose logs zilean-script-worker-bash zilean-script-worker-node zilean-script-worker-groovy after a deploy for that warning if you expected sandboxing to be active.

Bring up core first — it owns migrations, and the other services exit non-zero and restart until the schema exists.

Terminal window
docker compose pull
docker compose up -d zilean-postgres
docker compose up -d zilean-core
docker compose logs -f zilean-core # confirm migrations applied
docker compose up -d zilean-ui zilean-worker zilean-mcp zilean-webhook \
zilean-script-worker-bash zilean-script-worker-node zilean-script-worker-groovy

See Deploying for the full Compose topology, resource limits, and reverse-proxy routing.

Set SEED_JOB=1 on core for one boot. It inserts a single sample job — smoke-test-httpbin, GET https://httpbin.org/get, cron */5 * * * * — which exercises the whole path: scheduler tick → job insert → HTTP call → visible in River UI and the admin UI.

Terminal window
docker compose run --rm -e SEED_JOB=1 zilean-core

Unset it once you’re managing real jobs — it re-inserts the same row on every restart while set.