Skip to content
Walid Karray
Go back

Homelab GitOps With Docker Compose: Signed Commits, Pulled Every Minute

Cartoon of me leaning back in a chair with a coffee, feet up, while a small box-shaped robot reads a long paper scroll and a crane sets shipping containers onto two servers, under a wall clock

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

Hand-drawn app platform: a browser reaches Traefik over HTTPS on the apps-private VM, where Authelia sits beside it and a Compose project holds an app and its own database, both writing named volumes backed up nightly; on the apps-untrusted VM an app parses the internet, and a vpn project puts an app behind gluetun, its only way out to the commercial VPN; a monitoring VM holds Grafana; the NAS gives media read-only to apps-private and downloads to apps-untrusted through the host; a cache disk on the VM is not backed up

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:

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

Hand-drawn deploy loop: my dev box edits, signs and pushes to main on GitHub; the CI runner validates and holds nothing of the lab; the converge pulls main every minute, applies signed heads only, over SSH to the guests and the app VMs, and reads what the push changed: a project to that project, a role to that role, the Cloudflare config to Cloudflare, anything else to everything; and all of it, daily

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 changesThe converge applies
an app’s project directorythat project, on its VM
an Ansible rolethat role
the Cloudflare configCloudflare, with OpenTofu
the docsthe docs site’s project
CI files, agent skills, notesnothing
anything elsethe 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

Hand-drawn comparison: a stolen GitHub account pushes to main, the converge finds the head not signed by me, refuses it, and mails if it stays refused; my dev box pushes a signed commit and the converge applies it

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:

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

Hand-drawn path of a secret in four steps: my age key opens sops on my dev box, where I edit the value; the ciphertext is committed, names readable; the converge pulls it and decrypts it in memory with its own key; it writes the value over SSH into the project's environment file on its VM, readable by root only

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

Hand-drawn failure loop: a bad commit lands, the converge applies it and fails, retries every 15 minutes, and after an hour a mail goes out; I git revert and push, a new commit on main that the converge applies; to stop it, I pause the converge

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).


Share this post:

Related posts

Follow new posts by RSS

Previous Post
Activity Reports for Freelancers, Filled In by an AI Agent over MCP
Next Post
How I Run My Homelab in 2026: Proxmox, Docker Compose and Tailscale