ADR 0008: Public bootstrap node and its packaging
Status: Provisional (2026-10-15, revised the same day: docker compose on a VM replaces Kubernetes).
Code: crates/wats/src/bootstrap.rs, Dockerfile, deploy/compose/,
.github/workflows/release.yml. Runbook: docs/deploy-bootstrap.md.
Context
Section titled “Context”New nodes need a first peer. Until now, peers exchanged tickets by hand. The bootstrap node must:
- be reachable by name (the
wearetheswarm.aizone is at Gandi); - keep a stable identity;
- not be a trust anchor: a hijacked DNS record must not let someone else pose as the router.
Decision
Section titled “Decision”Bootstrap entries
Section titled “Bootstrap entries”- Format.
<endpoint-id>@<host>:<port>. - Resolution. The host is resolved with DNS at startup, so the IP can move. The endpoint id is pinned: Iroh authenticates every QUIC connection against the dialed key, so a host answering with another key is never connected to.
- Built-in list.
DEFAULT_BOOTSTRAPis compiled in. Since v0.1.1 it holdsrouter.wearetheswarm.ai(id9132c3e3…d603d8, deployed 2026-10-15). - Overrides.
--bootstrap/WATS_BOOTSTRAPadd entries.--no-default-bootstrap/WATS_NO_DEFAULT_BOOTSTRAPdisable the built-in list.--loopbacknever uses it.
- Failure handling. A node skips itself, and skips entries that fail to resolve. Entries are resolved concurrently, each with a 10 s DNS timeout, so one dead seed never delays the others; the built-in list can hold several seeds.
- Peer cache first (addendum, 2026-10-16, security review B1). A node
persists the peers that answered its
hello, with their last known addresses and last-seen time, in<data dir>/peers.json(64 most recent, 30 days at most, saved every 5 minutes and at shutdown). At startup it dials them first, with the--bootstrapentries and peer tickets. The built-in list is the fallback: dialed at once when the cache is empty, otherwise only if no gossip neighbor is up 15 s after start. So the swarm keeps forming when our router is down or blocked, and the router is not contacted by every restart. A loopback (hermetic) node keeps only the loopback addresses of its cache. - Empty values. Empty
WATS_*values (common in.envfiles) count as unset.
Router configuration
Section titled “Router configuration”- Port. A fixed UDP port, 7842.
--port/WATS_PORTbinds0.0.0.0:<port>(required) and[::]:<port>(best effort), so that both the A and the AAAA record work. - Public address.
--external-addr/WATS_EXTERNAL_ADDRadvertises a public address when the node sits behind NAT.
Identity
Section titled “Identity”- Where it is read from.
--secret-file/WATS_SECRET_FILEreads the root secret from a file that must exist, instead of<data dir>/secret.key. For the router it is/etc/wats/secret.keyon the VM, mounted read-only. - Where it is generated. On the operator’s machine. That copy goes to a password manager and is the only backup that matters. A missing secret file is an error, never a silently fresh identity.
- Printing it.
wats idprints the endpoint id, the author id and the bootstrap entry. It does not need the node to be stopped.
Shutdown
Section titled “Shutdown”- Signals. The node shuts down cleanly on SIGTERM as well as Ctrl-C.
- Registration. Handlers are registered at startup, because a process running as PID 1 in a container ignores signals it has no handler for.
Disk guard (stopgap)
Section titled “Disk guard (stopgap)”--blob-quota / WATS_BLOB_QUOTA stops fetching blobs for others once the
blob store is over budget. --max-blob-size / WATS_MAX_BLOB_SIZE caps
single blobs. The router is a frequent replica, so it needs this until the
Phase 2 give quota exists.
Packaging
Section titled “Packaging”- Images. Two Dockerfiles, same runtime contract (user 65532, data
on a
/datavolume,wats nodein the foreground):Dockerfilefor local builds and the compose test: a multi-stage build, Rust 1.99 on Debian trixie, then distrolesscc-debian13:nonroot;Dockerfile.releasefor published images: it packages the staticx86_64-unknown-linux-muslbinary the release already built onto distrolessstatic-debian13:nonroot. Base images are pinned by digest.
- Static binaries. musl builds use
mimalloc(MIT) as the global allocator, because musl’s allocator is slow under concurrency. Release binaries are stripped. - Release.
release.ymlcompiles each target once (static Linux, macOS arm64), attaches the binaries to the release, and pushes the Linux one as a linux/amd64 image,ghcr.io/wearetheswarm/wats:<version>and:sha-<commit>, to a private GHCR package with OCI labels.- It runs on
release: publishedor by hand, and checks that the tag matchesCargo.toml. - There is no
latesttag: deployments pin a version. - No image is built on pull requests or pushes (CI budget).
- It runs on
- Production: docker compose on a VM (
deploy/compose/compose.router.yaml).- OVH VPS, Debian 13, fixed public IPv4 and IPv6.
- Host networking, so UDP 7842 sits on the public addresses with no Docker NAT.
restart: unless-stopped, a named volume for/data, and the key file mounted read-only.- Same hardening as before: non-root, read-only root filesystem, no
capabilities,
no-new-privileges, resource limits, rotated logs.
- Local test (
deploy/compose/compose.yaml,scripts/compose-test.sh):- a router, a peer, a poster and a late joiner on a compose network,
bootstrapping through
<router-id>@router:7842; - it exercises DNS resolution and the pinned id, gossip, replication and sync, in one command;
- it is not run in CI.
- a router, a peer, a poster and a late joiner on a compose network,
bootstrapping through
Why not Kubernetes
Section titled “Why not Kubernetes”A first version shipped Kubernetes manifests (StatefulSet, UDP LoadBalancer). They were dropped for three reasons:
- QUIC needs a public UDP port.
- UDP listeners on the OVH managed Kubernetes LoadBalancer are uncertain, and Ingress does not apply to QUIC at all.
- The router is a single stateful node that wants a fixed IP. A VM gives it exactly that, with fewer moving parts. Kubernetes can come back for durable nodes at scale.
Consequences
Section titled “Consequences”- Not a trust anchor. Losing the router does not stop the swarm: nodes that know each other keep working, and the default list can hold several routers later. DNS hijacking can only cause a denial of service, never impersonation.
- Key rotation. Rotating the router key means a new release with a new pinned id. Old binaries then fail to bootstrap through it, though they keep any peers they already know.
- Disk. Until Phase 2 quotas, the router’s disk use is bounded only by the blob quota.
- Later options. arm64 images on a native runner, a health endpoint, metrics, and several routers in other regions.