Deploying
The docker-compose.yml in Installation is a reference
topology — the service definitions, networks and limits to adapt, not a file
guaranteed to run unmodified in your environment.
Images
Section titled “Images”Four images are published to GHCR (ghcr.io/patrickpingol/zilean-*), tagged vX.Y.Z,
vX.Y, vX, and latest on every release — see Installation
for the full docker-compose.yml.
| Image | Contains | Base |
|---|---|---|
zilean-slim |
All five non-script binaries — zilean-core, zilean-ui, zilean-worker, zilean-mcp, zilean-webhook. The Compose command: selects which one runs. |
alpine + ca-certificates, tzdata |
zilean-scripts-bash |
The Bash worker only. Installs bash and coreutils explicitly rather than relying on busybox sh, so scripts behave as they do elsewhere. |
alpine (+~2.5 MB over slim) |
zilean-scripts-node |
The Node/TypeScript worker only, with tsx. |
alpine + pinned nodejs (+~110 MB) |
zilean-scripts-groovy |
The Groovy worker only, with Groovy on PATH. |
alpine + pinned openjdk17-jre-headless (+~205 MB) |
Apache Groovy and tsx are the externally-fetched dependencies in these images. Each is
downloaded from its upstream source during the image build and verified against a pinned
SHA-256, and neither is resolved through a package manager at build time — so the exact
bytes that ship are fixed by the image tag, not by whatever a dependency range resolves
to on the day it is built.
Networks
Section titled “Networks”Two networks, and the separation is the point.
- An application network carrying Postgres and every service. It is deliberately
not
internal: true, because scripts legitimately need outbound internet access — the isolation being bought here is from neighbouring unrelated services, not from the internet. - An external network joined by
zilean-core,zilean-ui,zilean-mcp, andzilean-webhook, so your reverse proxy can reach them.
The three script workers are attached to the application network alone. Neighbouring services on the external network are therefore unroutable from them by construction, rather than by policy.
zilean-core and zilean-mcp sharing the application network is also what lets core
live-probe zilean-mcp’s health (GET /healthz) for the MCP Settings page’s “not
reachable” warning — see MCP. It defaults to
http://zilean-mcp:8092/healthz, matching the reference compose file’s service name and
MCP_ADDR; set MCP_HEALTH_URL on zilean-core if you renamed that service or changed
MCP_ADDR.
Resource limits
Section titled “Resource limits”These are ceilings, not reservations. The rationale is specific: on a busy host, an
unbounded Go service that leaks memory gets a neighbour OOM-killed, because the kernel
picks the victim. pids_limit is the fork-bomb ceiling for a careless — not
malicious — script.
| Service | mem_limit |
cpus |
pids_limit |
|---|---|---|---|
zilean-core |
512m | 1.0 | 256 |
zilean-ui |
512m | 1.0 | 256 |
zilean-worker |
512m | 2.0 | 512 |
zilean-mcp |
512m | 1.0 | 256 |
zilean-webhook |
512m | 1.0 | 256 |
zilean-script-worker-bash |
256m | 1.0 | 256 |
zilean-script-worker-node |
512m | 1.0 | 256 |
zilean-script-worker-groovy |
1g | 1.0 | 256 |
Notes on the numbers that aren’t obvious:
zilean-workergets 2 CPUs becausepool_countfans one firing into concurrent HTTP calls. Its memory stays modest because response bodies are truncated.cpus,mem_limitandWORKER_MAX_CONCURRENCYmove together. The variable sets how many HTTP jobszilean-workerruns at once per replica (20 by default, at most 200); raising it without raising the CPU and memory ceilings trades throughput for contention, and it needs a matching connection pool — see Deploying to AWS for thepool_max_connsandmax_connectionsside.zilean-webhookmatches core/ui/mcp — it serves one public route, so it is sized like the other small HTTP services rather than the worker.- Bash keeps full CPU and pids despite the small memory limit — bash is the forkiest
runtime (every pipeline stage and
$(…)is a process), and five concurrent jobs share one pid ceiling. - Groovy’s
1gis load-bearing. A JVM sizes its heap as a percentage of the container limit, so this number is what-XX:MaxRAMPercentageis a percentage of. Without a container limit, the JVM sizes its heap from total host memory instead.
Groovy also pays ~1–2 s of JVM startup on every invocation, which makes it a poor fit for high-frequency schedules — an every-30-seconds job would spend ~5% of wall clock just starting JVMs. Prefer Bash or JS/TS for anything frequent or short-looped.
Container confinement settings
Section titled “Container confinement settings”The three script workers run inside a container hardened the same way, and can
additionally sandbox every script with bwrap (bubblewrap) for filesystem and
pid-namespace confinement.
Container hardening
Section titled “Container hardening”All three script workers carry the same always-on settings in the reference Compose
file. They are subtractive — they surrender privileges rather than needing anything from
the host — so they apply on every host, including one where bwrap cannot run, and
they are independent of SCRIPT_SANDBOX_ENABLED:
| Service | read_only |
tmpfs at /tmp |
cap_drop |
security_opt |
|---|---|---|---|---|
zilean-script-worker-bash |
true |
size=128m,noexec,nosuid,nodev |
ALL |
no-new-privileges:true |
zilean-script-worker-node |
true |
size=256m,noexec,nosuid,nodev |
ALL |
no-new-privileges:true |
zilean-script-worker-groovy |
true |
size=256m,noexec,nosuid,nodev |
ALL |
no-new-privileges:true |
read_only: true— the image cannot be modified, and/tmpis the only writable path (plus a script volume, if you mount one). Each run gets its own working directory under it, so relative-path writes work; writes to the container’s own filesystem fail. The container’s root filesystem is read-only; with bwrap sandboxing enabled, a script’s writes outside its per-run directory land in the sandbox’s own temporary root and are discarded when the run ends, never reaching the container.tmpfsat/tmp— size-capped at 128m for Bash and 256m for Node and Groovy,noexec,nosuid,nodev. The caps are suggested rather than tuned: each is at most half of the worker’s referencemem_limit(256m / 512m / 1g — see Resource limits), because tmpfs pages are charged to that limit rather than added to it, and the rest of the limit is left for the interpreters of up to five concurrent jobs. Keeping the cap below the limit is what lets a script that fills/tmpgetno space left on deviceinstead of OOM-killing the container. Because the tmpfs isnoexec, a helper the script writes to scratch cannot be executed as a program —chmod +x helper && ./helperfails withpermission denied; run it through its interpreter instead (bash helper.sh,node helper.js). A script volume is not mountednoexec. The interpreters live on the read-only rootfs, so Bash, Node/TypeScript viatsx, Groovy, stdin payloads and environment variables are unaffected.cap_drop: ALL— empties the capability set.no-new-privileges:true— stops setuid or file-capability privilege gain on exec.
This hardening narrows what a script can do to the container, but it does not give
per-job separation: every job on a worker runs as the same UID (65532), so concurrent
jobs can still read each other’s scratch files, and the image contents remain readable.
That separation is what bwrap adds.
Opt-in: bubblewrap sandboxing
Section titled “Opt-in: bubblewrap sandboxing”Each of the three script workers can run every script under bwrap (bubblewrap) for
filesystem and pid-namespace confinement — but only once you opt in. Set
SCRIPT_SANDBOX_ENABLED=1 in .env; unset (the default) means these workers run
scripts without per-job confinement, with no warning that they’re doing so, because
sandboxing an existing self-hosted deployment must never be something an upgrade turns on
for you. The hardening above still applies without it: a script can read the whole
container filesystem it runs in, and nothing stops it walking outside its own working
directory — its writes stay confined to /tmp (and any mounted script volume), but its reads are not. See
Installation’s “Recommended: enable script sandboxing”
for why you should set it — the short version: without it, one script’s temp files are
visible to every other concurrently-running script job.
Once SCRIPT_SANDBOX_ENABLED=1 is set, these three workers also need the two
security_opt relaxations that the rest of the stack does not. no-new-privileges:true
is not one of them — it is part of the always-on hardening above:
| Service | security_opt |
Why |
|---|---|---|
zilean-script-worker-bash |
seccomp:unconfined, systempaths:unconfined |
Runs every script under bwrap |
zilean-script-worker-node |
seccomp:unconfined, systempaths:unconfined |
Runs every script under bwrap |
zilean-script-worker-groovy |
seccomp:unconfined, systempaths:unconfined |
Runs every script under bwrap |
| All other services | (none) | Execute no operator-supplied code |
seccomp:unconfined—bwrapcallsunshare(CLONE_NEWUSER), which Docker’s default seccomp profile blocks for an unprivileged caller.systempaths:unconfined—bwrapmounts a fresh/procfor the sandbox, and the kernel refuses that while Docker’s masked/procpaths are mounted over it.
Both loosen the container’s own confinement, by one syscall family and Docker’s
/proc masking respectively. Neither loosens the sandbox: what a script can reach is
decided by bwrap’s bind list and namespaces, which these settings do not touch.
Each worker runs a real bwrap self-test once at startup when SCRIPT_SANDBOX_ENABLED=1
is set — not just a check that the binary exists — because the host prerequisite above is
exactly the kind of failure a bare existence check misses. If the self-test fails (that
sysctl being the most common reason), the worker logs a clear warning and falls back to
running scripts without per-job confinement (the container hardening still applies)
instead of failing them outright. Check
docker compose logs zilean-script-worker-bash zilean-script-worker-node zilean-script-worker-groovy after a deploy for that warning if scripts should be
sandboxed but you’re not sure they are.
Script volumes
Section titled “Script volumes”Script volumes are opt-in. The reference Compose file mounts no volumes directory and sets
no SCRIPT_VOLUMES_ROOT, so nothing changes unless you add them. To enable volumes, mount
one host directory at /volumes in each script worker and set SCRIPT_VOLUMES_ROOT to it.
Every script volume is a direct child directory of that
root, so files a script writes survive container recreation. Add the same two entries to
zilean-script-worker-bash, zilean-script-worker-node and zilean-script-worker-groovy:
environment: SCRIPT_VOLUMES_ROOT: /volumes volumes: - /srv/zilean-volumes:/volumesThe host directory itself must be writable by UID 65532, the user all three script
worker images run as. Files a script writes are owned by that UID on the host:
mkdir -p /srv/zilean-volumessudo chown -R 65532:65532 /srv/zilean-volumesOnce mounted with the sandbox off (the default), every script job on that worker can read
and write every volume, and ro is advisory; enable
sandboxing when you use volumes. A volume is persistent and
is not mounted noexec, so it is the one place a script’s writes survive and where a
written file could be executed.
A read-only (:ro) root mount does not work. A root that is unset, missing, or unwritable
is treated as not mounted: the worker still starts and runs every job that declares no
volume, but it reports volumes_root_mounted: false (logging a warning when the value is
set but unusable), and every job that declares a volume fails until the worker is restarted
with a writable root. SCRIPT_VOLUMES_ROOT cannot be /. The mount and the variable are
container settings that need a redeploy of the script workers. Back the host directory up
like any other host data: it is not in Postgres. See
Volumes.
Stop behaviour
Section titled “Stop behaviour”All eight app services use stop_grace_period: 30s, set above River’s 25-second
soft-stop so a graceful shutdown completes before Docker escalates to SIGKILL.
Startup order
Section titled “Startup order”zilean-postgres → zilean-core → everything else. Core is first because it alone
applies migrations.
Remember that depends_on waits for a container to start, not for migrations to
finish. The other services exiting non-zero and restarting until the schema exists is
the expected path, and restart: unless-stopped is what makes it converge.
Reverse proxy routing
Section titled “Reverse proxy routing”Core and ui share one origin split by path prefix — same-origin is required there,
since the SPA served by ui calls core’s API same-origin through the proxy. zilean-mcp
is a separate upstream on its own path, needed only if you’re using the MCP endpoint —
see MCP. zilean-webhook is a separate upstream too, serving the
public inbound webhook route for jobs whose schedule_type is webhook.
| Path | → Service | → Port |
|---|---|---|
/api/* |
core | 8091 (CORE_ADDR) |
/prometheus/metrics |
core | 8091 (CORE_ADDR) |
/riverui/* |
ui | 8090 (UI_ADDR) |
/login, /logout, /admin/assets/*, /admin/accept-invite |
ui | 8090 (UI_ADDR) |
/ (everything else) |
ui | 8090 (UI_ADDR) |
/mcp |
mcp | 8092 (MCP_ADDR) |
/api/webhooks/* |
webhook | 8093 (WEBHOOK_ADDR) |
Why the constraints matter:
- Core registers no login routes. An unauthenticated request to core gets redirected
to
/login— which ui, not core, serves. Put them on different origins and login redirects break. /login,/logout,/admin/assets/and/admin/accept-inviteare registered outside the auth middleware so the login form stays reachable. Everything else on ui sits behind it./logoutacceptsPOSTonly; aGETto it does not end the session.- Auth is already enforced in-app.
/prometheus/metricsrequires the metrics-read permission;/riverui/requires a write permission, because River UI can retry, cancel and delete jobs — session validity alone would let a read-only user mutate the queue. /api/webhooks/*must stay public. It is served byzilean-webhook, not core, and sits outside admin session auth — the per-job token in the URL is the credential, so anything that can reach the path can fire the job it names. Route it straight through rather than behind the admin auth gate, and rate-limit it at the proxy: because it is public and effectively unauthenticated apart from the token, that limit is the primary control. PointWEBHOOK_TRUST_PROXY_HEADERSat it only if the proxy actually sets and sanitizesX-Forwarded-For(see Environment variables). The webhook limiter keys off the leftmost entry only when it is a valid IP; a blank or non-IP entry, or an absent header, falls back to the connection’s remote address. IPv6 clients are keyed by their/64prefix.POST /loginandPOST /admin/accept-invitedeserve the same proxy limit. They are served outside the auth middleware and both reach a store lookup and bcrypt work before any outcome is known, so rate-limit them at the proxy — that limit is the primary control, exactly as it is for/api/webhooks/*. In-process, each route has its own per-IP limiter — IPv4 per address, IPv6 per/64prefix — admitting 10 POSTs per minute with a burst of 5 by default, configurable withUI_LOGIN_RATE_PER_MINUTEandUI_LOGIN_RATE_BURST, and caps the request body at 64 KiB before any store lookup or bcrypt work. On/loginonly a POST carrying a valid CSRF token spends a limiter token, so a cross-site page cannot burn your login budget.GET /loginis never counted, so normal page loads and session-expiry redirects are unaffected, and saturating one route’s bucket does not block the other. A limited or rejected submission re-renders the login or accept-invite page with the status and a message:429“Too many sign-in attempts. Try again in a minute.” withRetry-After: 60;413“Form submission too large.” for an oversized body; or400“Could not read form submission.” for a malformed one. Those are the login messages; invite acceptance shows equivalent messages that also tell the user to open their invite link again. ui also bounds each connection itself (10 s for request headers, 30 s to read the request, 120 s idle, 64 KiB of headers, 60 s to write the response), so a stalled client cannot pin a handler. The limiters read only the leftmostX-Forwarded-Forentry, and only whenUI_TRUST_PROXY_HEADERSistrue; a blank or invalid entry — or an absent header — falls back to the connection’s remote address. Behind a reverse proxy withUI_TRUST_PROXY_HEADERSunset, 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. Leave itfalseonly if every client reaching ui connects directly; otherwise setUI_TRUST_PROXY_HEADERS=truewhen the proxy sets and sanitizesX-Forwarded-For(overwriting rather than appending to a client-supplied value), and keep the proxy-level/loginlimit as the primary control (see Environment variables).- Keep
/prometheus/metricsoff any internet-facing hostname. It is authenticated, but there is no reason to expose it. Scrape it on an internal hostname. Its own path is also what keeps it from colliding with the admin SPA’s client-side/metricsroute — routing that path to ui instead lets the SPA render its Metrics page on a hard refresh too, not just on in-app navigation.
Address core by its service name and literal port (e.g. zilean-core:8091). Proxy
templates that auto-discover upstreams typically only resolve the container they are
attached to.
Verifying a deploy
Section titled “Verifying a deploy”In this order:
- Login works end to end — the site root serves the login page, logging in lands in the admin SPA, logging out returns to the login page.
/riverui/renders. Served by ui, whose River client is deliberately never started — worth an explicit check, since no test can cover it.- The admin UI’s job list loads. This is the real test of whether
/api/*reaches core. The SPA calls its API same-origin, so a wrong prefix shows as an empty list, not an error. - A job runs. Use
SEED_JOB=1for one boot, or trigger an existing job manually and watch it in River UI. - Script worker isolation is actually in place, before enabling any script job. See Script jobs for what that does and does not cover.
Rollback
Section titled “Rollback”There is no down migration path — dbmigrate/migrations/ contains only *.up.sql.
Schema additions are generally additive and an older binary won’t reference new columns,
but that is not a guarantee across every migration: at least one has renamed a column,
which requires all binaries to be rebuilt and restarted together rather than rolled
out incrementally, and needs the rename reversed by hand to roll back.
Check what a version bump actually contains before assuming a rollback is safe.