DR drill: apps01 + obs01 rebuild via stable_instance_id (2026-08-09)¶
Result: success, but unplanned. This didn't start as a drill — it was
the real rollout of homelab-platform#3 (stable_instance_id, to stop
cloud-init regenerating SSH host keys on every Proxmox reboot). It turned
into a live, no-warning DR exercise partway through: a Terraform state gap,
a Proxmox permission gap, and a provider ForceNew surprise combined to
destroy both apps01 and obs01 mid-apply, with no snapshot and no
plan to be operating at this scale. Everything came back, but this wasn't
apps01-rebuild.md's deliberate "no rollback net, honestly staged" design —
this one wasn't a choice, and both hosts were briefly down for real.
Scope¶
Turn on stable_instance_id on both existing shared hosts (apps01,
obs01), landing homelab-platform#3. Ended up also exercising, for the
first time: redeploying a service with its own secrets from scratch
(homelab-observability's GRAFANA_ADMIN_PASSWORD, via a BWS_ACCESS_TOKEN
CI machine account) — the exact gap apps01-rebuild.md's finding #4 flagged
as untested — plus a full migration of Terraform state off a local file and
onto HCP Terraform, plus simultaneous recovery of two hosts instead of one.
Target state before this started: apps01 ran homepage +
code-server + the host-level alloy-agent; obs01 ran the
homelab-observability stack (Grafana, Prometheus, Loki, Tempo, gateway
Alloy) plus its own alloy-agent. Blast radius here: both hosts, everything
on the shared VM fleet, at once — worse than the prior single-host drill,
not by design but by what stable_instance_id needed to touch on hosts that
already existed.
Steps run¶
1. Land the code changes (homelab-platform#3, PR #13)¶
Provider ssh {} block, module bump to homelab-iac-common@v0.1.13,
stable_instance_id = true set fleet-wide in main.tf. Merged clean.
2. First terraform plan, from the wrong checkout — caught before applying¶
Run from an Orca-managed worktree of homelab-platform, not the checkout
that had ever run apply. Terraform state was a gitignored local file
(terraform/terraform.tfstate) that only ever existed in the canonical
clone — this worktree had none. The plan came back wanting to create
apps01/obs01 from scratch (4 to add, 0 to change, 0 to destroy, but
for brand-new VMs at already-live VMIDs), which would have collided with
the real running hosts. Not applied.
3. Migrate Terraform state to HCP Terraform (PR #14)¶
Added a literal cloud {} block (org codyecsl-homelab, workspace
homelab-platform, execution mode Local — same pattern as tailscale-iac),
hcp_terraform_token to secrets.json, TF_TOKEN_app_terraform_io
passthrough in docker-compose.yml. From the canonical clone:
source ./scripts/bws-load.sh
docker compose run --rm terraform init
Terraform detected the backend change and prompted to copy existing local
state into the new workspace — accepted. A follow-up plan from the
canonical clone, now against real migrated state, came back
4 to add, 0 to change, 2 to destroy for apps01/obs01: enabling
stable_instance_id on an already-existing VM sets
initialization.meta_data_file_id, which is ForceNew in bpg/proxmox —
not the single extra reboot homelab-platform#3 assumed, a full
destroy-and-recreate. Discussed the options (fix the module upstream first,
accept the rebuild, or scope to one host); decided to accept the rebuild on
both hosts.
4. terraform apply — both VMs destroyed, recreate failed, both hosts down¶
module.host["obs01"]...: Destruction complete after 2s
module.host["apps01"]...: Destruction complete after 2s
then, creating the stable_instance_id meta-data snippet on each host:
Error: failed to get datastore: error reading datastore: received an
HTTP 403 response - Reason: Permission check failed (/storage/local,
Datastore.Allocate)
Datastore.Allocate is not datastore-scoped — despite the error naming
/storage/local, this was a gap in the API token's role definition, not
an ACL entry for that one storage. The token's role (CaddyIac, defined
under Datacenter → Permissions → Roles) already granted
Datastore.AllocateSpace — the privilege VM disk creation on local-lvm
needs — but not the separate Datastore.Allocate privilege, which nothing
had ever needed until this snippet write. Both apps01 and obs01 did
not exist in Proxmox at this point — an unplanned, live outage of every
service on the shared fleet. Fixed by adding Datastore.Allocate to the
CaddyIac role itself (Datacenter → Permissions → Roles → CaddyIac → Add),
not any path-specific pveum acl change.
5. Second apply attempt — new blocker, still both hosts down¶
Past the permission error, hit a different one immediately:
Error: failed to open SSH client: unable to authenticate user "root" over
SSH to "192.168.1.10:22" ... attempted methods [none password]
stable_instance_id's meta-data snippet is written to the Proxmox node's
filesystem over SSH — content_type = "snippets" has no API upload path in
bpg/proxmox — and this exposed three stacked gaps, not one:
- The toolbox container's
ssh { agent = true }had no agent socket forwarded into it at all, only a static key file (mounted for git module fetches). Fixed in PR #15: bind-mount$SSH_AUTH_SOCKinto theterraformservice,SSH_AUTH_SOCK=/ssh-agentinside it. - The host shell itself had no ssh-agent running (
ssh-add -l→ "Could not open a connection to your authentication agent") — neededeval "$(ssh-agent -s)"first. root@the Proxmox node had no key-based access authorized at all. Password auth was the only thing that had ever worked there. Fixed withssh-copy-id root@192.168.1.10(prompted for and accepted the root password interactively) to actually install a key inroot'sauthorized_keysbefore any agent-based flow had a chance of working.
6. Clean apply¶
docker compose run --rm terraform plan # 4 to add, 0 to change, 0 to destroy
docker compose run --rm terraform apply
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
host_ips = { "apps01" = "192.168.10.101", "obs01" = "192.168.10.102" }
Same IPs as before — derived from vm_id, not discovered.
7. Clear stale host keys, rebaseline¶
ssh-keygen -R 192.168.10.101
ssh-keygen -R 192.168.10.102
SSH_KEY_PATH=~/.ssh/homelab-ci docker compose run --rm ansible \
ansible-playbook -i inventory/hosts.yml site.yml
Clean run, failed=0 unreachable=0 on both hosts; alloy_agent correctly
scoped to apps01 only. Note: the key baked into cloud-init for the
debian user is ~/.ssh/homelab-ci ("homelab-ci deploy"), not
docker-compose.yml's default ~/.ssh/id_ed25519 — needed the explicit
override.
8. Verify stable_instance_id actually holds¶
Rebooted both hosts a second time and confirmed the SSH host key did not change on either — the real proof, since the first boot after recreation already used the pinned instance-id from the start.
9. Redeploy every service, via its own CI, on both hosts¶
gh workflow run deploy.yml --repo CodyECSL/homelab-homepage
gh workflow run deploy.yml --repo CodyECSL/homelab-code-server
gh workflow run deploy.yml --repo CodyECSL/homelab-observability
All three workflow_dispatch runs succeeded unmodified. Verified live:
homepage :7990 -> 200
code-server :7991 -> 302 (auth redirect, expected)
grafana :3000 -> 302 (login redirect, expected)
gateway :4318 -> 404 (bare GET on an OTLP-only path, expected)
homelab-observability's GRAFANA_ADMIN_PASSWORD came back correctly via
its CI's BWS_ACCESS_TOKEN — the first time any service's own secrets have
been exercised end-to-end through a from-scratch host rebuild.
Findings¶
- Local, per-checkout Terraform state was a live landmine, not a
theoretical one. It took an ordinary worktree without the state file
to expose it (step 2) — no malice or unusual setup required, just a
second place
terraformgot run from. Fixed by moving to HCP Terraform; this should have been true from this repo's firstapply, the same waytailscale-iacwas already set up. stable_instance_idforces a full VM rebuild on existing hosts, not a reboot.homelab-platform#3assumed the latter; the module'sinitialization.meta_data_file_idisForceNewinbpg/proxmox. Confirmed the hard way on bothapps01andobs01.caddy-iacstill owes the equivalent fix per that issue and will hit the same thing. Worth fixing upstream inhomelab-iac-common— decouple the meta-data attach from the VM resource (e.g.qm set --cicustomover SSH, post-create) so it stops forcing replacement.- A role-permission gap and a three-layer SSH-access gap were both
invisible until the exact moment they were needed, and they landed
back-to-back, with both hosts already destroyed in between. The API
token's
CaddyIacrole hadDatastore.AllocateSpacebut not the separateDatastore.Allocateprivilege; neither that, nor a forwarded ssh-agent, nor — it turned out — any key-based access toroot@the Proxmox node at all (only ever password auth) had ever been exercised before this change. Nothing about the fleet's steady-state operation would have surfaced any of it ahead of time; the node had simply never needed to be reached this way before. - Recovering two hosts at once, blind, is meaningfully worse than one.
apps01-rebuild.md's finding #2 ("no bulk-redeploy primitive") is still true and now demonstrated at 2x — three separategh workflow runinvocations across three repos, by hand, in sequence. At the fleet's stated capacity (10-15 services onapps01alone), an unplanned two-host outage like this one would mean redeploying well over a dozen services by hand, under time pressure, with no single "what was running here" manifest to work from. - Secrets-through-a-rebuild is now verified, not just designed.
homelab-observability'sGRAFANA_ADMIN_PASSWORDround-tripped throughBWS_ACCESS_TOKENcorrectly on a service that had never been deployed fresh from nothing before. Closes the gapapps01-rebuild.mdfinding #4 explicitly flagged as the next thing worth testing. - The IP-derivation and per-host blast-radius design held again, this
time under two simultaneous host rebuilds rather than one — both VMs
came back on their original IPs (
.101/.102), and nothing aboutconfig/hosts.yaml'sfor_eachneeded touching to make that true.
Rough timing¶
Not tracked live with a stopwatch, but reconstructed afterward from two
sources that turned out to have second-precision timestamps anyway: the
system journal's podman container lifecycle events (every
docker compose run --rm terraform|ansible ... invocation logs
create/start/died/remove) and the GitHub API's PR-merge and
workflow-trigger timestamps. Bash history itself had none — no
HISTTIMEFORMAT was set, so nothing to reconstruct from there; worth
turning on for future drills.
| Time (EDT) | Event | Source |
|---|---|---|
| 21:57 | PR #13 merged — issue #3 code lands | GitHub |
| 21:59:55–22:00:09 | First plan, wrong checkout — no state, would've recreated both VMs from scratch. Not applied. |
journal |
| 22:28:51 | PR #14 merged — HCP Terraform backend | GitHub |
| 22:31:30–22:31:46 | terraform init — state migrated to HCP |
journal |
| 22:32:23–22:32:24 | plan against real state — reveals the 2 to destroy ForceNew surprise |
journal |
| 22:36:42–22:36:58 | apply — both VMs destroyed, recreate fails on Datastore.Allocate |
journal |
| 22:37:58 | Retry, fails fast (SSH access gap) | journal |
| 22:50:56 | PR #15 merged — ssh-agent forwarding fix | GitHub |
| 22:53:09–22:54:39 | Clean plan + successful apply — both VMs back |
journal |
| 22:57:42–22:58:50 | Ansible site.yml rebaseline (68s — matches the ~64-task recap) |
journal |
| 23:13:33 | homelab-observability GHA deploy triggered |
GitHub |
| 23:24:43 | This doc's own PR merged | GitHub |
Both hosts fully absent from Proxmox: ~18 minutes (22:36:58, destroy
completes but recreate fails, to 22:54:39, successful recreate). Total
incident, first merged PR to docs merged: ~87 minutes. The two
blocking-error diagnose-and-fix cycles (permission, then SSH agent) were
the dominant cost within that window, each requiring a code change, a
merge, and a retry rather than a same-terminal fix — not the VM
create/destroy operations themselves, which were consistently single-digit
to low-double-digit seconds, comparable to apps01-rebuild.md's
single-host timing.
One honest gap: which container run maps to which specific command is inferred from duration and sequence, not directly logged — podman's journal entries don't include argv. The window boundaries above (outage start/end, total span) don't depend on that inference and are solid.