Runbook¶
Operator-facing procedures for the flomico.com homelab. The per-repo READMEs explain what each repo is; this explains how a change actually travels from an edit to something running.
- How the repos fit together
- Scenario 1: stand up a new service
- Scenario 2: update an existing service
- Scenario 3: change a route
- Scenario 4: change something shared
- Triage
- Recurring maintenance
How the repos fit together¶
Build-time: what this repo publishes, and who pins it¶
Everything below is consumed at an explicit tag. Nothing tracks main, so a
change here is inert until a consumer bumps its pin — see
Scenario 4.
flowchart LR
subgraph common["homelab-iac-common"]
MOD["terraform module<br/>proxmox-vm"]
COL["ansible collection<br/>flomico.homelab"]
SHIM["bw-load / bw-seed<br/>shims"]
IMG["toolbox image<br/>ghcr.io/codyecsl/homelab-toolbox"]
WF["reusable workflow<br/>deploy-stack.yml"]
end
COL -->|baked in at image build| IMG
MOD -->|git module source| PLAT["homelab-platform"]
IMG --> PLAT
SHIM --> PLAT
IMG --> SVC["service repos<br/>homelab-homepage, ..."]
WF --> SVC
PLAT -->|terraform apply| HOST["apps01<br/>192.168.10.101"]
SVC -->|deploy| HOST
caddy-iac and tailscale-iac still carry their own copies of the Terraform
and Bitwarden plumbing — migrating them is outstanding work, not the design.
Deploy-time: what happens on merge¶
sequenceDiagram
autonumber
participant Dev
participant Repo as service repo
participant CI as GitHub runner
participant Net as tailnet
participant Host as apps01
Dev->>Repo: merge to main
Repo->>CI: deploy.yml calls deploy-stack.yml@vX.Y.Z
CI->>Net: join as ephemeral node, tag:ci
Note over Net: grant tag:ci -> target host :22<br/>lives in tailscale-iac
CI->>CI: pull toolbox image from GHCR
CI->>Host: ssh :22, ansible-playbook
Host->>Host: compose_stack writes /opt/STACK and runs compose
Host->>Host: manifest to /opt/.stacks/STACK.json
CI->>Net: node deregisters
Traffic and the route registry¶
caddy-iac's Caddyfile is the port registry. Every service here is
reverse-proxied, so that one file is the authoritative list of what runs on
.101 and on which port. There is no second registry to keep honest.
flowchart TD
CF["caddy-iac<br/>ansible/caddy/Caddyfile"]
CADDY["Caddy VM<br/>.240 admin / .241 guest"]
HOST["apps01<br/>192.168.10.101:PORT"]
SYNC["homelab-homepage<br/>sync-caddy-routes.yml (daily)"]
YAML["config/services.yaml"]
DASH["homepage dashboard"]
CF -->|ansible deploy| CADDY
CADDY -->|reverse_proxy| HOST
CF -->|generator opens a PR on drift| SYNC
SYNC --> YAML
YAML -->|merge triggers a homepage deploy| DASH
Scenario 1: stand up a new service¶
Steps 4-5 below - deploying onto a host via a service's own CI, and verifying
it - were exercised for the first time in the
2026-08-06 apps01 rebuild drill, which
destroyed and recreated the VM itself and then redeployed homelab-homepage
onto it via a clean run of its own workflow.
Steps 1-3 - creating a new service repo from nothing and wiring its CI secrets - remain untested. Every service repo so far was created by copying an existing one, not by working through this section fresh. Expect to hit something not written down here; add it when you do.
1. Decide two things first¶
Port. Pick one not already in caddy-iac's Caddyfile. That file is the
registry — check it, don't guess.
Guest-safe or admin-only. This determines the Caddyfile site block and is easy to get wrong:
| Site block | Reachable at | Who | |
|---|---|---|---|
| Guest-safe | svc.flomico.com:8443 { } |
.241 |
anyone on the guest-web Tailscale profile |
| Admin-only | svc.flomico.com { } |
.240 |
full profile only |
The :8443 suffix is an internal listener port — clients always connect on
plain :443. See caddy-iac's CLAUDE.md.
2. Create the repo¶
myservice/
├── stack/docker-compose.yml # what runs on apps01
├── config/ # app config, if any (optional)
├── ansible/
│ ├── inventory/hosts.yml
│ └── deploy.yml
├── docker-compose.yml # toolbox, runs the deploy
└── .github/workflows/deploy.yml
Copy inventory/hosts.yml, the root docker-compose.yml, and
.github/workflows/deploy.yml from homelab-homepage — they are boilerplate.
ansible/deploy.yml is the only one needing thought:
- name: Deploy myservice
hosts: apps01
become: true
gather_facts: true
roles:
- role: flomico.homelab.compose_stack
vars:
compose_stack_name: myservice
compose_stack_src: ../stack
compose_stack_config_src: ../config # omit if there is no config/
Bind persistent state to /srv/myservice/ rather than a named Docker volume, so
a whole-VM backup captures something legible.
Only if the service needs secrets: add a secrets.json (schema in
homelab-iac-common's README) and copy the bws-load.sh / bws-run.sh /
bws-env.sh shims from scripts/bws-load-shim.sh, scripts/bws-run-shim.sh,
and scripts/bws-env-shim.sh there. Secrets live in the one shared homelab
project in Bitwarden Secrets Manager, not a per-repo item — add the field
under its existing name if another repo already uses it, or a
<service>_-prefixed one otherwise. Most services need none —
homelab-homepage has neither.
3. Wire up CI¶
gh repo create CodyECSL/myservice --private --source=. --remote=origin --push
gh secret set TS_OAUTH_CLIENT_ID --repo CodyECSL/myservice
gh secret set TS_OAUTH_SECRET --repo CodyECSL/myservice
gh secret set DEPLOY_SSH_KEY --repo CodyECSL/myservice < ~/.ssh/homelab-ci
~/.ssh/homelab-ci is the dedicated CI deploy key, authorized on every host
in the homelab. It gets there two different ways, and the difference matters
when you add a host:
| Host | Authorized by | Mechanism |
|---|---|---|
apps01, obs01 |
homelab-platform |
docker_host_deploy_keys in its inventory, applied by flomico.homelab.docker_host |
Caddy VM (.240) |
caddy-iac |
an ansible.posix.authorized_key task in its own deploy-caddy.yml pre_tasks — that playbook never applies docker_host |
The private half is backed up in Bitwarden as the SSH Key: homelab-ci secure
note (private_key/public_key fields), for recovery on a machine that
doesn't have it locally.
The public half is declared in two places: homelab-platform's inventory
as a literal, and ssh_public_key in the shared bws project. Those are the
same key. ssh_public_key is a slightly misleading name for it — it is the
homelab-ci public key, not the operator's personal key — and it does
double duty: Terraform hands it to cloud-init for every new VM, and
caddy-iac's playbook reads it to authorize the key on the already-running
Caddy VM. A rotation has to update both declarations.
A VM built today already trusts homelab-ci
Because ssh_public_key is that key, a freshly provisioned host accepts CI
from birth and needs no authorize step. Only hosts created before that
secret was switched need fixing up — which is exactly why the Caddy VM did
and nothing else does.
Never set DEPLOY_SSH_KEY to your personal key. It is also your GitHub key,
so a pipeline holding it has far more reach than a deploy needs. If a host does
not accept homelab-ci yet, authorize it there rather than widening the secret.
Ordering trap — this is how you lock a pipeline out
The key is authorized by a playbook run, which itself needs to log in. On
a host that does not have it yet, the only key that works is the one
cloud-init baked in from TF_VAR_ssh_public_key — your personal key.
Always authorize first, swap the secret second. Who does the authorizing run depends on which key CI currently holds:
- CI holds nothing that works (a brand-new host): run the playbook
once from your workstation, then set
DEPLOY_SSH_KEY. - CI already holds a working key (the
caddy-iaccase — its pipeline carried the personal key, which the VM accepted): CI can bootstrap itself. Merge the playbook change while the old secret is still in place; that deploy authorizeshomelab-ci. Only then swap the secret.
Either way, setting the secret first means CI cannot get in to authorize
the key that would have let it in. Verify with ssh -i ~/.ssh/homelab-ci
debian@<host> 'echo ok' before swapping — that check is the whole gate.
Reuse the same OAuth client #2 values as every other service repo (see
tailscale-iac's README). Do not generate a new one. See
homelab-iac-common issue #1 — this copy-paste step is the thing that should
be automated.
Only if ansible/deploy.yml's preflight assert needs a secret of its own
(Grafana's admin password is the first example — homelab-observability) —
add the field to this repo's secrets.json and set the BWS_ACCESS_TOKEN
repo secret (the read-only Secrets Manager machine account's token — same
value on every repo that needs one; see homelab-iac-common's README).
deploy-stack.yml runs the playbook under bws-run.sh inside the toolbox
container, which resolves this repo's own secrets.json there:
gh secret set BWS_ACCESS_TOKEN --repo CodyECSL/myservice
Most services need none of this — homelab-homepage has neither secrets.json
nor BWS_ACCESS_TOKEN.
Then the UI-only step that is easy to forget: grant the new repo read access to the toolbox image, or the deploy fails on an image pull.
github.com/users/CodyECSL/packages/container/homelab-toolbox/settings →
Manage Actions access → add CodyECSL/myservice → Read.
Reusable-workflow access is already enabled account-wide on this repo and does not need repeating.
tailscale-iac needs no change — the tag:ci grant already covers
apps01, obs01 and the Caddy VM on :22, which every service repo shares.
Only a new host would need one: an endpoint in config/endpoints.yaml plus
that endpoint added to the tag:ci grant in terraform/policy.tf. Do that
deliberately — each host added widens what a compromised runner inherits.
4. Add the route and deploy¶
Follow Scenario 3 for the Caddyfile, then:
git push origin main
gh run watch --repo CodyECSL/myservice
5. Verify¶
ssh debian@192.168.10.101 'cat /opt/.stacks/myservice.json' # deployed_by: github-actions
curl -sI http://192.168.10.101:PORT | head -1 # 200 from the host
curl -s -o /dev/null -w '%{http_code}\n' https://myservice.flomico.com/
If the last one fails but the second succeeds, it is Caddy or DNS, not the service — see Triage.
Scenario 2: update an existing service¶
Three kinds of change, and they do not all go the same way.
| Change | Path | Action |
|---|---|---|
App config (config/**) |
CI | merge → auto-deploy |
Stack definition (stack/**, image tag, ports) |
CI | merge → auto-deploy |
| Hostname or upstream port | caddy-iac too |
Scenario 3 |
For the first two:
git commit -am "..." && git push origin main
gh run watch --repo CodyECSL/myservice
deploy-stack.yml runs compose_stack, which always passes
--force-recreate. That flag is load-bearing: these stacks bind-mount their
config, and a plain docker compose up only inspects service definitions, so
without it a deploy copies new config to disk and leaves the container serving
the old version.
When to deploy by hand instead¶
The manual path is identical in effect — same role, same image:
docker compose run --rm ansible ansible-playbook -i inventory/hosts.yml deploy.yml
Reach for it when CI itself is what is broken (expired secret, GHCR access,
tailnet), or when iterating fast enough that a commit per attempt is silly. It
leaves deployed_by as your username in /opt/.stacks/, which is how you can
tell afterwards which deploys were manual.
Changing what host a service runs on¶
host_ip in the service repo's .github/workflows/deploy.yml, and the
inventory default. Both currently name 192.168.10.101 literally — LAN topology
is committed by convention across these repos, not hidden.
Scenario 3: change a route¶
Adding a hostname, changing a port, or moving a service between guest and admin. This spans three repos and the order matters.
# 1. caddy-iac: edit the site block
cd ~/Documents/GitHubRepos/caddy-iac
source ./scripts/bws-load.sh
docker compose run --rm ansible \
ansible-playbook -i inventory/hosts.yml playbooks/deploy-caddy.yml
A redeploy is required — the Caddyfile is bind-mounted, so a committed change that has not been deployed is not live.
# 2. verify the route before trusting DNS
curl -s -o /dev/null -w '%{http_code}\n' \
--resolve svc.flomico.com:443:192.168.10.241 https://svc.flomico.com/ # .240 if admin-only
Certificates need no action: DNS-01 with a *.flomico.com wildcard means a new
hostname works immediately after redeploy, with no DNS change.
# 3. homelab-homepage picks the route up automatically
sync-caddy-routes.yml runs daily and opens a PR when routes drift. To not
wait, run npm run sync locally and commit. Merging that PR triggers a homepage
deploy on its own.
Optionally add a config/service-meta.json entry for a proper display name,
icon and group — without one the service still appears, under "Uncategorized"
with a capitalised-subdomain name. A new route can never silently vanish from
the dashboard; it just looks generic.
Finally, from a browser: flush DNS, or a cached negative lookup makes a working change look broken.
sudo systemctl restart systemd-resolved && sudo resolvectl flush-caches
Scenario 4: change something shared¶
Editing homelab-iac-common changes nothing anywhere until consumers bump
their pins. That is the design — blast radius is opt-in.
# 1. verify locally first - all three CI jobs can be run by hand
./test/bw-load.test.sh
terraform -chdir=terraform/modules/proxmox-vm init -backend=false && \
terraform -chdir=terraform/modules/proxmox-vm validate
ansible-lint ansible/collections/ansible_collections/flomico/homelab
# 2. tag and push - the image only builds on tags
git push origin main
git tag v0.1.4 -m "v0.1.4" && git push origin v0.1.4
# 3. confirm the image tag exists before bumping anything
gh run view "$(gh run list --repo CodyECSL/homelab-iac-common \
--workflow toolbox.yml --limit 1 --json databaseId --jq '.[0].databaseId')" \
--repo CodyECSL/homelab-iac-common --log \
| grep -oE 'homelab-toolbox:[a-zA-Z0-9._-]+' | sort -u
Then bump each consumer that needs it:
| Consumer | What to bump |
|---|---|
| service repos | uses: ...deploy-stack.yml@vX.Y.Z |
homelab-platform |
module ?ref=, image tag in docker-compose.yml, HOMELAB_COMMON_REF in the shims |
Collection or role changes reach hosts through the image, so a consumer that
only bumps the workflow ref keeps running the old roles until toolbox_tag
moves too.
Never move a published tag. Cut a new one. v0.1.0 was force-moved once
while nothing consumed it; that stopped being safe the moment the first deploy
succeeded.
Triage¶
The failing step name usually identifies the cause outright.
| Symptom | Cause | Fix |
|---|---|---|
workflow was not found calling deploy-stack.yml |
Reusable-workflow access off, or a tag that does not exist | gh api -X PUT repos/CodyECSL/homelab-iac-common/actions/permissions/access -f access_level=user |
tailscale up fails |
OAuth client's tag or scope wrong | Client needs auth_keys + tag:ci, nothing else |
Wait for the host to be reachable times out |
Tailnet joined, ACL grant missing | Check tag:ci → host :22 in tailscale-iac |
invalid reference format ... must be lowercase |
Owner casing in an image reference | Lowercase it — github.repository_owner preserves CodyECSL |
manifest unknown |
Image tag does not exist | Confirm the published tags; {{version}} strips the leading v |
denied / unauthorized on pull |
Repo lacks package read access | Manage Actions access on the package → add repo → Read |
SSH auth failure in Deploy |
Wrong half of the keypair, or the public half isn't authorized on the host | DEPLOY_SSH_KEY is the private key, and should be homelab-ci everywhere. The public half is declared in two places: docker_host_deploy_keys in homelab-platform/ansible/inventory/hosts.yml (apps01, obs01) and ssh_public_key in bws (the Caddy VM, via caddy-iac's playbook — despite the name, that secret holds the homelab-ci key). Compare the relevant one against ~/.ssh/homelab-ci.pub or the SSH Key: homelab-ci Bitwarden item — if the two declarations have drifted, that is the bug. On a host that has never had a playbook run, the key isn't authorized yet — run it once from a workstation |
Unable to change directory ... /opt/STACK |
Only expected under --check |
Real runs create it first |
| Config edited but container serves the old version | --force-recreate skipped |
It is in the role; suspect a stale image tag instead |
REMOTE HOST IDENTIFICATION HAS CHANGED |
Rebuilt VM reusing an IP | ssh-keygen -R <ip> |
Service reachable on .101:PORT but not by hostname |
Caddy not redeployed, or stale DNS | Scenario 3 steps 1 and 3 |
Note CI never consults a known_hosts file — the toolbox image sets
ANSIBLE_HOST_KEY_CHECKING=False, since an ephemeral runner has nowhere durable
to pin a key. A genuine host-key change would therefore not be noticed by a
deploy. Accepted because the only path in is the tailnet, ACL-scoped to port 22
on one host.
Recurring maintenance¶
| Item | Cadence | Notes |
|---|---|---|
CADDY_IAC_TOKEN |
expires | Fine-grained PAT on homelab-homepage for the route sync. Expires silently — the symptom is the sync workflow quietly no-opping |
| Tailscale OAuth client #2 | on rotation | Shared by every service repo, so rotation touches all of them — see homelab-iac-common issue #1 |
DEPLOY_SSH_KEY |
on rotation | The dedicated homelab-ci key, backed up in Bitwarden as SSH Key: homelab-ci. Rotating it means updating the public half in both places it is declared — docker_host_deploy_keys in homelab-platform's inventory, and ssh_public_key in bws (which is the homelab-ci key, not a personal one) — then re-running homelab-platform's site.yml and caddy-iac's deploy, then re-setting the secret in every service repo. Authorize everywhere before swapping any secret |
| Toolbox base image | as needed | Rebuild by cutting a tag; picks up Debian and ansible-core patches |
apps01 sizing |
when memory is tight | config/hosts.yaml in homelab-platform, then terraform apply. Memory first — CPU overcommits fine |
| Unattended upgrades | automatic | docker_host enables security updates; it does not reboot for kernel updates |
Open work¶
homelab-iac-common#1 — CI secrets are hand-copied per repo and recorded nowhere; rotation is O(repos) with no completeness check.homelab-iac-common#2 — closed as of 2026-08-08.homelab-ciis authorized onapps01andobs01viadocker_host_deploy_keys, and on the Caddy VM by anauthorized_keytask incaddy-iac's own playbook (that playbook does not applydocker_host, and Terraform'suser_account.keyscannot touch a live VM). No pipeline carries the operator's personal key any more. Note the personal key is still authorized on every host — that is the operator's own access and the rollback path, deliberately kept.homelab-iac-common#5 —deploy-stack.yml'stoolbox_tagdefault silently falls behind every toolbox release.proxmox-iac#1 — the Proxmox hypervisor itself (network, storage, VLANs) isn't source-controlled or documented as a from-scratch build.- No runbook scenario yet covers rebuilding a shared host itself (as opposed to a service on it) — see the apps01 rebuild drill's findings. Nor is there a way to bulk-redeploy every service on a host after one, beyond re-running each service's CI by hand.
caddy-iacandtailscale-iacstill carry duplicated Terraform, Ansible and Bitwarden plumbing.- A future DR drill (or a real bare-metal rebuild) needs an explicit step
to restore the
homelab-ciSSH keypair before anything else touchesapps01.docker_host_deploy_keysinhomelab-platform's inventory authorizes this key's public half on apps01, andhomelab-homepage's CI uses its private half asDEPLOY_SSH_KEYto deploy there. The private key exists only on the operator's machine and as a Bitwarden backup (SSH Key: homelab-cisecure note,private_key/public_keyfields) — it is not in git and nothing pulls it down automatically. A restore also has two declarations of the public half to reconcile, not one: the literal inhomelab-platform's inventory andssh_public_keyin bws. If they disagree, apps01/obs01 and the Caddy VM end up trusting different keys and only one repo's CI breaks — an annoying thing to diagnose under DR. On a lost-local-drives rebuild this step has to come beforeansible-playbook site.yml, not after, since that inventory var is what authorizes the public half on the host in the first place:The apps01 rebuild drill predates this key's existence, so it didn't exercise this step — worth verifying on the next drill.bw get item "SSH Key: homelab-ci" | jq -r '.fields[] | select(.name=="private_key").value' > ~/.ssh/homelab-ci chmod 600 ~/.ssh/homelab-ci bw get item "SSH Key: homelab-ci" | jq -r '.fields[] | select(.name=="public_key").value' > ~/.ssh/homelab-ci.pub