Installation
Zilean runs as eight service containers, published as images you pull and run with Docker Compose — no build step required.
Prerequisites
Section titled “Prerequisites”- Postgres. The reference deployment uses
postgres:17.10-alpine. No minimum version is formally declared; the application’s own SQL deliberately avoidsdate_binso it does not require Postgres 14+. - Docker and Docker Compose.
Images
Section titled “Images”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.
Two migration systems, both automatic
Section titled “Two migration systems, both automatic”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.
1. River’s tables
Section titled “1. River’s tables”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.
2. Zilean’s own schema
Section titled “2. Zilean’s own schema”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_migrationstable. zilean-coreis 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) ormigrations 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.
Generate the admin password hash
Section titled “Generate the admin password hash”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):
docker run --rm httpd:2.4-alpine htpasswd -bnBC 10 "" 'the real password' | cut -d: -f2It prints a bcrypt hash, ready to paste into .env:
ADMIN_PASSWORD_HASH=$2y$10$...Generate the settings encryption key
Section titled “Generate the settings encryption key”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:
openssl rand -base64 32Set the required configuration
Section titled “Set the required configuration”At minimum, create a .env file:
DATABASE_URL=postgres://zilean:changeme@zilean-postgres:5432/zilean?sslmode=disableADMIN_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.
Start the services
Section titled “Start the services”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-coreOptional: script volumes
Section titled “Optional: script volumes”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:/volumesMerge 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:
mkdir -p ./script-volumessudo chown -R 65532:65532 ./script-volumesThe 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 /.
Recommended: enable script sandboxing
Section titled “Recommended: enable script sandboxing”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:
SCRIPT_SANDBOX_ENABLED=1on 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
/tmpby 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:
cat /proc/sys/kernel/apparmor_restrict_unprivileged_usernsIf 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.
docker compose pulldocker compose up -d zilean-postgresdocker compose up -d zilean-coredocker compose logs -f zilean-core # confirm migrations applieddocker compose up -d zilean-ui zilean-worker zilean-mcp zilean-webhook \ zilean-script-worker-bash zilean-script-worker-node zilean-script-worker-groovySee Deploying for the full Compose topology, resource limits, and reverse-proxy routing.
Smoke test
Section titled “Smoke test”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.
docker compose run --rm -e SEED_JOB=1 zilean-coreUnset it once you’re managing real jobs — it re-inserts the same row on every restart while set.