1. Pins (checked 2026-11-12)
Findings that changed the build:
- Patroni’s Debian package satisfies its store dependency with Consul when installed
without recommends, which leaves only
consulandkubernetesusable. The image installspython3-etcdexplicitly and importspatroni.dcs.etcd3at build time. - Node 26 no longer ships corepack; the console image installs pnpm 12.18.0 with npm.
go-service.Dockerfilenow copiesdb/(platformctl embeds the migrations) andcontrolplane.Dockerfilecopiesdocs/reference/openapi(phase 12 embeds it) and the tracker releases (TRACKER_RELEASE_DIR=/releases).
2. Images
Names:
{{ sa_image_registry }}/<name>:{{ sa_image_tag }}. Upstream images (NATS, etcd,
HAProxy, Umami) are pinned by digest in infra/ansible/vars/pins.yml.
PostgreSQL 18.7. postgres_version: "18.7" in pins.yml, PG_BASE
postgres:18.7-trixie@sha256:9f4fd4aa918f52ae3d6f3b687f1de90d8f867cd4d520021545a9a50c120eae3b
(index digest, resolved 2026-11-13) and PG_VERSION=18.7 in the Dockerfile. The image built on
2026-11-13 reports postgres (PostgreSQL) 18.7 (Debian 18.7-1.pgdg13+2), patroni 4.1.5 and
pgBackRest 2.60.0.
3. Roles
- base: checks the inventory contract; unattended upgrades from Debian security only,
no automatic reboot; sshd drop-in (key-only,
AllowUsers ops, no root, no forwarding); host firewall (section 4); chrony with an explicit check that the node is synchronised withinbase_time_max_offset_seconds(1 s, so any two nodes agree within 2 s, well inside the 15 s that lock fencing assumes); Docker Engine and Compose at their pins, held, with alocallog driver andlive-restore. - secrets: SOPS and age on the controller, decryption on the controller, root-only env files on the nodes (docs/deploy/secrets.md).
- edge: Compose project
/opt/veristar/edgewith Caddy, gateway, collector, NATS and etcd. NATS: one clustersa-<env>with authenticated routes over the private network and the authorization block rendered byplatformctl nats auth-configfrominternal/natsx(so the deployed permissions are the tested ones). etcd: one member per edge node, peers and clients on the private network. - core: Compose project
/opt/veristar/corewith postgres-ha (Patroni, oncore[0]only in this phase, DCS etcd3 on the edge nodes), HAProxy routing port 5000 to the member whose Patroni REST answers/primarywith 200, Umami (plan 4.9 environment), control plane, replayer, metricswriter, jobs and Caddy with the console. - storage (on the controller): reads the Azure backup storage account back through the
az CLI (ZRS, no account keys, versioning, immutability, lifecycle, container) and stops when
a protection is missing (backups.md). In the local converge’s
s3mode it creates the stand-in’s bucket instead. - base on the probe: the same role without a private network (no private interface, no private firewall rules), so the probe gets the firewall, SSH hardening, time sync and Docker at the pins.
- Caddy configs are assembled by
deploy/caddy/assemble.shfrom the tested per-host Caddyfiles: TLS bySA_TLS(cloudflarefor DNS-01,internalonly locally), automatic HTTP redirects off, and port 80 answering the node’s composite health.
4. Firewall and exposure
Tableinet sa_host, loaded by its own unit sa-firewall.service (Debian’s
nftables.service flushes the whole ruleset on stop, which would remove Docker’s rules).
Input: loopback, established, the private network from its CIDR, ICMP, TCP 80 and 443
and UDP 443 on the public interface, TCP 22 from the admin CIDRs (I13), and this node’s
containers reaching ports it publishes on itself. Forward (before Docker’s chains):
from the public interface only established traffic and DNAT to 80 and 443. Private
services also bind only the private address, so they never listen publicly at all.
Client addresses over IPv6
Caddy is the only container that sees the visitor’s address, and it hands that address to the gateway (X-Edge-Client-Ip: visitor token, country, per-prefix limits) and to the
control plane (X-Sa-Client-Addr: sign-in limits). Docker forwards a published IPv6 port
into a container with no IPv6 address through docker-proxy, so Caddy then sees the
bridge gateway, one IPv4 address, as the peer of every IPv6 visitor (audit N-3, confirmed
with the shipped edge Caddyfile on Docker 29.3.1: an IPv6 client was seen as 172.31.0.1).
Both compose files therefore attach Caddy, and only Caddy, to a second network public
that is IPv6-only (enable_ipv4: false, a unique local /64 from edge_public6_subnet
and core_public6_subnet). Docker picks the gateway endpoint per address family, so IPv6
is DNATed straight to Caddy’s public address and keeps the client address, while IPv4
still arrives through the default network. The other services keep IPv4-only names, so
nothing else changes (pg_hba, NATS and HAProxy addresses). This needs Docker 28 or newer
and ip6tables (set in daemon.json by the base role). The forward chain of sa_host
is an inet table, so its DNAT rule already admits IPv6. TestCaddySeesIPv6ClientAddress
in deploy/caddy reproduces both layouts.
5. Playbooks
site.yml:verify-release.yml(image signatures, section 8), base and secrets everywhere, the backup bucket (storage role), NATS and etcd (a rebuilt edge node rejoins etcd,roles/edge/tasks/etcd.yml), postgres-ha and HAProxy, WAL archiving (roles/core/tasks/archive.yml),bootstrap.yml, all edge and core services, the observability role, then a probe node if the inventory has one. A second run reports no change.verify-release.yml: imported first bysite.yml,deploy.yml,rotate-all.ymlandrestore.yml; verifies the release manifest and pins every node to the verified digests.reboot.yml: rolling host reboot for kernel and libc fixes (../runbooks/security-patch.md section 6a).bootstrap.yml(core[0]): checks I3, waits for a writable leader, creates the Umami role and database and the platform database,platformctl db migrate,db roles, starts Umami (which migrates itself),umami bootstrap,db umami-retention-role,nats apply. Each step reports a change only when it made one.deploy.yml: edge nodes one at a time, the NATS topology, core nodes one at a time, then the observability role on every node including the probe. Each node must answer 308 on port 80 (the composite health) before the next starts; any failure stops the run.
- edge: gateway
/edgez= gateway readiness, collector health check 200, local NATS/healthz200 (fails when its streams are not current, for example without quorum). WithoutEDGE_HEALTH_COLLECTOR_URLandEDGE_HEALTH_NATS_URLit answers 503. - core: control plane
/corez= readiness (database pools, NATS), the database behind HAProxy is not in recovery, Umami heartbeat.
infra/tofu/modules/failover).
On Azure the public address is NAT-mapped onto the VM’s one interface, so Caddy publishes 80
and 443 on the private address (sa_public_bind_ipv4 in the environment’s
group_vars/all.yml), and the host firewall’s forward chain lets the private network reach
the ports published there, since it arrives on the same interface.
6. Blocked on inputs
7. Not in this stream
jobs retain-web(phase 13) must run once a day; no timer is deployed for it yet.- The second core node, Patroni replication, pgBackRest repositories and the backup
bucket (
storagerole) are phase 16 work. - (Done since, finding O-26.) Every link between nodes is TLS with certificates of an internal CA: section 9.
9. Transport security on the private network (finding O-26)
playbooks/internal-ca.yml (make internal-ca ENV=<env>, before the first converge and
after any inventory change) keeps one CA per environment in infra/secrets/<env>/ca.enc.yaml,
read on the controller only, and one key and certificate per node in tls.enc.yaml, which the
secrets role installs under /etc/veristar/tls/<container user>/ (each directory
0500, the key 0400, owned by the user of the container that reads it). Rotation:
../runbooks/secret-rotation.md, “Internal CA and node
certificates”.
Services take the files from configuration (
NATS_TLS_CA, NATS_TLS_CERT, NATS_TLS_KEY,
PLATFORM_DB_SSLROOTCERT, PLATFORM_DB_SSLCERT, PLATFORM_DB_SSLKEY, internal/config/tls.go)
and refuse to start with a CA and PLATFORM_DB_SSLMODE other than verify-full; with a CA the
NATS client refuses a server without TLS. There is no fallback to plain text anywhere.
Each container that reads a certificate carries the label sa.tls (hash of the node certificate
and the CA bundle), so a reissue recreates exactly those containers at the next converge.
Existing clusters. No staging or production environment had been converged when this was
built, so the roles do not migrate a running etcd from http to https peer URLs (etcd keeps the
advertised peer URLs of an existing member); converge an environment with TLS from the start.
8. Procedure, step by step
Staging is shown. Needs prerequisites.md, the SOPS files of secrets.md section 5, the inventory from opentofu.md section 6,cosign on PATH and your age identity in ~/.config/sops/age/keys.txt (or
SOPS_AGE_KEY_FILE). Steps 1 to 3 are the first converge; bootstrap.md walks
through it with the checks around it.
-
Environment variables that are not secret go in
infra/ansible/inventories/staging/group_vars/all.yml, beside the generatedhosts.json: -
Converge at a release tag (
v<release>, whose imagesrelease-images.ymlbuilt and signed):This runssite.yml: base and secrets on every node, NATS and etcd, postgres-ha and HAProxy, the backup settings,bootstrap.yml, the edge and core services, then the observability role. Expected: aPLAY RECAPwithfailed=0andunreachable=0for every node. For comparison, the local converge’s first run reportedok=91 changed=40 failed=0on the core node andchanged=32on each edge node (local-converge.md section 5). The guards printno inventory: run make staging-apply firstandset SA_IMAGE_TAG to the release to deploywhen their inputs are missing.site.ymlfirst verifies every platform image’s signature against the release manifest (playbooks/verify-release.yml, soSA_RELEASE_MANIFEST=<release-images.json>is required) and the nodes pull the verified digests, exactly as the rolling deploy does (../security/supply-chain.md section 3). -
Run it again. Expected:
changed=0on every node (the local converge’s second run:ok=82 changed=0 failed=0on the core node). -
Later rollouts use the rolling deploy with signature verification
(upgrade.md):
Without the manifest it stops with
ROLLING=1 needs SA_RELEASE_MANIFEST=<release-images.json of the release> (phase 18 signature check). -
Lint after changing a role:
make ansible-lint(passed on 2026-11-13 with the production profile, 0 failures).
make local-converge, same playbook,
inventory inventories/local/); step 4’s signature check ran in make supplychain-it with a
test-only key. No cloud converge has run yet: production waits for its VMs
(opentofu.md section 3.8) and for a signed release (section 5).