nibrunner

Configuring a host

What every key in a host's config.toml is, and the three rules that explain every refusal.

nibrunnerd install laid this host out and left you a config.toml to make its own. This is what every key in that file is.

The file is at /etc/nibrunner/config.toml unless NIBRUNNER_CONFIG names another. It is read once, at startup, and validated whole — so a host that starts is a host whose configuration was right, and one that is wrong says which key and why before it does anything.

nibrunnerd start after editing it. It re-renders what install wrote, leaves what it did not alone, and restarts whatever read something that changed.

Three rules that explain every refusal

No key has a default. A key its section declares but the file omits is refused by name. There is no second copy of the answer compiled in for an absence to fall back to, so what a running host does is what this file says.

An unknown key is refused too. A mistyped key and a missing one are different errors, which is the thing an environment variable could never do for you.

A whole section is what may be absent[proxy.http.tls], [proxy.raw], [metrics]. A section that is present is filled in completely, so there is no half-configured listener to warn about at startup because there is no way to write one.

volumes.backend is not specified, and nothing here is optional
volumes.zerofs is not read by the local-file backend, which is what volumes.backend says
proxy.http.port is not free, because 21000-21999 is what a slot takes for an app's loopback port

Every host has these

KeyTypeMust be
paths.state_dirstringabsolute path — everything this host keeps, state.db included
paths.runtime_dirstringabsolute path — sockets and pidfiles that outlive the daemon
paths.snapshot_dirstringabsolute path — where a sleeping app's memory goes
paths.guest_image_dirstringabsolute path — vmlinux, rootfs.ext4, manifest.json, put there by install
paths.desired_state_filestringabsolute path — the document it watches
paths.api_socketstringabsolute path
paths.versions_filestringabsolute path — what install stamped what it laid down into
artifacts.store_urlstrings3://bucket[/prefix], or an absolute path
volumes.backendstringlocal-file or zerofs
volumes.storage_prefixstring1–512 bytes, no leading or trailing /, no empty or ./.. segment
exports.store_urlstringsame rule as artifacts.store_url
exports.staging_dirstringabsolute path — a bundle is assembled here and removed after
network.denied_egress_addresses_v4array of stringeach a.b.c.d/n, n ≤ 32
network.denied_egress_addresses_v6array of stringeach addr/n, n ≤ 128

Both arrays may be empty, but the keys must be there. What they hold is the ranges a guest is denied by name, on top of the blanket rules — public addresses that are still yours, and that a tenant must not reach.

volumes.storage_prefix is one host, not one app. Every tenant placed here shares it. Deleting it destroys all of them.

Where volumes live

backend = "local-file"

Volumes are sparse files under the state directory. Nothing else to configure, and [volumes.zerofs] is refused rather than ignored.

What this costs is the property the other backend exists for: a volume that outlives the machine. The flush that is the durability point becomes the host's page cache rather than a service that has to be asked, and an export is refused rather than written, because a checkpoint is something only an object store can cut.

What it buys is density. A host is bounded by the 1000 loopback ports a slot takes.

backend = "zerofs"

Blocks live in an object store and are reached from the guest over NBD. install renders ZeroFS's two configuration files and the two units that supervise it from the keys below, so these are the only place any of it is said.

KeyTypeMust be
binarystringabsolute path — where install puts ZeroFS
config_filestringabsolute path — rendered by install
mount_pathstringabsolute path — this host's own view of the filesystem
nbd_socket_pathstringabsolute path
ninep_socket_pathstringabsolute path
rpc_socket_pathstringabsolute path
storage_urlstrings3://bucket/prefix, or an absolute path
cache_dirstringabsolute path
cache_disk_gibinteger> 0, whole gibibytes
cache_memory_gibinteger> 0, whole gibibytes
checkpoint_runtime_dirstringabsolute path
checkpoint_config_filestringabsolute path — rendered by install
checkpoint_cache_dirstringabsolute path

Size cache_disk_gib against the disk it is on. It is a cache of the object store, and it shares that disk with the snapshots in paths.snapshot_dir. A snapshot lost costs one cold boot; a full disk breaks the filesystem every app on the host runs from, asleep or not.

Whole gibibytes, not fractions. The daemon holds this cache back from what any guest may be promised, and reads it back out of the file it rendered — which truncates. A fraction would reserve against a number ZeroFS is not taking, and a host that promises memory the cache will take back kills tenants.

This backend caps a host at 63 apps. Slot N takes /dev/nbdN and the export reader holds the last of them.

What this host serves

Every way in is a section under [proxy], and each is absent or complete. Each binds an address of its own, because each faces a different machine.

SectionKeyWhat
[proxy.http]listen_address, portthe one HTTP listener, where the edge reaches it
[proxy.http.tls]certificate, keyserve that port encrypted, with this material
[proxy.http.tls.client_ca]certificatea PEM trust pool
[proxy.raw]listen_address, max_ports_per_guestports carried to a guest unread, where the relay reaches them

One HTTP listener, not a plain one and a TLS one. Nothing here redirects, so two would serve every app unencrypted and encrypted at once, forever, with nothing moving a visitor from the first to the second. [proxy.http.tls] absent is plain HTTP, which is what a host behind an edge that terminates TLS wants.

This daemon does not obtain certificates. It serves what is at the path it was given. ACME, renewal and rate limits belong to certbot, or to the edge. It is read once, at startup, so a renewed certificate needs systemctl restart nibrunnerd — safe, because nothing this daemon does stops a tenant, and neither does its death.

One certificate covers the whole host. There is no SNI selection, so every hostname a tenant holds has to be covered by this one — a wildcard, in practice, which matches one label deep: app.example.com but not a.b.example.com.

Naming a client_ca makes a caller's own certificate the price of the handshake. On an origin whose IP is discoverable, that is what keeps it reachable only through the edge. It also means you cannot reach it yourself without one, so turn it on after the plain path is proven.

A connection whose handshake named one app and whose request names another gets a 421. That is the only thing standing between two tenants that share a certificate.

One HTTP port per guest. A guest's hostname resolves to this host, and [proxy.http] carries it to the one port that guest answers HTTP on. That is what the subdomain means, so there is no second.

[proxy.raw] is the way in for a protocol this host does not read — ssh, DNS, WireGuard. Such a port carries bytes and nothing else, so nothing can route it by name and it is reached at a port of its own. It carries whatever arrives, tcp or udp — a port is a port, and the relay in front of this host forwards both for every one in its range. max_ports_per_guest is how many, bounded by what a slot reserves past the HTTP port, which is seven. Absent carries nothing raw.

A raw port is never published from this host. An address a tenant hands to its own users is published by definition, and publishing this host's would put every guest on it in front of one tenant's users. So the relay that publishes it is a machine of its own, and proxy.raw. listen_address is where that relay reaches this host — a private address, never the world's. Binding it to 0.0.0.0 is a choice this file lets you make and says out loud.

A document asking for what this host does not serve is refused by name, and the instance is reported failed saying so, rather than started somewhere nothing could reach it: a hostname on a host with no [proxy.http], or more ports than [proxy.raw] allows.

Metrics

KeyTypeMust be
portintegera free port
listen_addressstringan IP address to bind

The page is rendered from the same builder that writes reported.json, so a scraper and the file cannot disagree. Nothing here is an input: there is no route that changes anything.

Not 9091 on a zerofs host — ZeroFS holds that one, and it is refused by name here rather than becoming a startup failure over there.

Ports, across every section

21000–28999 is refused everywhere. Each slot reserves eight consecutive ports from 21000, and a listener inside that range would be taken out from under you by the next app deployed. A slot hands out only as many as the document asked for — one, or two — and the rest are reserve, so that raising the limit later moves nobody's ports. 0 is refused separately: that is the kernel picking one, and a host should say what it serves on.

metrics.port must differ from proxy.http.port.

What is not in this file

No secret. The AWS credentials and the ZeroFS encryption password live in host.env beside this file, which install creates with every variable named — the ones this configuration needs uncommented and empty, the rest commented — and never writes into. nibrunnerd start refuses while one it needs is still empty. The rendered ZeroFS configuration references ${ZEROFS_ENCRYPTION_PASSWORD} and ${AWS_REGION}; the daemon resolves the rest from its own environment. So this file can be read over someone's shoulder.

Nothing about what runs here. Which apps this host serves is desired.json, which is watched rather than read once, and which whatever writes it is not this daemon's concern.

NIBRUNNER_LOG stays in the environment — it is a tracing filter an operator changes to debug one restart, not a property of the host.

Two worked examples

Both are rendered from the code that reads this file, so neither is a copy that can fall behind it.

The starting point — volumes as files on this machine's own disk, stores as directories on it, plain HTTP on :80 — is what install writes when a host has no configuration at all. There is no copy of it in the repository: run nibrunnerd install on a host with none, or read HostConfig::starter in crates/nibrunnerd/src/config.rs.

deploy/config.example.toml is a host with every section: volumes in an object store, artifacts and exports in S3, TLS behind an edge that presents a client certificate, raw ports for a relay, and a metrics page. just config-example writes it from HostConfig::example, and CI fails when it is behind.

On this page