jerry@homelab:~/projects$ cat cicd.md
CI/CD without a Docker socket
Every stack in my homelab has its own git repository, and a push to main deploys it. That runs on Forgejo Actions with my own runners at home, one per Docker host. Getting there was mostly about one question: how much power should a CI job get?
Why not the Docker socket
The usual way to let a CI job deploy containers is to mount the Docker socket into it. But my runner shares the host with everything it deploys, including Forgejo itself, the thing the runner depends on to work at all. A job with the socket has root-level reach over every container on that host. One buggy workflow could take down the git server it was started from.
So a deploy job never touches Docker. It SSHes back into its own host and asks for one thing: "deploy this stack".
How it works
push to main โโโถ Forgejo โโโถ runner on the same host
โ job: ssh with a deploy key,
โ command = the stack's name
โผ
authorized_keys forces ONE script
(no shell, no port forwarding, no agent)
โ
stack on the allowlist?
โโ no โโโถ "Refusing: not an allowed deploy target"
โโ yes โโโถ git pull
docker compose pull
docker compose up -d
- One deploy key per host, never my personal SSH key. The keys are stored once as user-level secrets in Forgejo, so every repository can use them.
- A forced command. In
authorized_keys, that key can only ever run one script, withno-pty,no-port-forwarding,no-agent-forwardingandno-X11-forwarding. A compromised workflow gets that script, not a shell. - An allowlist. The script reads the requested stack name and refuses anything that isn't on its list for that host.
- A tiny workflow per stack: on a push to
main, write the key from the secret, SSH in with the stack's own name as the command. That's it. - Health check and rollback for the stacks on a second list: the script waits for the new container to report healthy, and if it doesn't, it puts the previous image (by ID) and commit back, and the job goes red. My home-dashboard is one of them.
The cloud VMs only receive
Two of my machines are small cloud VMs: one serves this website, the other watches my home network from outside. They never pull from my git server, which only answers inside my private network anyway. Instead, CI at home pushes to them over Tailscale. The upload goes into a write-only staging folder, and a root-owned script with its own allowlist copies it into place and restarts the stack.
This website is pushed the same way: CI builds it, rsyncs the result to the VM, and then fetches a version.txt from the live site to prove that this exact commit is what visitors get.
What stays manual, on purpose
- Forgejo, Caddy and Authelia: the git server, the reverse proxy and the login gate everything else depends on, the deploy pipeline included. A bad automatic deploy there could take down the very tools I'd need to fix it. They each have a repository, but I deploy them by hand. Same for the website VM's public Caddy.
- The deploy script itself. It's the gatekeeper that decides what can deploy. If a push to its repository could overwrite the live copy, a bad commit could rewrite its own gatekeeper. So I pull it by hand and run a sync script that installs each file only after a
bash -nsyntax check, and skips a broken one with a warning instead of installing it over the working copy.
Images I build myself
Self-built tools like my mc-tool and home-dashboard need an image built from source. CI builds it, pushes it to Forgejo's own private container registry, tagged both latest and the commit SHA, and only then runs the normal deploy, which simply pulls the new image. A v* tag builds a versioned image and a release, but never deploys: a version tag means "worth pinning", not "replace what's running".
Two details that took a failed run each to learn:
- Every host needs its own registry login, with a read-only token, separate from the token CI uses to push. The second host's first deploy failed with
no basic auth credentialsuntil it had one. - A pushed tag is not a release. It only creates a git ref. The release itself takes a call to Forgejo's API; the ready-made release action expected files to upload, failed, and left an orphaned draft behind.
The trade-off I accepted
Building an image inside a job genuinely needs a Docker daemon. Forgejo's runner can only give that to every job on the runner, not just the build jobs. So for image builds I turned socket access back on, which undoes part of the design above. I accepted that knowingly: it's a single-user homelab, and every workflow file on it is mine. If an SSH-to-self build step turns out to be feasible later, I'll switch.
While setting up the registry I also ran into a reported CVE about private images being pullable without logging in. Forgejo's answer was that package visibility follows the owner's visibility, so the fix was making sure my account itself is private, which it now is. The registry is only reachable inside my private network anyway.
Gotchas worth knowing
- Renaming a stack's folder breaks its next deploy. Docker Compose takes its project name from the folder name, so the renamed stack tried to create a container whose name the old, now orphaned project still held. Pinning
name:in the compose file prevents it. - A comment-only change doesn't redeploy anything. Compose compares the parsed config, not the file, so no recreate happens. Correct behaviour, but it surprised me the first time.
- A new stack must be added to that host's allowlist. Until it is, the deploy fails with "Refusing: โฆ is not an allowed deploy target", while the workflow file itself looks completely fine.
- A new stack folder must be a
git clonebefore the first deploy. A folder created by hand for the.envfile isn't one, andgit pullfails withfatal: not a git repository. - Removing a service from a compose file doesn't remove its container. A plain
docker compose up -dleaves it running;--remove-orphansonce by hand cleans it up. - A frozen
apt-get upgradelayer. Docker caches a layer by its command text, which never changes, so the "upgrade" ran once and then never again.--no-cachethrows away every other layer's cache too. The fix was a build argument set to the commit SHA, placed just above that one layer, so only that layer is rebuilt every time.
What's next
An SSH-to-self build step, so image builds no longer need socket access.
jerry@homelab:~/projects$ โ
cd ..