Skip to content
Noite
Esc
↑↓navigate↵open⌘Jpreview
On this page

Tenancy

NOITE_TENANCY decides whether other people may push code — multi runs tenant code sandboxed, single is just you.

NOITE_TENANCY decides whether other people may push code to the install. Unset, it is multi off a localhost base, single on.

  • multi: tenant builds and release commands run as the app’s own unprivileged uid (one uid per app, from a reserved range) with a cleared environment (no platform secret reaches them), tenant fleets as the fleet user each in its own cgroup with its own memory.max (RUNNER_FLEET_MEMORY_MB, overridable per app in Settings → Limits). Worker egress is confined to public addresses inside celld, with the object store as the one exception (Worker code can reach it but holds no keys); an nftables policy closes the build uids off loopback, private ranges and cloud metadata the same way. Each fleet node’s operator API additionally requires a per-fleet token the runner mints and sends back on its own internal calls. /data is private to the runner. Release commands run with the scope’s own bucket keys (scoped credentials); where no scoped provider exists they are skipped. The base compose grants NET_ADMIN, SETUID, SETGID and CHOWN; the cgroup grant is the multi-only docker/compose.multi.yaml overlay (CAP_SYS_ADMIN plus lifting Docker’s default AppArmor mount denial) — layered by the installer when .env says multi, by hand otherwise. Without them the runner keeps /ready at 503 with the reason and refuses builds.
  • single: only you push code. No egress policy, and release commands run with the root bucket keys.

What multi does and does not promise (alpha)

multi is for semi-trusted tenants: people you would invite to your server, running code you do not review, who are not trying to break out. It stops accidents and casual snooping. It is not a boundary you can hand to strangers.

  • Fleets and release commands hold scoped bucket keys for their own prefix only — an app reaches fleets/{slug}/, a preview environment reaches envs/{slug}/{label}/ (never production’s), and no key lists the bucket root. The one exception is a store with no prefix-scoped keys (R2) or without the extra configuration (AWS S3 needs NOITE_CREDENTIAL_PROVIDER=aws and an IAM-capable key): there fleets keep the root keys as an explicit root mode the boot log, /ready and make doctor all name. Worker code holds no keys either way.
  • Worker code and builds can reach the object store’s unauthenticated surface (it answers 401/403; builds hold no keys).
  • Each tenant fleet runs in its own cgroup with its own memory.max, so one app’s growth hits its own limit instead of pushing every app toward the same shed threshold. Swap is disabled per leaf, so a capped fleet is OOM-killed rather than swapped out. An OOM kill restarts the fleet and records a tenant-visible fleet event and fleet log line. Without the multi-only cgroup grant (docker/compose.multi.yaml) fleets share the container cgroup and multi stays at 503.
  • Release commands (for example D1 migrations) are skipped in multi where no scoped provider exists, because they would need the root keys.

Run multi only for people you trust the way you would trust a colleague with shell access to a shared box, and use single when you are the only one pushing. The full list is in Known limits.

The repository’s isolation lane (make e2e-isolation, also runnable from the CI workflow) proves the multi claims: a hostile tenant app tries to read platform secrets and files, another app’s build cache and worktree, another app’s bucket prefix and the bucket root from its release command, and to reach every internal API from its build, its release command and its Worker, and the lane asserts every attempt fails while internet egress still works.

NOITE_TENANCY and related knobs are documented in Environment variables.

Sandbox containers

Apps built on the agent template run tools (or the whole agent) in Sandbox containers: celld containers classes on the install’s own engine, as host siblings fenced by celld. Only the runner holds the host engine socket (DOCKER_SOCKET, default /var/run/docker.sock): each fleet gets a filtered per-fleet engine socket from the runner, and a relay container in the host network namespace carries the container path. Tenants never see the real socket and never reach another app’s sandbox, and deploys may only reference registry images or Dockerfiles that run nothing (FROM, COPY and metadata only, as in Void’s default Sandbox image): a RUN step, a base image with ONBUILD triggers, or a per-class runtime fails the deploy with a direction. The runner pins every base image by digest before celld builds.

NOITE_SANDBOX switches the whole plane: unset is on when the engine socket answers; 0 (also false/off) turns it off — no relay, no per-fleet sockets, fleets get no DOCKER_HOST, and deploys declaring containers fail. Turn it off where no app needs Sandbox containers. Two requirements when it is on: a rootful engine (Sandbox needs a rootful Docker daemon — celld’s fence needs NET_ADMIN in the host netns, which neither rootless Podman nor rootless Docker can grant) and Docker 26 or newer for the relay’s volume mount. On a rootless dev host you normally set nothing by hand: when no engine socket exists at all (no DOCKER_SOCKET override and no /var/run/docker.sock), make up and make dev set NOITE_SANDBOX=0 themselves. That check only looks for a socket, not whether the engine is rootful, so with a rootless engine keep NOITE_SANDBOX=0 explicit — even when its socket exists or you point DOCKER_SOCKET at it. make doctor reports the plane’s status (enabled, engine, relay, subnets) from /ready; it is informational and never fails readiness.

Sandbox containers still share the host kernel (runc; gVisor support is on the roadmap) and pulled images are never garbage-collected, so engine disk grows with every new image. See Known limits.

Was this page helpful?