Environment variables
Every runner env var with its default, effect, and owner.
Defaults below are what docker/compose.yaml applies when the var is unset. The runner refuses dev secrets off a localhost base. A set-but-malformed numeric value refuses the boot instead of falling back: the runner exits naming each bad var, its value and the expected form — unset (or empty) still means the default.
Ownership: runner-owned (read by the runner) vs worker-passthrough (handed to the control UI worker untouched).
These are install-level variables (Compose, the image environment). Per-app values live in each app’s Settings → Environment instead: they reach the app’s install, build, release command, fleet, and Worker code (env.NAME), and a change applies from the next deploy. See Configure.
Image
| Var | Default | Effect | Owner |
|---|---|---|---|
NOITE_IMAGE |
ghcr.io/ryuzcorp/noite:alpha |
Image Compose pulls: a release channel (alpha, stable) or a version tag |
Compose |
Domain and edge
| Var | Default | Effect | Owner |
|---|---|---|---|
BASE_DOMAIN |
noite.localhost |
Tenant + control DNS base | runner-owned |
CONTROL_SUBDOMAIN |
(empty) | Empty = control on bare domain; prod sets app |
runner-owned |
CONTROL_EXTRA_HOSTS |
(empty) | Comma-separated extra control hostnames | runner-owned |
CADDY_AUTO_HTTPS |
unset = on except a localhost base |
off behind a terminating proxy |
runner-owned |
HTTP_PORT |
9080 |
Host HTTP port | runner-owned |
HTTPS_PORT |
9443 |
Host HTTPS port | runner-owned |
GIT_PUBLIC_BASE |
http://git.noite.localhost:9080 / https://git.<domain> |
Public Git remote base | runner-owned |
BETTER_AUTH_URL |
http://noite.localhost:9080 |
Control origin; set before first registration (passkeys bind to it) | worker-passthrough |
CADDYFILE_PATH |
/data/caddy/Caddyfile |
Generated Caddyfile location | runner-owned |
CADDY_ACCESS_LOG |
/data/caddy/access.log |
JSON access log tailed for device/path/ref stats | runner-owned |
CADDY_ADMIN_URL |
http://127.0.0.1:2019 |
Caddy admin endpoint for /load writes |
runner-owned |
RUNNER_BIND |
0.0.0.0:8080 |
Runner listen address | runner-owned |
RUNNER_WORK_DIR |
/data/runner |
Fleet working dirs, mirrors, builds | runner-owned |
RUNNER_POLL_MS |
5000 |
Reconcile tick interval | runner-owned |
CELLD_BIN |
celld |
Fleet binary path | runner-owned |
CONTROL_BUNDLE_DIR |
/opt/noite/control/dist |
Baked control UI bundle (fleet #0) | runner-owned |
RUST_LOG |
noite_runner=info,tower_http=info |
Runner log filter | runner-owned |
CELLD_ADVERTISE |
RAILWAY_PRIVATE_DOMAIN, else the container hostname |
Address other control nodes dial during an overlapping redeploy (:8091); must be reachable from a peer |
runner-owned |
Auth
| Var | Default | Effect | Owner |
|---|---|---|---|
RUNNER_TOKEN |
dev-runner-token |
Bearer token for runner API + UI server calls; refused off a localhost base at default |
runner-owned |
BETTER_AUTH_SECRET |
dev-only-change-me-to-a-long-random-string |
Session signing secret; dev- values refused on real domains |
worker-passthrough |
NOITE_ADMIN_EMAIL |
(empty) | Only this address may claim the first admin account, and it must still present the one-time setup code (asked by the installer; kept on re-runs). Also promotes the matching account to admin at startup. Empty on a public base means the first registration needs the code alone instead | worker-passthrough |
NOITE_EMAIL_WEBHOOK_URL |
(empty) | Receives lost-passkey OTP JSON; unset = printed to the control log on a localhost base, not sent (the failure is logged) on real domains. Without it, use noite-runner recover |
worker-passthrough |
NOITE_SMTP_FROM |
(empty) | OTP sender label | worker-passthrough |
Store
| Var | Default | Effect | Owner |
|---|---|---|---|
NOITE_S3_BUCKET |
noite |
Single bucket; git/, fleets/, control/ prefixes |
runner-owned |
S3_ENDPOINT |
http://rustfs:9000 |
Object-store endpoint | runner-owned |
S3_PUBLIC_ENDPOINT |
http://127.0.0.1:9000 |
Endpoint returned to host-side tools | runner-owned |
AWS_REGION |
us-east-1 |
Store region | runner-owned |
AWS_ACCESS_KEY_ID |
RUSTFS_ACCESS_KEY → noiteaccess |
Store key | runner-owned |
AWS_SECRET_ACCESS_KEY |
RUSTFS_SECRET_KEY → 32-char dev default |
Store secret | runner-owned |
RUSTFS_ACCESS_KEY |
noiteaccess |
Bundled RustFS key | runner-owned |
RUSTFS_SECRET_KEY |
32-char dev default | Bundled RustFS secret | runner-owned |
RUSTFS_API_PORT |
9000 |
Bundled RustFS S3 port (loopback) | runner-owned |
RUSTFS_CONSOLE_PORT |
9001 |
Bundled RustFS console port | runner-owned |
NOITE_DB |
/data/noite.sqlite (image) |
Runner SQLite path (sqlite: prefix optional) |
runner-owned |
Tenancy
| Var | Default | Effect | Owner |
|---|---|---|---|
NOITE_TENANCY |
unset = multi off a localhost base, single on |
multi sandboxes builds/fleets as users behind egress policy; single = operator pushes only |
runner-owned |
NOITE_CREDENTIAL_PROVIDER |
auto |
Where tenant fleet keys come from: auto scopes the bundled RustFS and probes other stores for the MinIO admin API, minio forces that provider (MinIO, RustFS), aws forces the AWS IAM provider (needs an IAM-capable key), root keeps the root keys explicitly |
runner-owned |
NOITE_SANDBOX |
unset = on when the engine socket answers | Sandbox containers (celld containers classes, e.g. the agent template); 0, false or off disables them: no relay, no per-fleet engine sockets, fleets get no DOCKER_HOST, and deploys declaring containers fail |
runner-owned |
RUNNER_BUILD_UID_BASE / RUNNER_BUILD_UID_RANGE |
10030 / 1024 |
Reserved per-app build uid range [base, base + range); each app’s builds and release commands run as their own uid from it |
runner-owned |
RUNNER_FLEET_UID / RUNNER_FLEET_GID |
10020 |
Tenant fleet user | runner-owned |
NOITE_FLEET_PORTS |
20000-29999 |
Fleet port range, two ports per app | runner-owned |
NOITE_STOP_BUDGET_MS |
25000 |
Graceful-stop budget (min 5000); fleets stop inside budget − 3 s |
runner-owned |
RUNNER_BUILD_MAX_MB |
2048 |
Refuse builds past this worktree size (MiB) | runner-owned |
RUNNER_BUILD_TIMEOUT_S |
1800 |
Bound on each install and build step (seconds) | runner-owned |
RUNNER_CI |
on | Run a repo-root noite.ci.yaml in containers instead of the built-in install/build; 0/false/off disables |
runner-owned |
RUNNER_CI_IMAGE |
oven/bun:1.4.4 |
Image for a CI job that names none | runner-owned |
RUNNER_CI_IMAGES |
the default image only | Comma-separated exact image allowlist; the default image is always allowed. Use a tag or digest | runner-owned |
RUNNER_CI_NETWORK |
bridge |
Docker network for CI containers: bridge (registry access) or none |
runner-owned |
RUNNER_CI_DOCKER_BIN |
docker |
Docker CLI binary for CI containers | runner-owned |
Bounds
| Var | Default | Effect | Owner |
|---|---|---|---|
RUNNER_MAX_APPS_PER_USER |
10 |
Per-account app quota, enforced on create | runner-owned |
RUNNER_MAX_ENVIRONMENTS_PER_APP |
5 |
Preview environments per app, enforced at create | runner-owned |
RUNNER_MAX_ENVIRONMENTS |
25 |
Preview environments per instance, enforced at create | runner-owned |
RUNNER_FLEET_MAX_RSS_MB |
0 |
→ CELLD_MAX_RSS_MB when non-zero; container-wide shed threshold by design (celld measures container-scale working set), never per-fleet; 0 = celld default (80% of container memory) |
worker-passthrough (fleet) |
RUNNER_FLEET_MEMORY_MB |
512 |
Per-fleet memory.max in MiB (own cgroup per fleet); per-app override in Settings → Limits; needs the multi-only cgroup grant (docker/compose.multi.yaml) — without it fleets share the container cgroup |
runner-owned |
RUNNER_FLEET_MEMORY_MAX_MB |
default cap | Ceiling for a per-app memory override in MiB; in multi a tenant can only lower its cap, never raise it past this. Single installs ignore it |
runner-owned |
RUNNER_FLEET_IDLE_EVICT_S |
120 |
→ CELLD_IDLE_EVICT_S; idle seconds before a fleet hibernates cells |
worker-passthrough (fleet) |
RUNNER_FLEET_LEASE_TTL_MS |
60000 |
→ CELLD_TTL_MS on every fleet and the control node; a storage or disk stall longer than two thirds of it restarts every app. Minimum 10000 |
worker-passthrough (fleet) |
RUNNER_FLEET_DEPLOY_POLL_S |
300 |
→ CELLD_DEPLOY_POLL_S; covers missed /reload only |
worker-passthrough (fleet) |
RUNNER_FLEET_ASSET_CACHE_MB |
64 |
→ CELLD_ASSET_CACHE_BYTES; per-fleet on-disk asset cache |
worker-passthrough (fleet) |
RUNNER_FLEET_LOCAL_CACHE_MB |
1/16 of detected memory, clamped 64–512 |
→ CELLD_LOCAL_CACHE_MAX_BYTES; per-fleet hibernated-DB cache (celld’s own default is 2 GiB per node) |
worker-passthrough (fleet) |
RUNNER_CONTROL_MEMORY_MB |
1/4 of detected memory, clamped 256–1024 |
Control fleet (fleet #0) cap in MiB; its own cgroup leaf when the install has writable cgroups, else the container-wide shed threshold |
runner-owned |
RUNNER_FLEET_SPAWN_BATCH |
1 on ≤ 2 cores, else 2 |
Cold fleets started per reconcile pass, so a boot or upgrade does not stack every celld at once; restarts are never batched | runner-owned |
RUNNER_BUILD_CONCURRENCY |
1 on ≤ 2 cores, else 2 |
Concurrent tenant deploys; one slot covers install, build, release and the celld deploy |
runner-owned |
RUNNER_BUILD_MEMORY_MB |
detected memory minus a 1 GiB resident floor (a quarter of bigger boxes), split across build slots, clamped 512–4096 |
memory.max of each deploy’s build cgroup leaf, which runs install, build and release; 0 leaves builds uncapped. Over the cap the leaf may swap up to RUNNER_BUILD_SWAP_MB, then the step is killed alone with a log naming both budgets — fleets and the runner keep serving. A Vite 8/Rolldown build of a Void app peaks around 1.3–1.5 GiB: on a box that cannot spare that in RAM the swap carries the overflow, and a box with no swap or a build that fills both still fails with the budget named. The leaf also gets a hard CPU quota (one core less than the box, split across concurrent build slots) and a low I/O weight, so a build cannot starve the runner, RustFS or SSH |
runner-owned |
RUNNER_BUILD_SWAP_MB |
the host’s swap split across the build slots, clamped 0–4096; 0 when the host has none |
memory.swap.max of each deploy’s build leaf. The overflow past the RAM cap, so a build larger than the box’s spare RAM finishes (slowly) instead of thrashing at the cap until the step timeout; the watchdog only fails it once RAM and swap are both full. Fleets keep zero swap. An explicit 0 restores the old zero-swap behavior |
runner-owned |
RUNNER_PRESSURE_PCT |
85 |
Container memory percentage that triggers pressure parking of idle apps; 0 disables |
runner-owned |
RUNNER_PRESSURE_IDLE_S |
300 |
How long an app must be idle before pressure may park it | runner-owned |
RUNNER_PRESSURE_SWEEP_S |
30 |
Pressure check interval (minimum 10) |
runner-owned |
RUNNER_OTEL_FLUSH_MS |
30000 |
→ CELLD_OTEL_FLUSH_MS; bucket-flush + ingest tick cadence (min 1000); dashboards lag ~40 s |
worker-passthrough (fleet) |
RUNNER_BUILD_CACHE_MB |
1024 |
Per-app persistent build cache cap (package managers and their caches); 0 disables |
runner-owned |
RUNNER_TELEMETRY_RETENTION_DAYS |
30 |
Days kept in bucket prune, metric tables, glob floor (min 1; a non-integer or < 1 value fails boot validation) |
runner-owned |
RUNNER_TIP_SWEEP_S |
60 |
Fallback S3 tip sweep per app; covers out-of-band bundle writes | runner-owned |
RUNNER_DEPLOY_PROBE_PATH |
/ |
Pre-activation canary: after staging, the deploy asks the staged generation for HEAD <path> through celld invoke before the one activate serves it; an answer below 500 moves the pointer, anything else fails the deploy with the old generation still serving. Empty disables the probe (a celld without invoke) |
runner-owned |
RUNNER_DEPLOY_PROBE_TIMEOUT_S |
30 |
Seconds the canary waits for the staged generation | runner-owned |
RUNNER_FLEET_LOG |
error,celld=warn |
Fleet RUST_LOG (runner’s own filter stays separate) |
worker-passthrough (fleet) |
Sleep
| Var | Default | Effect | Owner |
|---|---|---|---|
RUNNER_SLEEP_AFTER_MIN |
detected memory: 15 (≤ 2 GiB), 360 (≤ 4 GiB), else 1440 |
Park apps idle this many minutes; wins over RUNNER_SLEEP_AFTER_H; 0 = never (no per-app override re-enables it), clamped at 2592000 s (30 days) |
runner-owned |
RUNNER_SLEEP_AFTER_H |
— | The same threshold in whole hours, for windows minutes cannot express; ignored when RUNNER_SLEEP_AFTER_MIN is set; 0 = never (overrides a set RUNNER_SLEEP_AFTER_MIN); clamped at 720 h (30 days) |
runner-owned |
RUNNER_SLEEP_SWEEP_S |
a quarter of the threshold, clamped 300–3600 |
Sleep sweep cadence | runner-owned |
RUNNER_WAKE_TIMEOUT_S |
120 |
Longest a held request waits for a woken fleet; failed wake answers 503 |
runner-owned |
RUNNER_WAKE_CONCURRENCY |
4 |
Apps allowed to cold-start at once for requests; later wakes queue inside RUNNER_WAKE_TIMEOUT_S |
runner-owned |
| See Limits for the sleep mechanism. |
Telemetry and rate limits
| Var | Default | Effect | Owner |
|---|---|---|---|
NOITE_TELEMETRY |
unset = on | Anonymous instance heartbeat is opt-out; 0, false or off disables it and locks the setting (Telemetry) |
runner-owned |
DO_NOT_TRACK |
unset = on | Any non-empty value other than 0 disables the heartbeat and locks the setting, honouring the Do Not Track convention |
runner-owned |
NOITE_UPDATE_CHECK |
unset = on | Daily release check is opt-out; 0, false or off disables it and locks the setting (Updating) |
runner-owned |
NOITE_RATE_LIMIT_RPM |
600 |
Worker per-route-class (auth/invite) limit; 429 + Retry-After; 0 disables |
worker-passthrough |
NOITE_AUTH_RATE_LIMIT |
600 |
better-auth per-client /api/auth/* budget; 0 disables |
worker-passthrough |
NOITE_EDGE_RPM |
1800 |
Edge limit per client IP, per site (control, API, fallback page, each app across its hostnames); 0 disables |
runner-owned (Caddy) |
NOITE_EDGE_GIT_RPM |
120 |
Edge limit per client IP on git.; 0 disables |
runner-owned (Caddy) |
NOITE_EDGE_APP_RPM |
0 |
Edge ceiling per app across every client; 0 = none |
runner-owned (Caddy) |
NOITE_TRUSTED_PROXIES |
unset | Proxies whose forwarded client address the edge believes: cloudflare, private_ranges, IPs/CIDRs |
runner-owned (Caddy) |
See Edge protection for how these fit together.
Void proxy
| Var | Default | Effect | Owner |
|---|---|---|---|
NOITE_VOID_EMAIL_DOMAIN |
(empty) | Sender domain every Void from is pinned to (<slug>[+tag]@<domain>); required for email |
runner-owned |
NOITE_VOID_SMTP_HOST |
(empty) | SMTP relay host; email is enabled iff set | runner-owned |
NOITE_VOID_SMTP_PORT |
(empty) | Relay port; unset = 587/465/25 by mode |
runner-owned |
NOITE_VOID_SMTP_MODE |
starttls |
starttls, implicit or plain |
runner-owned |
NOITE_VOID_SMTP_ALLOW_PLAINTEXT |
(empty) | 1 allows plain (the proxy refuses it otherwise) |
runner-owned |
NOITE_VOID_EMAIL_ALLOWLIST |
(empty) | Admission: comma/space-separated exact addresses or @domain exact domains (subdomains do not match); empty fails closed |
runner-owned |
NOITE_VOID_AI_ENABLED |
(empty) | 1 turns /ai/* on, else every AI call answers 501 |
runner-owned |
NOITE_VOID_AI_MODELS |
(empty) | Model-map JSON for /ai/run: { "<model>": { "endpoint", "model" } } |
runner-owned |
NOITE_VOID_AI_ALLOW_PLAINTEXT |
(empty) | 1 allows http model endpoints, loopback sidecars only (the runner refuses http otherwise; inert in multi tenancy, where loopback endpoints never boot) |
runner-owned |
NOITE_VOID_SANDBOX_MAX_CONCURRENT |
4 |
Max concurrent live sandboxes per Void fleet (0 admits none) |
runner-owned |
Unset means email sends and AI calls answer 501; /revalidate needs no operator config. Per-app credentials (the SMTP login, the AI endpoint key) are tenant Settings, never operator config — see Void email and AI. Misconfigured values refuse the boot naming the var. In multi tenancy the relay host and AI endpoints must be publicly reachable — loopback, private, link-local, CGNAT and ULA addresses, and obviously local names (localhost, *.local, *.internal, single-label names), refuse the boot naming the var.
Platform-only
Set by the platform, not by operators. Railway: PORT (listen port), RAILWAY_DEPLOYMENT_ID, RAILWAY_DEPLOYMENT_DRAINING_SECONDS (must exceed the stop budget + 5 s). TINI_SUBREAPER is set by the image’s init. None of these go in .env.