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.
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.
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.
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 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:
- Commit its Compose file and encrypt its secrets.
- Copy the data.
- Run it next to the old one.
- 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
- My homelab was one small box, yoda, managed by hand, with updates pulled in unreviewed, expired certificates, plaintext secrets and end-of-life software.
- 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.
- 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.
- 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.