1. The choice
The machine that runs it is an unprivileged Proxmox LXC with Docker 29.3.1 and no nested virtualisation. Options considered:
Each node is
infra/local/node.Dockerfile: debian:trixie (pinned by digest) with
systemd as PID 1, OpenSSH, sudo and Python, plus an ops user whose key is installed at
start, which is what cloud-init does on an Azure Debian 13 VM
(docs/deploy/opentofu.md section 1). Nothing the roles configure is preinstalled:
Docker Engine and Compose come from Docker’s apt repository at their pins, chrony,
nftables and unattended-upgrades from Debian.
--privilegedgives the node the capabilities Docker, nftables and systemd need. They stay inside the LXC’s user namespace, so the node cannot change the host clock or anything outside the LXC./var/lib/dockerand/var/lib/containerdare volumes per node, because overlayfs cannot be stacked on the container’s own overlay root (the first attempt failed withmount ... overlay ... invalid argument).- Two Docker networks stand in for the cloud networks:
sa-lc-public(172.31.99.0/24, default route,gw-priority=1) andsa-lc-private(10.99.0.0/16, addresses.11+indexedge and.21+indexcore per location subnet, as in opentofu.md section 2). - A
registry:3.0.0container (pinned by digest) on the public network plays the repository host’s registry.infra/local/build-images.shbuilds every image fromdeploy/imagesand pushes it there; the nodes pull from it. - Ansible connects as
opsover SSH with a throwaway key. The controller’s address (172.31.99.1) is the only admin CIDR.
2. Running it
make local-converge-up also issues the internal CA and node certificates
(make internal-ca ENV=local, ansible.md section 9). After a converge,
tests/localconverge/tls-audit.sh captures the private network with tcpdump sidecars while the
end-to-end suite runs, checks that no flow carries readable protocol data or any secret of the
environment, and tries every private endpoint from a host without a certificate (finding O-26).
On 2026-11-13 the full six-node topology exhausted this machine’s memory alongside other work
(load above 900); the audit ran on LC_TOPOLOGY=minimal with -e core_umami_processes=1.
LC_TOPOLOGY=minimal before local-converge-up leaves out the second core node (3 edge, 1 core,
1 backup node), the smallest set the roles converge; make rotate-local uses it for the
credential rotation run (plan phase 21).
Since 2026-11-13 site.yml ends with the observability play (phase 17) on every edge
and core node. It needs somewhere to send alerts: the Discord webhook (input I8) does not
exist locally, so pass the address of the test receiver instead, and run that receiver
on the controller, where its log shows every Alertmanager message:
resources/scratch/local-converge/ (SSH key, known
hosts, age identity, logs), infra/secrets/local/ and
infra/ansible/inventories/local/hosts.json, all gitignored. The local secrets are
random values from infra/secrets/new-secrets.py, encrypted with SOPS to a throwaway
age identity; they are local-only and are deleted by local-converge-down.
The inventory up.sh writes has exactly the shape of the OpenTofu output
(opentofu.md section 1) with sa_environment: local, sa_provider: local and the base
domain sa-local.test (the reserved .test TLD). Local-only settings are in
infra/ansible/inventories/local/group_vars/all.yml, each with its reason.
3. What was verified (2026-11-12)
ansible-lint(production profile) andyamllint -spass.deploy/caddy/assemble_test.govalidates the assembled node configs withSA_TLScloudflare, cloudflare-delegated and internal on the custom Caddy build and refuses an unset or unknown value.site.ymlconverged the four nodes from fresh containers, and the next run reportedchanged=0on every node (section 5 has the recap).- Every service ran on every node: on each edge node Caddy, gateway, collector, NATS and etcd; on the core node postgres-ha (Patroni leader, PostgreSQL 18.6), HAProxy, Umami, control plane, replayer, metricswriter, jobs, Caddy with the console, and the oauth fixture.
- NATS: three members, one meta leader, the 10 streams and 6 consumers of
platformctl nats applyat R3. etcd: three members at 3.6.16, one leader. - Port 80 on every node answered 308 (healthy) for any Host header. Stopping the
collector on edge 1 turned edge 1 to 503 and back to 308 once it was restarted.
Stopping NATS on edges 2 and 3 turned edge 1 to 503 (its NATS reported
stream ... is not current). Stopping Umami turned the core node to 503. - From an address on the public network that is not an admin CIDR: 80 and 443 open on every node; 22, 4222, 6222, 8222, 2379, 9090, 5432, 8008, 3000, 5000 and 8085 closed.
- Clock check: every node within 5 ms of NTP (
chronyc tracking). deploy.ymlwith a new image tag recreated the services one node at a time (edge 1, 2, 3, then core 1), each gated on its port-80 answer before the next.tests/localconverge(build taglocalconverge): sign-in through the core Caddy and the fixture as an operator, tenant and site created through the console API, one pageview through each edge node’s Caddy and gateway, all three stored by Umami (website_eventpaths/converge/sa-lc-edge-1..3), an OTLP token created through the API and one delta histogram of 5 requests sent through each edge node’s collector, 15 requests from 3 services inapi.metrics_minute.
4. Limits: what this does not prove
- Not real servers, network or DNS. No network security group, no Azure NAT of the public address (the local nodes hold theirs on an interface), no latency between zones, no Traffic Manager health checks. Hostnames are mapped by the test client.
- No public TLS. Caddy uses its local CA (
sa_tls: internal); DNS-01 throughcaddy-dns/cloudflareis configured (sa_tls: cloudflare), compiles and validates, but has not issued a certificate yet: that happens at the first staging converge. - Clock. The nodes share this machine’s kernel clock and may not set it, so chronyd
runs with
-x(measure, do not steer). The offset check runs for real; clock steering does not. - No GitHub. Sign-in goes to the test-only oauth fixture (
tests/fixtures/githuboauth), through a control plane image built withTAGS=oauthfixturethat exists only in the local registry. The fixture listens on port 8099 of the core node, which the local inventory opens; no other environment may setcore_oauth_fixture(the core role refuses). Real sign-in is blocked on I2, operator ids on I3 (the local operator is fixture user 1001). - PostgreSQL 18.7, the same image as every other environment (plan section 5).
- Two core nodes since phase 16 (section 6); phase 15 ran one.
- Scripted events, not browsers. The web path check uses
tests/gateway/gwclient, which solves the proof in Go. The browser matrices stay with the phase 3 to 5 suites, which start their own stacks and were not re-pointed at this one. - Mutable local tag. The local images are tagged
lc; a rebuild under the same tag is not pulled again (pull: missing). Real releases use immutable tags.
5. Converge record
Fresh nodes (images already in the local registry), 2026-11-13. First converge 177 s, second 47 s, thenmake local-converge-e2e:
not found (the same digest pulled on the other nodes). Image pulls are
now a separate task retried five times; every other compose failure still fails the node.
6. Phase 16 topology: locations, latency, backups
Phase 16 (high availability and drills) extends the topology to the shape of plan section 4.1 plus a backup node:- Locations are separate Docker networks (
sa-lc-fsn1,sa-lc-nbg1,sa-lc-hel1). A router container (sa-lc-router,infra/local/lib.sh) joins them; each node gets the route10.99.0.0/16 via 10.99.<loc>.254at every start (node-init.sh), as a cloud private network would hand it out. Docker drops packets routed between its bridges unless the source bridge is trusted, so each location network trusts the other two (trusted_host_interfaces). - Latency.
tc netemon the router delays what it sends into each location: 1.5 ms toward fsn1 and nbg1, 11.5 ms toward hel1, 0.3 ms jitter. Round trips are therefore about 3 ms fsn1 to nbg1 and 23 ms to hel1 (measured with TCP connects: 4.8 ms and 24.3 ms including the handshake). The local network names keep the labels of the first hosting plan; they stand for three separate zones. These numbers are assumptions, not measurements of Azure’s network between availability zones, which the first staging drill measures. - Partition.
lc_partition <location>takes the router’s interface into that location down, cutting it off the private network in both directions while its public addresses stay reachable;lc_healrestores it. - Backups. No Azure storage exists locally, so the pgbackrest repository is
a posix repository on
sa-lc-backup-1in hel1, a location with no database node, reached over pgbackrest’s TLS protocol with the node certificates of the internal CA (make internal-ca ENV=local, whichmake local-converge-upruns). This is a stand-in, not an equivalent: object storage is not a server pgbackrest talks to, has its own durability and latency. Staging and production usebackup_repo_mode: azure(the default), which this machine cannot run: it needs the Azure instance metadata service for the managed identity. - Power off in the drills is
docker killof a node container: no clean shutdown of anything inside. Power on isdocker start, which boots systemd and Docker again; compose restarts the services (restart: unless-stopped).
7. Object storage stand-in, probe node and make ops-local
Two switches of infra/local/env.sh, read by up.sh and down.sh:
LC_BACKUP=s3replacessa-lc-backup-1withsa-lc-s3(hel1,10.99.3.35), SeaweedFS 4.52 serving S3 over TLS with a private CA, host-style addressing unders3.sa-local.test. The inventory then carriesbackup_repo_mode: s3and ansa_object_storagewith the endpoint, region and bucket plus two local-only keys,storage_hostandca_file. The roles accept thes3mode in the local converge only. It exercises the stanza, archiving and backup flow over an object store protocol, not Azure itself (backups.md section 5).LC_PROBE=1addssa-lc-probe-1on the public network only (172.31.99.51), as the a probe host outside the platform’s network would.site.yml’s probe play converges it.
make ops-local (tests/localconverge/ops-local.sh) runs the operations checks of
docs/audit/functional-audit-ops.md on LC_TOPOLOGY=minimal LC_BACKUP=s3 LC_PROBE=1: backup
bucket, stanza and WAL archiving in s3 mode, a full backup, delete refusal and
--repo-target-time, idempotency, Patroni failsafe with etcd stopped,
the etcd rejoin of a rebuilt edge node, the probe and the watchdog push, then tears down. It
refuses to start at a load average of 20 or more and waits before each step until the load is
below 20 (ten minutes at most). LC_SRC builds the images from another checkout (for example
git archive HEAD unpacked elsewhere) when the working tree is mid-change.
Run record, 2026-11-13: steps up, images, converge (247 s, failed=0 on every node),
bucket and archiving passed. The run stopped during backup when the shared host’s load
average rose above 600, and was torn down. Three later attempts lost their node containers
within seconds to docker rm -f from outside the run (Docker events show kill signal 9 and
destroy of every sa-lc container, newest first, which no script in this repository does),
so the remaining steps have not completed yet.