Skip to content
WielandTech

← All work 02

K3s homelab platform

A small production platform in a closet. GitOps with Flux, a review app for every pull request, managed Postgres, and full observability.

1,300merged pull requests

Role
Sole architect and operator
Year
2025–2026
Status
Live
Diagram of connected cluster nodes.

Problem

Side projects usually die in deployment. Every new app needs hosting, TLS, a database, backups, monitoring and a way to try changes before they go live. Doing that by hand per app is how projects stall. The goal was a platform where shipping a new app is a template and a pull request, and where the deploy process itself stops mistakes.

Approach

Everything is declared in one Git repository and reconciled by Flux. Nothing is changed in the cluster by hand.

Architecture

Homelab network topologyPublic traffic reaches a cloud VPS, crosses an encrypted WireGuard tunnel to a UniFi gateway with dual WAN, and is allowed only to the ingress load-balancer address inside a default-deny cluster zone. Trusted LAN, IoT, work, guest and remote-VPN zones sit beside it. A GPU worker on the trusted LAN joins the cluster through narrow pinholes, and Tailscale is the admin path.Internetpublic DNS →cloud edgeCloud edge (VPS)TLS passthrough,PROXY header addedGateway (UniFi ZBF)zone-based firewall + IPSdual WAN: fiber + LTEWireGuardencryptedTrusted LANWorkstationsreach LAN appsGPU worker (WSL2)joins the clusterfrom the trusted LANIoT zoneisolated, replies onlyWork zoneinternet onlyGuest zoneclient isolationRemote VPN→ cluster zoneCluster zone (default-deny)Load-balancer IPs (MetalLB)IngressTraefikMediaPlexHomeHADatabasePostgresGameMinecraftK3s nodesControl-plane nodeWorker ×2Tailscale operatoradmin path; read-only API proxy for agentsNAS (block + file + S3)iSCSI volumes, NFS shares,object storage forPostgres backupsUPSbattery fornodes + NAStunnel → ingress onlyLANpinholesautomationpublic HTTPS pathallowed by a firewall policy; everything else is denied
Network. Public traffic lands on a cloud edge and crosses an encrypted tunnel; the cluster zone is default-deny and only the ingress address is reachable from it.
Path of one public HTTPS requestNine steps: DNS to the cloud edge, TLS passthrough, a PROXY header over the encrypted tunnel, the firewall rule that only allows the ingress address, the MetalLB address with a Local traffic policy, Traefik terminating TLS with the real client IP, the router or LAN-only guard, the service and pod, and finally CloudNativePG.DNSA record points at the cloudedge, never at home.1Cloud edgePasses TLS through untouched;plain HTTP only for ACME.2PROXY header + tunnelPrepends the client IP, thenWireGuard to the gateway.3Firewall allow ruleTunnel may reach the ingressIP and nothing else.4Load-balancer IPMetalLB; Local traffic policykeeps the source address.5Traefik terminates TLSLet's Encrypt cert. Trusts thePROXY header only from the tunnel.6Router or LAN-only guardInternal hostnames 404 foranyone outside the LAN.7Service → podOverlay network across nodes,GPU worker included.8Pod → PostgresCloudNativePG primary, withreplicas and backups behind it.9
One public HTTPS request, from DNS to Postgres. TLS terminates in the cluster, and the real client IP survives the tunnel.
Homelab platform layersFrom the bottom: hardware and K3s, networking, storage and data, secrets, observability, CI, and application namespaces on top. Flux reconciles every layer from Git and is drawn as a spine on the left.Applicationsby categoryPublic websites, APIs, shopsInternal toolsdashboards, RSSAI / GPUlocal LLMsMediastreaming, libraryHome automationHome AssistantGamesMinecraftCIin-clusterARC runnersephemeralBuildKitrootless, remoteRenovatedependency PRsObservabilityPrometheus+ GrafanaLoki + Alloypod logsAlerting→ phone, dead-manSecretsSealed Secretsencrypted in GitReloaderrestart on changeStorage & dataSynology CSIvolumes, snapshotsCloudNativePG3 instancesBackups→ object storageNetworkingMetalLBL2 IPsTraefik+ LAN guardcert-managerLet's EncryptTailscaleoperatorHardware & K3sControl plane1 nodeWorkers2 nodesGPU workerWSL2Lifecycleauto-upgradeBottom up: each layer depends on the ones below it. NAS + UPS sit under the hardware layer.FluxGitOps:reconcilesevery layerfrom oneGit repo.Nothing isappliedby hand.
Platform layers, bottom up. Flux reconciles every layer from Git.

Shipping

CI/CD and environmentsApp repositories build on in-cluster runners and push images with three tag families. Pull-request tags become review apps through a validated, auto-merged PR; dev tags flow to dev through Flux image automation; QA is pinned by a promotion workflow; production tags fast-forward the platform repo and a post-merge job waits for Flux to apply that exact commit before checking health.EventBuild → GHCRPlatform repo automationEnvironmentrunners +BuildKitPull requestpr-<n>Review-app PR generatedgate: render + schema + policy ✓Review appper-PR namespace + URLBranch pushdev-<ts>Flux image automationgate: render check → fast-forwardDevtracks newest dev tagPromotereuses devPromotion workflow pins taggate: render + schema + policy ✓QA (opt-in)pinned tagMerge to main<ts> (prod)Flux image automationgate: render check → fast-forwardProdpublic via the edgePost-merge verification (every push to the platform repo)1. Revision gate: wait until Flux has applied this commit (or a newer one).2. Then check pods, endpoints and events against a risk-based deadline.3. Fail the run unless the verdict is healthy.
The promotion ladder every app uses. Image tags drive each step, every step has a gate, and Git records every change.
  • Review apps for every PR. Opening a pull request in any app repo builds an image and dispatches an event. The platform repo then generates an isolated namespace, hostname and TLS certificate for it. Closing the PR tears it all down.
  • Gates built into the workflows. The org is on GitHub’s free plan, so there are no server-side required reviewers. The gates live in the workflows instead: promotions poll the CI check before merging, and a post-merge job verifies that the new image actually rolled out.
  • One shared, backed-up Postgres. CloudNativePG runs a single cluster. Apps get isolated databases and roles from a manifest, with nightly volume snapshots kept 14 days, plus continuous WAL archiving and weekly base backups to object storage for 30 days of point-in-time recovery.
  • CI inside the cluster. Ephemeral GitHub Actions runners and a shared remote BuildKit, so there’s no Docker daemon anywhere, including on the dev machine.
  • Safe for coding agents. Agents do much of the day-to-day work here, so their access is scoped. A read-only MCP server gives them pods, logs, events, Flux status and PromQL through a tailnet identity that RBAC limits to get/list/watch, with no Secrets. Anything that changes the cluster still has to be a pull request.
  • Observability. Prometheus, Grafana, Loki (14 days of logs for every pod), Alertmanager pushing to phones, and an external dead-man’s switch that reports when the whole lab is down.

Hard parts

  • Upgrading Flux safely. Each Flux minor version drops old API versions, so manifests have to be migrated before the controllers are swapped. Otherwise anything still on a removed version silently stops applying.
  • Automated dependency updates without footguns. Renovate automerges only patch-level image updates and minor chart updates, and only after CI is green. Everything else waits for review.
  • Hostnames live in the apps. ALLOWED_HOSTS, CSRF origins and CSP host lists are app code, so adding a domain means the app change deploys before the ingress change, or the new host returns 400 until it does.
  • Public ingress from home. A small cloud front end passes TLS through to the cluster over a tunnel, with PROXY protocol so apps still see real client IPs.

Outcome

The strip on the home page is live data from this cluster. As of this writing it runs 4 nodes (three bare-metal, one GPU), ~85 deployments and ~150 pods, at 99.9%+ availability over 30 days. It hosts 12 apps wired for review apps. The platform repo has 1,300 merged pull requests, about 150 of them review-app deploys. Every cluster change goes through Git.

It is also more expensive and less reliable than the cloud.

Stack

  • Kubernetes
  • K3s
  • Flux
  • Helm
  • CloudNativePG
  • Prometheus
  • Grafana
  • Loki
  • GitHub Actions