For a long time my side projects were deployed the classic homelab way: SSH into
the Docker host, git pull (if the project was even in git), docker compose up -d --build and hope for the best. One of my apps turned out to exist only on
that Docker host. Time to fix that.
The goal
- Git hosting I control, on my own hardware
- CI runners I host myself
git pushtomain= the change is live a minute later- Easy rollback when something breaks
The pieces
Forgejo is the git server. It’s a lightweight, community-run fork of Gitea and it runs comfortably in a small Proxmox LXC container (2 cores, 2 GB RAM). It brings repositories, pull requests, a container registry and Forgejo Actions, which uses (almost) the same workflow syntax as GitHub Actions.
A dedicated runner VM picks up the jobs. It has more CPU and memory than the Docker host, so all the image building happens there, with a warm build cache.
The Docker host no longer builds anything. It just pulls images and restarts containers.
How a deploy works
git push ──► runner builds image ──► Forgejo registry ──► Docker host pulls + restarts
(tagged with the commit)
- Every repo has a
.forgejo/workflows/deploy.ymlwith two jobs:buildanddeploy. buildbuilds the images and pushes them to the Forgejo registry, tagged with the full commit SHA.deploySSHes into the Docker host with a key that can do exactly one thing: run a small deploy script. That script checks out that exact commit and runs the repo’sci/deploy.sh, which pulls the images and restarts the containers.
Because every image is tagged with its commit, rolling back is just re-running an older pipeline.
Locking it down
- The deploy key is restricted with
command=andfrom=inauthorized_keys: it only works from the runner’s IP and can only run the deploy script. - Secrets (
.envfiles) never go into git; they only live on the Docker host. - The registry push token can only write packages; the Docker host has a read-only token.
- Old images are cleaned up automatically by a registry cleanup rule.
What’s next
- Tests that must pass before anything deploys
- A health check after deploy with automatic rollback
- Automatic dependency update PRs
And this blog? It’s a Hugo site in the same Forgejo, deployed by the same
pipeline. Writing this post was git commit && git push.