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 push to main = 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)
  1. Every repo has a .forgejo/workflows/deploy.yml with two jobs: build and deploy.
  2. build builds the images and pushes them to the Forgejo registry, tagged with the full commit SHA.
  3. deploy SSHes 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’s ci/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= and from= in authorized_keys: it only works from the runner’s IP and can only run the deploy script.
  • Secrets (.env files) 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.