Projects / Platform & Infrastructure as Code

Homelab Platform (GitOps)

49 containers in 12 Compose stacks on a self-hosted Unraid server, deployed from Git with secrets kept out of the repo, health checks on every service and weekly off-box backups.

The problem

My home server started the way most do: containers created by hand in a web UI, settings that lived only in that UI, and a vague memory of how each one was configured. That works until something breaks, a disk fails, or a change needs to be undone. Several of the services aren’t just for me either: friends play on the game servers every week, so outages and lost data have a real cost.

I wanted the server to behave like a production platform:

  • Reproducible. Every service can be rebuilt from Git alone.
  • Reviewable. Every change is a commit with a reason, and can be rolled back.
  • Observable. I hear about a failure before the people using it do.
  • Safe to expose. Anything public is authenticated, least-privilege and documented.

Architecture

The platform is a single repository of Docker Compose stacks, one folder per concern, checked out directly on the server where they run:

Stack What it runs
monitoring Uptime Kuma, Beszel hub and agent, VictoriaMetrics, a custom player-telemetry collector, the ops dashboard
sso Tinyauth and Pocket ID: OIDC single sign-on with passkeys for every admin surface
arena, minecraft-*, palworld, edopro, corellia-* Game servers and their sidecars (backups, web consoles, performance loggers)
portfolio A public landing page

Traffic arrives through Cloudflare, which is the only source the router accepts on ports 80 and 443. It then passes to Nginx Proxy Manager and, for anything administrative, through a forward-auth check against the SSO provider. DNS records and port forwards are themselves code, kept in a second repository and deployed to the router with a dry-run diff first.

Key decisions

Git is the source of truth, the server is a checkout. Stacks are registered with Unraid’s Compose Manager as indirect projects pointing at the repository, so the Unraid UI and git log always agree. Changes are made in the repo, applied with docker compose up -d, and committed with a message explaining why.

Secrets never enter Git. Credentials live in git-ignored *.env files next to each stack, in root-only files on the server’s flash drive, or in the application’s own encrypted store. Committed .env.example templates document what each one needs. Scripts that create credentials are run by a human, and nothing ever echoes a secret.

Least privilege, added one scope at a time. The automation token for Cloudflare started with read-only zone access and gained exactly the permissions each new script needed (cache rules, purge, WAF), nothing more. Admin routes are gated by group: game moderators get the game consoles and nothing else.

Health checks everywhere, and monitoring that understands the apps. Services define Docker health checks that test what the app does, not just whether a port is open. Uptime Kuma watches 50 endpoints across HTTP, TCP, Docker state and game-server probes, organised into groups per service. A purpose-built collector speaks seven game protocols to report players online, and VictoriaMetrics keeps 90 days of history for both.

Published images over local builds. Where no suitable upstream image existed, I built my own in GitHub Actions and published it to GHCR. Images rebuild weekly for base-image security fixes, and the workflow opens an issue when a tracked upstream patch moves ahead.

Backups that are actually restorable. A weekly job stops each stateful container, archives and verifies it, copies it to a separate NAS, and keeps a rolling window. Sidecars that share a server’s data are excluded so archives never capture files mid-write, and game servers get a two-minute stop grace period so a backup can’t interrupt a world save.

Operating it

Most of the platform’s real engineering happened after launch:

  • Renaming a game server touched the stack, DNS, the router, SSO rules, monitoring, the dashboard and backup settings. Because each lives in code, the rename was a set of reviewed diffs rather than a hunt through web UIs, and a missed backup setting surfaced in the next run’s verification.
  • The same rename left two monitoring checks pointing at containers that no longer existed. They went red rather than silently passing, so the loose ends were visible and fixed the same day.
  • Moving the ops dashboard to a new tool was a configuration change: the board, its apps and custom widgets are JSON in Git, applied by an idempotent script with a dry-run mode.

Results

  • 49 containers in 12 stacks, every one reproducible from the repository.
  • 50 automated health checks with alerting and a public status page.
  • Every change reviewable: the history explains why the platform looks the way it does.
  • Off-box backups verified weekly, with per-container rules for consistent archives.

What I’d do next

  • Move the remaining hand-provisioned VMs to Terraform with remote state.
  • Add automated restore tests, so backups are proven by a scheduled restore, not just a verify step.
  • Publish sanitized versions of the stacks as a public reference repository.

← All projects