The homelab is the platform underneath everything else on this site. It runs in a rack in my basement on a single Ubuntu box, hosts the sixteen-plus containers that make up my personal services and the shared infrastructure they all reach for, and acts as a staging environment for the same patterns I deploy at work. When I want to prove a pattern out — a GitOps deploy flow, a backup strategy, a shared message bus, an LLM contract — I build it here first.
The mental model is not “one big application.” It’s a small set of apps that share plumbing well enough that the next one is cheap to build. The plumbing is the work; the apps are what you see.
What’s running
Roughly sixteen containers, grouped into three categories that I keep strict about:
- Apps — user-facing services. A lifting log, a sports analytics dashboard, a jobs scraper, a homepage for my partner styled as a digital bullet-journal spread, the homelab dashboard itself.
- Infrastructure — the shared plumbing every app reuses. A reverse proxy, an internal DNS resolver, Portainer for container management, a WireGuard VPN as the only off-LAN entry point, Nginx Proxy Manager terminating Let’s Encrypt SSL, a host-metrics exporter, a log viewer, a web DB admin tool, and an Open WebUI instance wired to a small local LLM.
- Dev and shared — a shared Postgres with one schema per app, a dedicated Postgres for the one app that earned its own (and the one where partial unique indexes on app invariants pay off), and a RabbitMQ broker for event fanout and agent jobs.
The rule that keeps the platform from drifting into chaos is that apps own their own data but everything else is shared. Apps don’t reach into other apps’ schemas. Cross-app queries go through APIs, never through SQL joins. The discipline is what makes the n-th app cheap to build.
The deploy flow
The piece I’m most proud of and least likely to brag about at a party.
The original deploy was the obvious thing: SSH into the box, git pull, docker compose up -d --build, eyeball the logs, log out. It
worked. It also encouraged drift — half-applied changes, untracked
local edits, “I’ll commit it later” .env files. Production
environments aren’t supposed to be like that, even when the production
environment is yours.
So I replaced it with a real CD pipeline. One reusable GitHub Actions
workflow lives in the umbrella ops repo. Every deployable app calls it
via workflow_call with stack-specific inputs — health URL, container
name, environment-secret name, the commit SHA to deploy. Self-hosted
runners on the box pick up the job, the workflow SSHes into localhost
(yes, the runner is on the box, but the indirection keeps the workflow
portable), the box does git fetch && git reset --hard <sha>, the
deploy .env gets materialized from a per-stack organization secret,
docker compose up -d --build runs, a post-deploy health check
verifies the container is up and answering, and the workflow either
goes green or reports the failure.
End-to-end deploy on the pilot app: about thirty-five seconds from
git push to a verified-healthy container. Rollback is a
workflow_dispatch with an older SHA, which makes “rollback” a
deliberate action with an audit trail instead of a panicked SSH
session.
The reason it’s structured this way is that the alternative — N copy-pasted per-app workflows — would have rotted within a month. One reusable workflow with stack-specific callers means a fix to the deploy pipeline lands in every app at once. That’s the same factory pattern I write about on this site, just applied to CI instead of agent orchestrators.
Backups
The backup story is two-tiered.
Nightly, a script on the box auto-discovers every Compose stack,
tarballs the project directory, snapshots every named volume, and
runs pg_dumpall against each Postgres container into a dated
directory on RAID. Fourteen-day local retention. The script is
idempotent, runs out of cron, and writes a status file that the
homelab dashboard surfaces as a sidebar pill — so I see at a glance
whether last night’s backup ran clean.
Monthly, a second script reads each stack’s .env, symmetric-GPG-
encrypts it with a passphrase stored outside the GitHub-secrets path,
and writes the encrypted payload to RAID. Twelve-month retention.
GitHub organization secrets are the source of truth for environment
config; the GPG-encrypted RAID copy is belt-and-suspenders against
GitHub being unavailable, a secret being deleted, or a credential
needing to be reconstructed without re-typing it from a password
vault.
The two-tier setup is deliberate. The nightly tier protects against the boring failure modes — a botched migration, a corrupted volume, a config file I overwrote. The monthly tier protects against the exotic ones. They don’t share infrastructure with each other, which means a single failure can’t take both out.
The dashboard
The landing page is its own custom three.js application, not Heimdall or Homepage or anything off the shelf. I wanted something I would actually enjoy looking at, and the obvious dashboards all look like status pages.
It has two views.
The rack view is a 3D rendering of the rack itself. Each running service is a 1U unit in the rack, with status LEDs, a fill bar tied to a relevant metric (request rate, queue depth, container health), and a label. Hover for details. Click through to the service. The sidecar runs HEAD and TCP probes against every service every ten seconds and a red “issues” pill surfaces in the corner whenever anything is down. The aesthetic is “engineer’s workstation,” deliberately. It’s not pretty in the design-system sense; it’s detailed in the way a real piece of hardware is detailed.
The lab view is the one I’m prouder of. Each meaningful project or metric on the platform is rendered as a piece of glassware — Erlenmeyer flasks, beakers — sitting on a lab bench. Each vessel fills based on a configurable data source: a value log (weight, bankroll, protein, net worth), an HTTP poll against an app’s API (setups in the stock screener, jobs to review in the job finder), or a stub for vessels that don’t have an upstream yet. Click a vessel to record a new reading or open the underlying app. The vessels declare themselves in a YAML file that gets bind-mounted into the container and hot-reloaded on SIGHUP — so adding a new vessel is a YAML edit on the host, no rebuild.
The reason the lab view exists in addition to the rack view is that they answer different questions. The rack view answers “is anything broken.” The lab view answers “what does the system look like this week” — what’s filling up, what’s draining, what hasn’t moved. Both are dashboards; they’re dashboards for different problems.
The configuration story — services and vessels declared as host-mounted YAML, dashboard reloads on SIGHUP, no rebuild — is the pattern I keep reusing for other personal apps. Config-as-data, app-as-renderer. Fast feedback. Tiny blast radius.
What’s there because it’s everywhere at work too
Half of the platform is a staging environment for patterns I deploy at work. The CD pipeline shape, the GitHub-org-secret-as-truth pattern, the GPG-encrypted offsite backup, the schema-per-app discipline, the dead-letter-aware RabbitMQ contracts, the shared LLM endpoint — those are all patterns I prove out here before pushing them into a larger codebase where the failure modes are more expensive.
The shared LLM contract is the most recent example. Every app that
needs an LLM call speaks the OpenAI-compatible shape against an
endpoint declared in the stack’s environment file. The backend today
is a 16GB GPU in the rack serving 20B-class open-weight models behind
Ollama’s OpenAI-compat server, with the small-model Mac node it
started on kept as a fallback. Calling apps inject LLM_BASE_URL,
LLM_MODEL, and LLM_API_KEY from their stack environment; they
don’t know whether the backend is Ollama, Open WebUI, or a hosted
provider. That’s the same shape I want when LLM integration shows up
in real production systems — contract first, backend later.
What’s deliberately open
Two things, on purpose.
The first is that not everything is on the new pipeline yet. A few of the older apps still deploy via SSH and a git pull. Onboarding them to the pipeline is a deliberate phased rollout — pilot first, then infrastructure stacks, then app repos in risk order. The slow rollout is the point. I’d rather have three apps cleanly on the pipeline than ten apps half-migrated.
The second is that there’s no unified notification hub yet. Apps
either log to stdout (where my log viewer can show them) or post
directly to specific channels. The right shape is a single endpoint
every app calls — {source, severity, title, body, channel_hint} —
with a rules table that decides where each notification fans out.
That’s the next infrastructure piece on the list, and it’ll unblock
real alerting on top of the existing healthcheck infrastructure.
Why a homelab
The honest answer is that I would build this whether or not anyone ever saw it. Self-hosted infrastructure is a hobby and a craft and a reason to keep my hands on the parts of the stack that I don’t always get to touch at work.
The professional answer is that I learn faster when I own the whole stack. When something breaks in production at work, the failure mode is usually four layers away from anything I can change in the next ten minutes. When something breaks here, I am the SRE, the platform engineer, the app owner, and the user — usually the same evening. That feedback loop is the fastest skill-building environment I’ve found. Most of the patterns I write about on this site were proven out here first.