Limits
Quotas, fleet bounds, rate limits, and the scale-to-zero sleep mechanism.
Quotas
| Knob | Default | Effect |
|---|---|---|
RUNNER_MAX_APPS_PER_USER |
10 |
Per-account app quota, enforced in the runner on create — API keys cannot bypass it |
RUNNER_MAX_ENVIRONMENTS_PER_APP |
5 |
Preview environments per app, enforced at create |
RUNNER_MAX_ENVIRONMENTS |
25 |
Preview environments per instance, enforced at create; each one is a celld fleet and one on-demand certificate |
RUNNER_BUILD_MAX_MB |
2048 |
Builds refused past this worktree size (MiB) |
RUNNER_BUILD_TIMEOUT_S |
300 |
Each install and build step is killed past this (seconds) |
RUNNER_BUILD_CACHE_MB |
512 |
Per-app build cache cap (Bun, npm, pnpm, Yarn and their jup-managed releases); over it, whole caches are dropped, largest first; 0 disables |
RUNNER_TELEMETRY_RETENTION_DAYS |
30 |
Telemetry lifetime in bucket prune, metric tables, and glob floor |
Fleet bounds
Every app is its own celld fleet on one host, each in its own cgroup with its own memory.max. The bounds stop one account — or one busy app — from owning the machine. A fleet that hits its cap is OOM-killed inside its own group and restarted, and the kill records a tenant-visible fleet event and fleet log line (fleet restarted: memory limit … MiB exceeded (Settings → Limits)). The cgroups need the multi-only grant (docker/compose.multi.yaml); without it fleets share the container cgroup and multi stays at 503.
| Knob | Default | Effect |
|---|---|---|
RUNNER_FLEET_MEMORY_MB |
512 |
Per-fleet memory.max in MiB (own cgroup per fleet, plus pids.max); overridable per app in Settings → Limits → Fleet memory (applies on next spawn) |
RUNNER_FLEET_MAX_RSS_MB |
0 |
Shed threshold in MiB; over it, fleets refuse cells with 503 + Retry-After instead of OOM-ing |
RUNNER_CONTROL_MEMORY_MB |
1/4 of detected memory, clamped 256–1024 |
Fleet #0’s cap; its own cgroup leaf (and CELLD_MEMORY_SCOPE=self) where available, else the container-wide shed threshold |
RUNNER_FLEET_LOCAL_CACHE_MB |
1/16 of detected memory, clamped 64–512 |
Per-fleet celld hibernated-DB cache (CELLD_LOCAL_CACHE_MAX_BYTES); celld’s own default is 2 GiB per node |
RUNNER_FLEET_SPAWN_BATCH |
1 on ≤ 2 cores, else 2 |
Cold fleets started per reconcile pass; restarts are never batched |
RUNNER_BUILD_CONCURRENCY |
1 on ≤ 2 cores, else 2 |
Concurrent tenant deploys (one slot covers install, build, release and celld deploy) |
RUNNER_FLEET_IDLE_EVICT_S |
120 |
Idle seconds before a fleet hibernates cells and returns memory |
RUNNER_FLEET_LEASE_TTL_MS |
60000 |
Node lease lifetime; a storage stall longer than two thirds of it restarts every app (minimum 10000) |
RUNNER_FLEET_ASSET_CACHE_MB |
64 |
Per-fleet on-disk asset cache (celld default is 512) |
RUNNER_FLEET_DEPLOY_POLL_S |
300 |
Fleet self-poll for deploys; the runner also POSTs /reload, so this covers misses only |
RUNNER_FLEET_LOG |
error,celld=warn |
Fleet log filter; celld warnings reach the per-app log view |
Rate limits
The edge (Caddy) limits every site before a request reaches a worker; the control UI adds finer limits of its own on sign-in routes. Requests per minute, 0 disables any of them:
| Layer | Var | Default | Scope | Over-limit |
|---|---|---|---|---|
| Edge, per visitor | NOITE_EDGE_RPM |
1800 |
Per client IP, per site: control, API, the fallback page, each app across all its hostnames | 429 + Retry-After |
| Edge, git | NOITE_EDGE_GIT_RPM |
120 |
Per client IP on git. |
429 + Retry-After |
| Edge, whole app | NOITE_EDGE_APP_RPM |
0 (none) |
Per app, across every client | 429 + Retry-After |
| Worker limiter | NOITE_RATE_LIMIT_RPM |
600 |
Per client, per route class (auth/invite) |
429 + Retry-After, before better-auth or D1 |
| better-auth budget | NOITE_AUTH_RATE_LIMIT |
600 |
Per client on /api/auth/*, keyed on forwarded address |
429 |
An app admin can override both app limits (per visitor and whole app) in the app’s Settings → Limits. The edge counts over a sliding window whose whole-event budget never exceeds the configured rate: 1800/min lets 300 requests through in any 10 seconds, and below 6/min the window widens so a small limit stays small (1/min is one request per 60 s). Retry-After carries the real remaining time of the limit that refused. Per-visitor limits group IPv6 addresses by /64. Behind a proxy, see Edge protection.
The edge also bounds each request before a worker sees it: request headers past 16 KiB answer 431, header fields whose names contain _ or . are dropped (a backend that maps -, _ and . to one name would read them as another header), and a body upload or response write that makes no progress for a minute closes the connection. A pause between writes, like a quiet event stream, does not count.
Sleep (scale to zero)
An app idle past its threshold stops costing anything; the next request brings it back transparently (a cold wake measures ~1.3 s). The platform threshold is RUNNER_SLEEP_AFTER_MIN minutes, or RUNNER_SLEEP_AFTER_H hours — minutes win when both are set. Unset, it derives from detected memory: 15 minutes at ≤ 2 GiB, 6 hours at ≤ 4 GiB, else 24 hours. 0 on either disables sleeping install-wide and no per-app override re-enables it; the longest window is 2592000 seconds (30 days) — a larger value clamps there with a boot warning. The sweep runs a quarter of the threshold apart (5 minutes on a ≤ 2 GiB host, an hour on the longer tiers), so an app parks close to its threshold.
Per-app override. Settings → Limits → Sleep after sets this app’s threshold in seconds (0 = never, valid range 60–2592000 = 30 days); empty falls back to the install default. Preview environments use their owning app’s override.
Scheduled work never auto-sleeps. An app whose last deploy uploaded a Wrangler config with triggers.crons or queues.consumers skips the sweep unless its owner set an explicit override — a sleeping scheduler would silently miss its triggers. Memory-pressure parking below still parks it; the next request wakes it.
Sweep. Every RUNNER_SLEEP_SWEEP_S seconds — unset, a quarter of the threshold, clamped 300–3600 — the runner parks apps that are deployed, desired running, awake, and request-free for their window.
Activity is the newest of: the last hour bucket with a fleet-served request in app_hour (edge answers — the :80 redirect, a gate refusal, a rate-limit 429, the starting page — never count), the app’s last wake (woke_at), its last deploy. A fresh deploy or manual start gets a full window.
A lossy log feed never parks. The access log is the activity feed, and its tail drops a whole backlog it cannot catch up on (over 8 MiB unread in one tick). Each such skip is recorded; while it falls inside an app’s sleep window, the sweep refuses to park that app on idleness — the dropped lines may have been its requests. Explicit sleeps (API, operator) and memory-pressure parking are never blocked, and a skip older than the window no longer matters.
Asleep is not stopped. desired_state stays running; sleep is its own column asleep_since, and status reads sleeping. A stopped app never sleeps or wakes; stopping an asleep app clears the flag. Going to sleep: mark asleep → rewrite Caddy sites to wake-on-demand → stop the fleet with normal SIGTERM drain.
Wake flow. Asleep sites route to the fleet port preceded by forward_auth to GET /v1/edge/wake. Caddy holds the request, body included, while the runner:
- Resolves the app from
X-Forwarded-Host(tenant slug or custom domain). - Clears the flag, sets
woke_at. - Spawns the fleet immediately.
- Waits for
/.well-known/celld/health200, bounded byRUNNER_WAKE_TIMEOUT_S(default 120). - Rewrites the Caddyfile back to the plain proxy.
- Answers 200; Caddy proxies the held request as if the app never slept.
Concurrent requests share one wake (per-app lock), and at most RUNNER_WAKE_CONCURRENCY apps (default 4) cold-start at once; the rest queue within the same timeout. Only a failed wake answers 503 — and records a tenant-visible wake failed fleet event plus the wake_failed sleep counter. A deploy, rollback, or web commit to an asleep app wakes it first; TLS ask treats asleep apps as live so certificates renew. First-request cost after a quiet day: one cold fleet start (celld boot + ready gate, typically seconds).
Memory pressure
The runner samples memory use every RUNNER_PRESSURE_SWEEP_S seconds (default 30): the container’s memory charge when the container is bounded, or host used memory when it is unbounded. At or above RUNNER_PRESSURE_PCT of the detected limit (default 85; 0 disables), it parks the least-recently-active app that has been idle for RUNNER_PRESSURE_IDLE_S seconds (default 300), stopping at 10 percentage points below the trigger. A parked app wakes on its next request like any sleeping app, and an app with an in-flight deploy is never chosen. Setting RUNNER_PRESSURE_PCT=0 turns the guard off; the fleet and deploy bounds in the tables above are the first line, and this is the last one before the kernel OOM-killer.
/ready and GET /v1/admin/stats report memory.scope (container or host), current_bytes, limit_bytes and used_percent. A bounded container is measured against its own cgroup; an unbounded one is measured against the host (MemTotal - MemAvailable), because RustFS, dockerd and the system live outside the container — the host figure is what the OOM killer uses. With per-fleet cgroups, stats also lists each fleet’s own charge under fleet_memory. make usage diffs them over a window.