Case study: homelab

Running a self-hosted stack — Gitea, CI/CD, game servers, monitoring, photos — on hardware I actually own.

Context

The default answer for personal projects is GitHub + some managed hosting. It works fine. At some point I decided I wanted to understand more of the stack I was depending on, and I had a machine sitting idle. So I started running things myself. That was the extent of the reasoning. No ideology, no distrust of cloud providers — just curiosity about what’s actually involved in running services rather than subscribing to them.

The lab is a mix of physical hardware and VPSes: a home machine, two Hetzner nodes, all stitched together over Tailscale. Over time it picked up Gitea, a container registry, a CI/CD system, game servers, monitoring, and photo storage.

Problem

The surface-level problem is operational: how do you get code from a local git push to a running container without standing up cloud infrastructure? But the real problem is that every convenience layer you skip forces you to replace it yourself — auth, TLS, log aggregation, health checks, update automation. Managed services bundle this silently. Running your own stuff makes every missing piece visible.

The constraint I cared about: the setup should be reproducible. If a machine died, I wanted to be able to rebuild it without archaeology.

Approach

1. Why Gitea over GitHub

GitHub works. I still have projects there. The reason to run Gitea isn’t that GitHub is bad — it’s that for personal projects, a self-hosted Gitea instance at repo.explewd.com is entirely under my control, and the full CI/CD loop stays on metal I own. No rate limits on CI, no third-party access to private repos, no dependency on someone else’s platform for the projects I actually care about.

Also, considering the current owner, their AI policy and the way they auto-onboard you unto features you do not want, I elected to nope out.

The tradeoff is maintenance. Gitea needs updates. Backups need to be deliberate. If the machine goes down, the git remote goes with it. I’ve accepted these tradeoffs. For anything that needs reliable external access — open-source projects, collaboration — GitHub might be the right answer. But for how long?

2. Topology

Three nodes, one Tailscale mesh:

thunkstation — a physical box at home running OpenMediaVault. Not public-facing. Hosts Gitea and the CI runners, Immich (photo storage), Jellyfin, and the observability stack (Prometheus + Grafana). The runners share CPU with everything else, which occasionally matters during heavy builds.

xpekatt-hel — a Hetzner VPS, the only node exposed to the internet. Runs Nginx Proxy Manager as the public ingress and hosts the public-facing services: goonk.se, Delve, f1-dashboard, and the game servers. Because it’s on the same Tailscale network as thunkstation, it can forward traffic to services running on the home machine without opening those machines to the internet.

claw-hel — a second Hetzner VPS, private. Not publicly accessible. Reached via SSH over Tailscale (and Telegram for specific automations). Currently runs openclaw and is being replaced.

3. Routing

Only xpekatt-hel is internet-facing. Nginx Proxy Manager handles TLS termination and routes by hostname — adding a new service means adding a proxy host entry and pointing DNS at the VPS. Services on thunkstation that need to be public are proxied over Tailscale from xpekatt-hel without exposing thunkstation directly.

4. The CI/CD pipeline

Gitea Actions, close enough to GitHub Actions that most workflow syntax transfers directly. A push triggers a workflow that:

  1. Checks out the code
  2. Builds a Docker image
  3. Pushes the tagged image to the Gitea container registry
  4. I ssh to the target host and runs docker compose pull && docker compose up -d

Step 4 is deliberate. A proper deploy agent or registry-watching setup would be more automated, but SSH is auditable and keeps me in control — the workflow file describes exactly what happens, and nothing deploys without an explicit push. The registry credential is a deploy token scoped to read-only on the specific image.

5. Observability

node-exporter runs on each machine and exposes metrics. Prometheus on thunkstation scrapes them and stores the time series. Grafana sits on top. Logs are still per-container and require SSH to dig into — that’s a known gap.

6. What actually breaks

Certificate renewal fails occasionally when Nginx Proxy Manager can’t reach the ACME endpoint. Symptom is a TLS error; fix is forcing a renewal from the NPM UI.

The runner runs out of disk after months of builds accumulating images. A weekly docker image prune cron job keeps it in check.

That, or a lot of stale containers fill the VPS disk. It’s happened more than once.

SSH key rotation broke CI for a few days when the deploy key was rotated without updating the Gitea Actions secret.

Immich updates occasionally require a database migration before the new container will start. Easy to miss if you just pull and restart. Read the release notes first.

The failure mode I think about most: thunkstation dies. Compose files and configs should be in git (they’re not all there yet — that’s in the todo list). Photos go to a separate disk with off-site copies. Rebuilding the service layer from scratch would be a day’s work.

What’s missing

  • Config in git — not all docker-compose files are versioned yet. “I’ll remember” is a lie. Most new fun things live entirely in gitea.
  • Log aggregation — finding out why something crashed requires knowing which container and SSH access. Loki would help.
  • Off-site backups for Gitea — photo backups are solid; the git repos are only backed up locally.
  • Staging environment — deploys go straight to production. Fine for stateless game servers; would matter more for anything with shared state.
  • OMV migration — OpenMediaVault works but feels like overkill for what thunkstation actually does. A normal Linux distro would be simpler to manage.

Status page →

Stack

LayerTech
Public ingress / TLSNginx Proxy Manager
Private networkingTailscale
Container orchestrationDocker Compose
Git hostingGitea (repo.explewd.com)
Container registryGitea built-in registry
CI/CDGitea Actions
Photo storageImmich
MediaJellyfin
ObservabilityPrometheus + Grafana + node-exporter