
Nothing outside my homelab can deploy into it. A small container inside the lab pulls my git repository every minute and applies what changed, but only when the newest commit carries my signature. Someone who took over my GitHub account could push to main, and the lab would refuse it.
This post covers that deploy, in the order a change meets it: how an app is packaged, how a push reaches the machines, what is checked and when, how secrets travel, and what happens when a commit is bad. How I Run My Homelab in 2026 has the big picture: one Proxmox host, network tiers, Tailscale as the way in.
What the deploy answers to
The lab’s docs open on a contract, and the deploy is judged against it: lose at most 24 hours of data, recover in hours from a software problem and in days from a hardware one, no availability promise, and one user, me. So nothing here does rolling updates or keeps a second replica: an app that restarts for a few seconds during a deploy is within the contract.
An app is a Compose project on the VM of its tier

Every app is a Docker Compose project: a directory in the repository named after the product, holding upstream’s own compose.yaml as far as it goes. It runs on one of three Debian VMs. Two are picked by trust rather than by load: apps-private for the apps I vouch for, with the front door (Traefik and Authelia) beside them, and apps-untrusted for what parses content from the internet. The third, monitoring, holds the monitoring stack. Today that is 22 project directories and about fifty running containers.
The reason is written in the lab’s decisions page. Proxmox recommends running application containers in a VM rather than in a system container, and NIST SP 800-190 groups containers by sensitivity on separate kernels. So the trust tier is the kernel boundary, and inside a VM the boundary is the project’s Compose network. A project’s services and its database meet on its own default network, and a database is never on another. Traefik reaches the apps it routes over a shared proxy network. The same page says when to revisit the choice: the day a tier’s apps outgrow one VM.
The repository’s instructions for writing an app are short, and most of them are about where state lives:
- a backing service (a database, a Redis) is a service of the app’s project, never a project of its own, so an app’s upgrade or restore touches nothing else;
- every image is pinned as
name:tag@sha256:…; - state is a named volume, which the VM’s nightly backup takes; a cache goes to a second disk the backup skips, since it can be fetched again;
- every long-running service has
restart: unless-stopped, a health check where the image has none and one is cheap, and no published port unless another machine calls it; - a route is a line in a routes file, never a container label: Traefik reads files only and has no access to the Docker socket.
None of those is checked. CI only makes sure every compose file parses, plus the VPN rule below; the list above is instructions that I, or the agent writing the change, follow.
Images come from upstream as published. An app with no upstream image is built on its VM from a Dockerfile next to its project, and never pushed anywhere. A registry of my own would be one more service to keep, and the lab would need it running before it could start a single container.
The apps that must reach the internet only through my commercial VPN, the download client and two remote browsers among them, live in a vpn project on apps-untrusted, inside the network namespace of gluetun, whose firewall is the kill switch: a packet leaves through the tunnel or not at all. CI fails a service of that project that isn’t attached to gluetun, and a probe alerts when the tunnel stops carrying traffic.
A container inside the lab pulls main

The deploy is a small Debian container on the private tier, called the converge. Every minute a systemd timer fetches main with a read-only deploy key. When main has moved, the converge applies only what the push changed, with Ansible over SSH:
| The push changes | The converge applies |
|---|---|
| an app’s project directory | that project, on its VM |
| an Ansible role | that role |
| the Cloudflare config | Cloudflare, with OpenTofu |
| the docs | the docs site’s project |
| CI files, agent skills, notes | nothing |
| anything else | the whole lab, and Cloudflare |
A project’s apply is docker compose up, so Compose recreates only the services whose definition or environment changed, and other projects are left alone. A config file mounted into a container is the exception: Compose can’t see it change, so the whole project restarts. Once a day the converge applies everything, so a change made by hand on a machine is put back within a day.
Two things never go through this loop. The editor servers on my dev boxes are applied by hand: their restart kills every terminal they serve, agents’ sessions included. And the hypervisor’s package updates are installed by me, in front of the machine, once Grafana mails that a security update has waited three days, because a reboot there takes every guest down. The Debian guests take their security updates on their own, every night, and Docker Engine takes its minor releases the same way; a major version is pinned until I move it.
There is no inbound connection anywhere in this. GitHub holds no credential into the lab, and the keys the deploy uses never leave it.
It applies only a head I signed

Before applying anything, the converge checks the signature on the head of main against my SSH signing key. Unsigned, or signed by another key, and it refuses and marks the run failed, like any failed apply (below). Commits that only touch CI files, Renovate’s automerges, don’t count as a refusal: they just wait for my next signed one.
A pull deploy alone only moves the risk from the network to the git host: whoever can push to main deploys. The signature adds a second condition, a key that lives on my dev box.
It has two limits. First, only the head is checked, and a signed head vouches for every commit under it. A commit someone else pushed would sit on main, refused, until I built on top of it: a pull, a commit of mine, a push, and it would go out with my signature above it. So when a head is refused, what sits on main has to be read before anything goes on top of it. Second, every commit made on my dev box is signed, an agent’s included. The signature says where a commit was made, not that I read it, and against a hijacked agent session there it does nothing. The defense there is me reading every change, which the post on my coding agents covers.
The checks run before a push and beside the deploy
The lab’s checks are one recipe, just validate (just is the command runner every task in the repo goes through), and two things run it:
- a
pre-pushhook on my dev box, on every push tomain; - CI, GitHub Actions on a self-hosted runner of mine in the lab’s untrusted tier, on every push and pull request. The runner holds nothing of the lab and no token able to write a repository.
It runs ansible-lint, docker compose config on every project, the VPN check, the naming rules, tofu validate, the docs build in strict mode, a drift check that fails any doc, AGENTS.md or skill naming a path, recipe or address that doesn’t exist, gitleaks, ShellCheck, and the Agent Skills validator.
Neither blocks the deploy. The hook is a plain git hook, which git push --no-verify skips, and the converge doesn’t wait for CI: it checks the signature, not the build status. A red CI tells me to revert. just plan shows what an apply would change on every machine and on Cloudflare, before anything moves.
Renovate opens the pull requests, I merge them
Renovate reads the compose files and the versions the Ansible roles pin, on weekends. It opens one pull request per new tag, one a week for all the digests that moved, and a major version waits until I approve it on its dependency dashboard.
Every app update waits for me. I merge with just merge <number>, which rebases the pull request onto main on my dev box, where every rebased commit is signed, and pushes the result; GitHub then shows the pull request as merged. A pull request merged with GitHub’s button would land signed by GitHub, not by me, and the converge would refuse it. The only automerges are Renovate’s bumps to CI-only files, which the converge skips anyway.
Secrets travel sealed until the last step

Every secret is committed, encrypted with SOPS to age keys, values sealed and names readable, so a diff still shows which secret changed. The file’s path decides who can open it: every file is sealed to my key, and every file an apply reads also to the converge’s. During an apply the converge decrypts in memory and writes the values into the project’s .env on its VM, readable by root only. There is no secret manager to run or unseal at boot: the repository and my age key rebuild the lab.
A pre-commit hook on my dev box runs gitleaks, refuses a SOPS file that isn’t valid ciphertext, and refuses a plaintext .env; CI runs gitleaks again on every push.
A changed secret goes live with the next apply, which recreates the service that reads it. The exception is a secret the app copied into its own state: a database password lives in the database too, so rotating it means changing it there first, then the file, in one sitting.
When a commit breaks something

After a failed apply, the converge leaves that commit alone for 15 minutes before trying it again; a new push is tried at the next minute. After an hour of failures, Grafana sends a mail through Cloudflare’s Email Sending API, like every alert in the lab, so its delivery doesn’t depend on the lab. The undo is git revert and a push, which goes through the same loop. To take the wheel back, just converge pause stops the loop, and just apply runs the same apply by hand from my dev box, refusing a checkout that isn’t exactly origin/main.
What I accept
A bad commit reaches the machines on its own, within a minute, and nothing stands in front of the deploy but my own reading. With one user and no availability promise, an alert and a revert are a price the contract accepts. The signature buys less than “only I can deploy”: it means only my dev box can, and what runs on that dev box matters as much as the deploy itself.
Cartoon illustrations generated with Gemini 3.1 Flash Image, through OpenRouter.
Icons in the diagrams: Simple Icons (CC0) and Lucide (ISC).