13 min readUpdated TL;DR

My homelab runs through pull requests

My homelab used to be one box of containers I managed by hand. Now it's a Proxmox cluster run from a Git repository called senate, which I built assisted by Claude Code. The ideas behind it, how I work in it, and what I learned.

In this post 8 sections

The repository that runs my homelab is called senate. Its README explains the name: the repo is the chamber where proposals are tabled and ratified, and the apply step is the vote.

The idea behind it is simple: nothing reaches the machines unless it went through a pull request first. Terraform describes the Proxmox cluster, Ansible configures its hosts, and every application is a Compose file. This blog is one of those applications.

I built it over five months, assisted by Claude Code, and it still helps with the upkeep. It doesn’t run the homelab: I choose what gets built, read what it proposes and decide what gets merged.

Before: one box, by hand

Nearly everything used to run on yoda, one small box with a few dozen containers. I managed it by hand, and it showed. Watchtower pulled in new images whenever they appeared, and was itself on its way out. The reverse proxy was serving expired certificates. Secrets sat in a plaintext file, and some software was simply end of life.

Before and after Before: one box called yoda running about 28 containers, with about 17 dormant ones. After: a cluster of five Proxmox nodes plus a NAS, each with its own role. BEFORE yoda one small box, managed by hand running dormant updates pulled in unreviewed expired certificates secrets in plaintext end-of-life software AFTER: FIVE PROXMOX NODES AND THE NAS obifive Plex, apps, WordPress the node with the iGPU obifour Home Assistant, state backend DNS, tunnel, CI runner obithree Agents, CI runner the old Home Assistant box obitwo My lab server, a Dell R710 staging, game servers obisix Monitoring, CI runners, DNS yoda, wiped and rejoined NAS Storage, backups not a Proxmox node
The dashed node is my lab server, a Dell R710, which is only on when I need it.

The idea: the repo is the only way in

Plan on pull request, apply on merge. A pull request runs checks that change nothing: Terraform posts its plan as a comment, the Ansible roles get linted, every changed Compose stack gets rendered with its secrets, the alert rules get validated and the diff gets scanned for leaked secrets. The Terraform plan is the real gate. I read it before I merge.

Merging fans the change out by path. Terraform changes go to the cluster, Ansible changes to the hosts, Compose changes to the stacks that changed, and nothing else gets touched.

How a change gets in A pull request runs read-only checks. I merge it. The merge fans out by path to a Terraform apply, an Ansible apply or a Compose deploy, which change the Proxmox cluster. Every night, drift checks open a GitHub issue on drift and close it on the next clean run. Pull request Read-only checks Terraform plan as a comment, linting, rendered stacks, secret scanning I merge Terraform apply terraform/** Ansible apply ansible/** Compose deploy apps/compose/** Proxmox cluster EVERY NIGHT Drift checks reality against the repo GitHub issue opens on drift, closes when clean
Plan on pull request, apply on merge. Every night the repo checks whether reality still agrees with it.

Merges are manual, and that’s partly how GitHub works: a merge made with a workflow’s own token doesn’t trigger other workflows, so merging from a workflow would skip the apply.

The repo also checks itself. Every night a Terraform plan and an Ansible dry run compare the machines with the code. If something drifted, an issue opens, and the next clean run closes it again. Failed deploys open issues too, so the issue tracker doubles as the homelab’s to-do list.

Everything else follows from that

Once the repo is the only way in, a few other things fall into place.

Updates are pull requests. Watchtower’s job went to Renovate: every image is pinned to an exact version, and a new one arrives as a pull request that gets the same checks as any other change. Renovate merges the smallest updates itself and leaves the rest for me. This blog deploys that way too.

Secrets live in the repo, encrypted. They’re SOPS files that my key and the CI key can both decrypt. A pre-commit hook refuses a secrets file that isn’t encrypted, and scanners check every pull request and the full history.

The machinery runs on the cluster it manages. The CI runners and the Terraform state are containers on the same cluster, created by the same code. It keeps everything in one place, and it means a bad change can pull the floor out from under the job that’s applying it. One runbook lists exactly which plans not to merge from CI for that reason.

Things get written down. There’s a runbook for everything stateful, so “I forgot how to do X” is a file away. A roadmap records what was decided and why. Anything bigger than a fix starts as a design and a plan in the repo before any code gets written.

How traffic gets in

The old reverse proxy, the one serving expired certificates, is gone. Every Docker host now runs its own Traefik, configured from the repo, and gets its certificates from Let’s Encrypt.

The few apps that are public don’t need an open port on the router. They go through a Cloudflare tunnel: cloudflared runs in its own small container and connects out to Cloudflare, which hands the traffic to Traefik. At home, a pair of AdGuard servers answers DNS and points the local names straight at Traefik.

How traffic gets in From the internet, traffic goes to Cloudflare, through the cloudflared tunnel, to Traefik, and on to the app. At home, AdGuard points local names at Traefik, which serves the app. FROM THE INTERNET Cloudflare public names cloudflared tunnel, no ports AT HOME AdGuard DNS local names point at Traefik Traefik one per host, real certificates App Compose stack
Public apps come in through the tunnel. At home, local names go straight to Traefik.

Moving in

The platform came first: Terraform against the cluster, encrypted secrets and a CI runner. Then the workloads moved over, one phase at a time.

The order of the migration First the platform, then Home Assistant to a VM, then media and monitoring, then DNS and the tunnel, then the apps and WordPress sites, then the NAS move to TrueNAS Scale with backups on it, and finally yoda itself wiped and rejoined as the fifth node. The platformTerraform, encrypted secrets, a CI runner Home Assistantto a VM, and its old box joins the cluster Media and monitoringthe first stacks off yoda DNS and the tunnelinto their own small containers The apps and WordPressthe rest of yoda, one stack at a time The NAS and backupsTrueNAS Core to Scale, then backups on it yoda wipedand rejoined as the fifth node
The order the homelab moved in.

The NAS got its own upgrade along the way: from TrueNAS Core, which runs on FreeBSD, to TrueNAS Scale, which runs on Linux. The leftovers of the old FreeBSD jails were cleaned up, and because Scale can run containers, the backup server now lives on the NAS. The first plan was to keep yoda alive just for that.

Every stack moved the same way:

  1. Commit its Compose file and encrypt its secrets.
  2. Copy the data.
  3. Run it next to the old one.
  4. Cut over.

The WordPress passwords, plaintext on yoda, got rotated on the way. At the end, yoda itself was wiped and joined the cluster as its fifth node. The box that used to run everything is now the one with the lightest load.

Working with Claude Code

The runbooks are what the agent knows between sessions. My Claude Code history goes back a few weeks, and the runbooks go back to the start. When a session opens a runbook and finds it out of date, updating it is part of the job.

Bigger work goes through the Superpowers plugin for Claude Code: a design first, then a plan that splits the work into checkbox tasks. When a session stops halfway, the plan shows where.

What I want from it is proof. When it says something is done, it should have shown it: a pinned version checked against the real upstream release, a config key misspelled on purpose to prove the new version actually reads it, a cancelled deploy followed up until it’s clear the stack still got deployed. Without that, I’d have to check everything myself.

A normal day looks like this. Renovate opens a handful of pull requests overnight. I ask Claude Code to check them and merge them if they’re fine, and it reads the diffs and the release notes before it does. Sometimes that turns up something else. The last time, a release had renamed the modes of my EV charger software and switched on a setting that undid the capacity-tariff cap from the evcc post. One screenshot later I had switched it back off, and there were pull requests for the runbook, a guard against it happening again, and an update to that post. The nightly drift checks can’t catch that kind of change: evcc keeps the setting in its own database, outside anything Terraform or Ansible manages, and so do other applications. The guard only watches that one switch.

It only merges when I say so because that’s the agreement. Claude Code works with my GitHub credentials, and branch protection isn’t available for a private repository on GitHub’s free plan, so if it pushed straight to main, nothing would stop it.

On a side project I’ve had two agent sessions working in one repository at the same time. They can’t message each other, so they coordinate through a shared Markdown file in the repo: who’s working on what, what’s ready, what got reviewed. One of them reviewed the other’s change, found two bugs, and they were fixed within minutes.

Things I learned

Most of these were written down in the roadmap the day they happened.

Measure the peak, not the idle number. I shrank the CI runners because they idled well below their memory limit, and the nightly jobs ran them out of memory within two days.

Removing code removes nothing on the hosts. Deleting an Ansible task doesn’t uninstall anything, and a deleted config file can stay live on a host long after it’s gone from the repo. Removal needs its own step.

A green run proves only what it actually ran. A check for whether a host was up failed in a way that made every host look down, so every deploy was skipped and the run went green.

Defaults can report success while doing nothing. The backup server pruned old backups but had never reclaimed their space. The DNS server silently dropped queries above a per-client limit that one laptop could hit loading one page.

Copied data brings its old state along. Copying a database directory copies its old passwords too, and the new container’s settings are ignored once the data is there.

Read what the tools will do before merging. One harmless-looking DNS change would have rebooted every guest in the cluster, including the one running the apply.

Not everything I build should stay. I built a security monitoring stack on my lab server, which is only on part-time. A service that has to be always on doesn’t belong there, so it went.

What’s next

  • An offsite copy of the backups, and a restore drill to prove they work.
  • A bake-off between two game server panels.
  • A small Kubernetes tier, as a learning exercise.
  • A new home for the Terraform state, because the current one was archived upstream.
  • Letting an agent do the first review of Renovate updates.
  • Agents that pick up work on their own. Paperclip didn’t make it past its trial, and OpenHands now runs Claude Code from a VM on the cluster, as a bot account that can only open pull requests. The Race to Dutch First post has how that’s set up.
  • Local models on the cluster itself. The hardware isn’t there yet, so that starts with more memory or a GPU.

TL;DR

  1. My homelab was one small box, yoda, managed by hand, with updates pulled in unreviewed, expired certificates, plaintext secrets and end-of-life software.
  2. Now it's a Proxmox cluster run from a Git repository called senate, in Terraform, Ansible and Compose, and nothing reaches the machines without a pull request.
  3. I built it over five months assisted by Claude Code, but I choose what gets built, read what it proposes and decide what gets merged.
  4. Two lessons I keep coming back to: a green run proves only what it actually ran, and removing code from the repo removes nothing on the hosts.

Before: one box, by hand1 of 8 · 13 min left