~/projects/homelab.md

homelab

Production-grade Kubernetes, in my house, rebuilt from one Nix flake.

A NixOS-flake-defined k3s cluster run the way I'd run production: GitOps delivery, sealed secrets, a private registry with its own CA, and real monitoring.

read the case study

highlights

  • Whole host defined in one NixOS flake, including a custom k3s derivation.
  • GitOps with ArgoCD, Argo Workflows and Flux; secrets via sealed-secrets.
  • MetalLB + Traefik for load balancing and ingress; Tailscale for remote access.
  • Workloads span AI/ML, CI/CD, home automation, monitoring, media, storage and game servers.
  • Custom apps written in Rust, Svelte and Python.

Running my house like production

case study · updated Sep 2026

The homelab is where I practice the job without a change-review board. It runs about forty workloads (AI inference, home automation, media, file sync, CI and a handful of game servers) on a single k3s node, next to a Proxmox server it also monitors. The node is rebuilt from one NixOS flake, and Git is the only way anything changes.

The repository is private because a homelab accumulates personal data. This page covers the architecture and the decisions behind it.

Goals

  • Rebuildable from scratch. If the disk dies, reinstalling means booting the installer, applying the flake, and letting GitOps put every workload back.
  • Git is the control plane. Cluster changes go through commits that ArgoCD reconciles, and host changes go through the flake.
  • Real practice. The same patterns I’d use at work (GitOps, private registry, image automation, metrics) at a scale where I can break things safely.

Architecture

┌────────────────────────── NixOS host (one flake) ──────────────────────────┐
│ hardware · networking · storage (NFS/SMB) · nvidia · tailscale             │
│ backup · agenix secrets · node-exporter · custom k3s 1.34 build            │
│                                                                            │
│   ┌──────────────────────────────── k3s ─────────────────────────────────┐ │
│   │ MetalLB (L2) ──► Traefik ──► ~45 hostnames, private-CA TLS           │ │
│   │                                                                      │ │
│   │ ArgoCD (app of apps) ◄── Git                                         │ │
│   │  ├─ platform:  metallb, traefik, cert-manager CA,                    │ │
│   │  │             sealed-secrets                                        │ │
│   │  ├─ delivery:  Argo Events + Workflows (Kaniko), Harbor              │ │
│   │  └─ workloads: AI, automation, media, storage, games                 │ │
│   │                                                                      │ │
│   │ Prometheus + Grafana ◄── exporters (node, apps, Proxmox)             │ │
│   └──────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘

By the numbers:

  • 32 LoadBalancer services
  • 45 ingress hostnames
  • 35+ namespaces with external access
  • 15 NixOS modules
  • ~650 commits in the first year

The host: one flake, fifteen modules

The machine is a nixosConfigurations entry in a flake that pins nixos-unstable, with nixos-hardware, nixvim and agenix as inputs. Each concern is its own module with its own options (modules.monitoring.enable, modules.backup.enable, …), so the host file reads like an inventory instead of a script. Host-level secrets are age-encrypted with agenix and decrypted at activation; they never touch the Nix store in plaintext.

Patching Kubernetes with Nix

k3s 1.33 had a startup race: CNI symlinks were created before the data directory was in place, so pods on a fresh boot could fail networking. The fix landed upstream in 1.34.1 before nixpkgs packaged it. Instead of waiting, I wrote a custom derivation for k3s 1.34.1+k3s1, pinning the versions, chart versions, image list and hashes, and I override the system k3s with a NixOS module. Going back to the stock package is a one-line change. This is the reason to run Kubernetes on Nix: the cluster runtime is just another pinned, reviewable input.

GitOps: ArgoCD, after trying to run two tools

Everything in the cluster is an ArgoCD Application, generated from an app-of-apps. Plain manifests are organised with Kustomize, and third-party software is deployed as ArgoCD Helm applications.

It didn’t start that way. Early on, some workloads were Flux HelmReleases and others were ArgoCD Applications, and only four apps were actually registered in ArgoCD. The fix was a migration:

  • every workload became an ArgoCD Application;
  • the Helm releases became ArgoCD Helm sources;
  • storage and namespace setup moved into separate “prereqs” applications, so ordering is explicit;
  • every workload folder was normalised (namespace declared, one resource per file).

Lesson: pick one reconciler per cluster. Two controllers that each think they own the desired state is how you get drift you can’t explain.

Delivery: from git push to a running pod

GitHub push ──► Argo Events (webhook) ──► Argo Workflows
                                                    │
                                        test ─► Kaniko build (no Docker daemon)
                                                    │
                                                    ▼
                         Harbor (private registry, robot accounts, own CA)
                                                    │
                          workflow step: kustomize edit set image, commit to Git
                                                    │
                                                    ▼
                                       ArgoCD syncs ──► rollout

The pipeline never touches the cluster directly. Its last step writes the new image tag into the Kustomize overlay and commits it, so every deploy is a Git commit you can read, revert or bisect. Adding CI for a new repository is a template copy: an EventSource, a Sensor and a WorkflowTemplate. Before settling on Argo Workflows I ran Tekton, and I also prototyped Woodpecker with ArgoCD Image Updater. Argo won because it shares a UI, RBAC model and GitOps flow with the rest of the platform, so there’s one less system to operate.

Networking and TLS

MetalLB hands out LAN addresses in L2 mode for anything that needs a real IP (DNS, game servers, the registry). Everything HTTP goes through Traefik. A cluster-internal CA, managed by cert-manager, issues certificates for internal hostnames, and Tailscale provides remote access without exposing ports to the internet.

Observability

Prometheus scrapes node-exporter on the host, the cluster, and exporters for individual apps and game servers. Dashboards and alerts live in Git like everything else.

For example, the Proxmox hardware monitoring app is a Kustomize package that ArgoCD deploys. It contains:

  • an Endpoints/Service pair pointing at the Proxmox box;
  • an additional Prometheus scrape config for its node-exporter hwmon sensors (15 temperature sensors, fan speeds, voltage rails, PWM duty cycles);
  • a Grafana dashboard shipped as a ConfigMap;
  • seven alert rules, such as CPU temperature warning and critical, fan failure, NVMe temperature and exporter down.

One piece I’m proud of is a small Rust service (tokio, reqwest, twilight) that runs PromQL queries against Prometheus and publishes live server stats as Discord channel names. It is built for Discord’s rate limits:

  • it only updates on meaningful changes (per-metric thresholds);
  • it persists its last state to a volume, so restarts don’t spam the API;
  • it ships with health checks and resource limits like any other production service.

What I’d do differently

  • Enforce secret hygiene from commit one. Secrets management grew from ad-hoc manifests into sealed-secrets over time. A gitleaks pre-commit hook and a “no plaintext Secret kind” policy check in CI on day one would have made that migration unnecessary.
  • One of everything. I tried two GitOps controllers and three CI systems. Trying them was worth it, but running more than one at a time wasn’t.
  • Real backups, not copies. Today the backup job rsyncs the repo and host config to a second disk. I’d replace that with restic to an off-site target plus scheduled restore tests, because a backup you haven’t restored isn’t a backup.
  • Plan for a second node. A single node keeps it cheap and simple, but upgrades mean downtime. A small second node would make replicated storage and drain-and-upgrade maintenance possible.

source

This one lives in a private repository. Happy to walk through it: stephen@aethyriion.dev

try about, experience, projects, stack, contact, neofetch · gui or ` to switch back · Esc for vim keys

NORMAL

*aethyriion-help.txt* keys work everywhere

Mouse and Tab work as usual. Vim keys are an extra, and :set nokeys (or the vim toggle in the title bar) turns them off. Vimium users: add an exclusion for aethyriion.dev.

move

jk
down / up (counts work: 5j)
hl
previous / next pane
ggG
top / bottom (3G = 3rd item)
C-dC-u
half page down / up
]][[
next / previous section
HML
top / middle / bottom of screen
zz
center the cursor

tabs & pages

1…5
jump to tab
gtgT
next / previous tab
-
up one directory
rcp
résumé / contact / projects

act

Entero
open the selected item
O
open in a new browser tab
fF
link hints (F = new tab)
yy
copy link of the selected item
yeyu
copy email / page URL

search & commands

/
search this page (smartcase)
nN
next / previous match
:
command line (Tab completes)
i
terminal mode (type commands)
`
toggle GUI / terminal mode
?
this help
Esc
cancel / close / back to NORMAL

ex commands (also typed in the shell without the colon)

:e projects
open a page (Tab completes)
:colo amber
switch theme
:set nocrt
turn scanlines off (nomotion, nokeys, sfx)
:help
this screen · :checkhealth · :version
:hire
you know what to do
all 26 commands
help [topic]
List commands (`?` shows the key bindings) (h, man)
cd <page>
Open a page in the GUI: about, experience, projects/<name>, stack, contact, resume (e, open, cd)
cat <file>
Print a section here: about.md, experience, projects, stack.nix, contact.toml (less, bat)
gui
Back to the GUI view (or press `) (exit, logout, startx)
ls
List pages (ll, la, tree)
home
Go to ~/README.md (readme)
about
Who I am (bio, whois)
experience
Work history (exp, work)
projects
Things I've built (proj, repos)
stack
Skills and tools (skills)
contact
How to reach me (links, socials)
resume
Résumé (HTML + PDF) (cv)
tabn / tabp
Next / previous tab (tabn, tabnext, tabp, tabprevious)
colo <name>
Switch theme: aethyr, green, amber, dracula, mono (colo, theme)
set [no]crt|motion|keys|sfx
Toggle effects, motion, vim keys, sound; theme=<name> (se)
whoami
Print the short version
neofetch
System info (fastfetch, fetch)
gh[!]
GitHub profile (gh! opens it) (gh)
email
Copy my email address (mail)
hire
kubectl apply -f offer.yaml
matrix
Wake up, Neo (cmatrix)
terminal
Switch to terminal mode (or press i) (cli, shell, term)
clear
Clear the shell (cls)
history
Command history
version
Build info and credits (ver)
checkhealth
Site health report