9 app-tier (dev/test/UAT × 3), 3 PG (data-dev HA), 1 meta. All Terraform-provisioned, Ansible-hardened, PBS-backed.
pve1, pve2, pve3 — every HA tier spans all three.
One commit, three promotable environments. SSH deploys, registry-pushed images, manual gating.
3-node PostgreSQL 16 cluster. libpq multi-host. ~35s failover, no data loss.
Proxmox, Terraform AppRole, Forgejo, Woodpecker, PG.
Entry conditions met. Sprint 1 begins with HA database ready.
A reproducible platform foundation, end to end
Sprint 0 was scoped as 'establish the build pipeline.' What landed by morning was the entire base of the Aros platform: 12 production VMs split across three Proxmox hosts, a CI/CD pipeline that has already shipped a real service through three environments, and a highly-available PostgreSQL cluster that's been stress-tested and ready for Sprint 1 code.
all in IaC, all backed up
docs / infra / platform
pve1, pve2, pve3
no data loss
dev / test / uat
app + database
TARGET-gated for promotion
Forgejo SSO for Woodpecker
How the session actually unfolded
Not the polished retro — the actual chronology, including the dead ends and the bonus rounds.
- 21:00
Kicked off with TF + Vault working, repos initialised
Inherited state: Proxmox cluster healthy, Forgejo + Woodpecker stack running on the legacy VM, Vault on iaccn01 holding Proxmox + AppRole credentials. Three empty repos waiting for code. - 21:30
First TF apply: 3 dev VMs (pve2 only)
Deliberate deviation — dev cluster doesn't need HA. Documented in current-sprint.md as Sprint 1 debt. - 22:00
Cross-host clone broke on test/UAT environments
Terraform tried to clone the template VM from pve2 to pve1/pve3 — failed because the source datastore name doesn't exist on the destination node. - 22:20
Replicated template to all three PVE hosts (9000/9001/9002)
Per-node template lookup via var.node_templates. TF now picks the local template on each target node. Test + UAT applied cleanly. - 22:45
Ansible base playbook across all 9 VMs
Hardened: unattended-upgrades, ssh config, Docker engine, ufw, NTP. Distributed CI deploy key alongside the operator workstation key. - 23:10
Hello-world FastAPI + Dockerfile + .woodpecker pipelines
Three pipelines: build-push, deploy-dev (auto on push), promote-test (manual), promote-uat (manual). All wired to push images to forgejo.ryanit.com/homelab/aros-platform/hello. - 23:40
Webhook from Forgejo to Woodpecker silently dropped
Push events triggered nothing. ALLOWED_HOST_LIST=private in compose env was overriding app.ini — and self-hosted *.ryanit.com is "external" from Forgejo's perspective. - 00:05
Forgejo config patched, webhook flowing
Set ALLOWED_HOST_LIST=*.ryanit.com,external. First green build landed seconds later. Hello-world deployed to dev. - 00:25
Promote to test + UAT, verified live at hello-{test,uat}.ryanit.com
One commit, two manual triggers, six VMs updated. Pipeline pattern locked in. - 00:50
Started HA Postgres prep (bonus scope)
TAD mandated HA. Provisioned 3 dedicated VMs on pve1/pve2/pve3 for the data tier. etcd installed via apt, Patroni via Python venv at /opt/patroni. - 01:30
Patroni bootstrap silently skipped replication user creation
Replicas couldn't attach. Turns out Patroni 4.x doesn't auto-create users defined under postgresql.authentication.* — they have to be in bootstrap.users. Fixed and templated. - 02:00
Cluster healthy, failover tested twice
Stopped leader → ~35s election → write traffic resumed via libpq multi-host. Old leader rejoined as replica on restart. Zero data loss across three committed rows. - 02:20
Sprint 0 closeout doc + ADR-0002 + all repos pushed
aros-docs 909ea5b, aros-infrastructure 57933a0, aros-platform 4ddd9b8. Sprint 1 entry conditions met. - 02:35
Provisioned hub-1 (meta tier) for this recap site
New TF environment, same module pattern. Ansible base in 3 minutes. Docker ready. This page is being written into it now.
The four things that didn't work the first time
Every one of these became a one-line fix in retrospect. Documenting them so the next pass — or the next operator — doesn't lose the same hour.
Cross-host VM cloning failed on the second-environment apply
First terraform apply for the test environment hung, then errored with datastore 'Z1-1' does not exist on node pve1. The provider was trying to clone a template from pve2 over to pve1 by name, but Proxmox storage names are per-node.
The bpg/proxmox provider clones by VMID + source-node and assumes the datastore exists at the destination. Our template lived only on pve2 with a node-local datastore name.
Cloned the template to all three PVE hosts (9000 on pve2, 9001 on pve1, 9002 on pve3) via the Proxmox API. Added var.node_templates map keyed by target node so Terraform picks the local template. Sprint 1 debt: automate template replication.
Forgejo webhooks silently dropped events to Woodpecker
Push events showed delivered in Forgejo’s webhook log, but Woodpecker never received them. No errors, no entries in the pipeline.
Forgejo’s ALLOWED_HOST_LIST was set to private via the compose FORGEJO__webhook__ALLOWED_HOST_LIST env var, which overrode the app.ini. Anything routed through our public *.ryanit.com URLs counted as “external,” so Forgejo refused to dispatch.
Switched the env var to *.ryanit.com,external. Restarted the Forgejo container. First commit afterwards triggered a green build inside 5 seconds. Bonus discovery: append-to-app.ini left a duplicate [webhook] section, which required extracting / editing / putting back with chown 1000:1000.
Manual Woodpecker triggers fanned out to every workflow
Hitting “manual trigger” on the Woodpecker UI fired all three promote workflows simultaneously — not just the one you wanted.
Default behavior of event: manual is to fire every workflow that subscribes to it. There’s no built-in “pick one” UI.
Gated every promote workflow with when: evaluate: 'TARGET == "test"' (or uat). User passes a TARGET variable on the manual trigger; only the matching workflow runs. Clean, declarative, no plugin needed.
Patroni 4.x didn't create the replication user from postgresql.authentication
Leader bootstrapped fine. Replicas crashed at start with FATAL: role "replicator" does not exist.
In Patroni 4.x, the postgresql.authentication.* section only configures which credentials Patroni uses — it does NOT auto-create those roles. Roles must be declared in bootstrap.users (which only runs on first init).
Created replicator and rewind_user manually on the running leader, then patched the patroni.yml.j2 template so future clusters get them at bootstrap. Failover tests passed immediately afterwards.
What's in the box
Everything is open-source, self-hosted, and reproducible from the three Forgejo repos.
Proof it works
Two artifacts captured during the session, kept here as receipts.
# Push from workstation
$ git push origin main
# ... commit 9f49926d ...
# Woodpecker fires automatically:
# build-push → forgejo.ryanit.com/homelab/aros-platform/hello:dev
# deploy-dev → ssh to 192.168.1.171/172/173, pull + recreate
$ curl https://hello-dev.ryanit.com/
{"message":"hello from aros","env":"dev","commit":"9f49926d","host":"aros-dev-2"}
# Promote with TARGET=test:
$ curl https://hello-test.ryanit.com/
{"message":"hello from aros","env":"test","commit":"9f49926d","host":"aros-test-3"} $ patronictl -c /etc/patroni/patroni.yml list
+ Cluster: aros-pg-dev ------+----+-----------+
| Member | Host | Role | State |
+---------------+-------------+---------+-------+
| aros-pg-dev-1 | 192.168.1.231 | Leader | running |
| aros-pg-dev-2 | 192.168.1.232 | Replica | streaming |
| aros-pg-dev-3 | 192.168.1.233 | Replica | streaming |
$ ssh aros-pg-dev-1 sudo systemctl stop patroni
# 35 seconds later...
| aros-pg-dev-1 | 192.168.1.231 | - | stopped |
| aros-pg-dev-2 | 192.168.1.232 | Leader | running |
| aros-pg-dev-3 | 192.168.1.233 | Replica | streaming |
# Three rows in sprint0_ha_test committed across the failover. No data loss. Sprint 1 enters with the runway built
The point of the bootstrap sprint is that the next sprint can be all about product code, not infrastructure. Sprint 1 starts from a database that's already HA, a pipeline that's already validated, and three environments that already exist.
- Sprint 0 closeout complete, all three repos pushed to current HEADs
- 13 VMs operational, hardened, backed up via PBS daily at 03:15
- CI/CD validated end-to-end across dev → test → UAT
- IaC patterns established (per-env Terraform, role-based Ansible)
- HA PostgreSQL operational at
192.168.1.231,232,233 - Sprint 1 kickoff:
aros-backendmodular monolith — FastAPI + SQLAlchemy 2.x async + Alembic, first migration creates tenant-aware schema
What we chose not to fix tonight
Every shortcut explicitly noted so it's visible at Sprint 1 kickoff rather than rediscovered in production.
- infra·Dev cluster runs single-PVE — re-evaluate with shared storage in Sprint 1.
- infra·Terraform state is local — migrate to MinIO S3 backend once MinIO exists.
- infra·Per-node template replication is manual — automate via Packer or scripted clone.
- infra·Shared
terraformAppRole — split into per-env roles for blast-radius containment. - data·No HAProxy / pgcat — fine for libpq clients, will need it for the first non-libpq consumer.
- data·Patroni watchdog disabled — switch to
requiredfor fencing once we trust the cluster. - data·
pg_hbaishost all all 192.168.1.0/24— tighten per-app / per-db in Sprint 1. - ci·Backup job uses VMID list — convert to tag-based selection.
- ci·
aros-cintfy user not yet created — pipeline notifications currently best-effort. - ci·Pipelines mount
docker.sockon the Woodpecker agent — replace with dedicated build runner. - secrets·Vault Agent integration deferred — credentials still piped through
/tmpduring playbook runs. - recap·This site has no auth and no pipeline — add oauth2-proxy + Woodpecker pipeline in Sprint 1.