skip to content
BitsAndBytes
Table of Contents

My homelab runs on docker-compose files scattered across half a dozen Proxmox VMs. It works — but every update is the same ritual: notice an image is stale, SSH into the right VM, edit the compose file, docker compose pull && up -d, hope. Multiply by the media stack, the auth stack, the monitoring stack, and “keeping things current” quietly becomes a part-time job I mostly don’t do.

The pattern that finally got me to move is GitOps with automated dependency PRs: a Git repo describes everything that runs, a controller makes the cluster match the repo, and a bot opens a pull request whenever anything I run has an update. Maintenance becomes reading changelogs and clicking merge. That last part — Renovate — is the killer feature, and this whole post is really the supporting machinery for it.

This is part 1: the foundation. One afternoon, from empty VM slot to a working loop.

flowchart TB
ME["🧑‍💻 me"] -- "git push / merge PR" --> REPO["📦 Git repo<br/>(single source of truth)"]
BOT["🤖 Renovate"] -- "update PRs" --> REPO
REPO -- "pull every minute" --> FLUX["🔄 Flux<br/>(in the cluster)"]
FLUX -- "reconcile" --> K8S["☸️ k3s cluster<br/>reality = repo"]

The stack: k3s (lightweight Kubernetes), Flux (the reconciler), SOPS + age (encrypted secrets in the repo), and Renovate (the PR bot). The community has built a deep groove around exactly this combination — repos like ahgraber’s homelab-gitops-k3s and the whole kubesearch.dev index are living reference material.

Finding room on the hypervisor

Everything lives on one Minisforum MS-01 running Proxmox, and RAM is the binding constraint: 94 GiB, of which ~19 GiB was actually free. No room for enthusiasm. The eligibility audit was short:

  • ❌ The media VM, the auth/DNS VM, Home Assistant OS, my AI box — all critical or appliances.
  • ✅ A stopped Windows 11 test VM holding an 8 GiB allocation hostage.

So the Windows VM died (qm destroy 109 --purge), its installer ISOs went with it, and a new VM took its budget: 4 vCPU, 12 GiB RAM, 60 GB disk. Single node, on purpose — multi-node on one physical box is several VMs pretending to be a cluster, paying real overhead for protection against nothing. The GitOps loop works identically on one node, and the repo doesn’t care how many nodes exist if I ever add a second machine.

A VM that builds itself

No installer ISO, no clicking through Ubuntu setup. The VM is built from a cloud image — a pre-installed generic Ubuntu disk — plus a tiny virtual CD-ROM that cloud-init reads on first boot:

flowchart TB
IMG["💿 ubuntu cloud image<br/>(generic, pre-installed)"] --> VM["🖥️ VM 108 'k3s'<br/>4 vCPU / 12 GiB / 60 GB"]
CI["📀 cloud-init ISO<br/>user + SSH key + DHCP<br/>+ qemu-guest-agent"] --> VM
VM -- "first boot ≈ 50 s" --> READY["✅ SSH-able server<br/>filesystem auto-grown"]

Five qm commands on the Proxmox host, fifty seconds of boot, and there’s a personalized server I never typed a password into. The point isn’t the speed — it’s that the VM is now disposable. I can rebuild it identically from a script, which is exactly the mindset Kubernetes wants you in.

k3s: Kubernetes as one binary

Full Kubernetes is half a dozen separately-installed services. k3s packages the lot into one binary with SQLite instead of etcd, which is why the install is genuinely this:

Terminal window
curl -sfL https://get.k3s.io | sudo sh -

Ninety seconds later, a Ready node. The classic first lesson came right after — deploy a throwaway app, then delete its pod and watch the control loop replace it before you can finish typing kubectl get pods. Desired state: 1, actual: 0, fixed. Nothing in docker-compose behaves like this, and it’s the same loop that will later roll out every update I merge.

One deliberate deviation: k3s bundles the Traefik ingress controller, and I disabled it. Not because it’s bad — because anything the k3s installer manages sits outside the Git repo, invisible to Renovate. The purist move is to remove it and reinstall the exact same software through Flux, version-pinned in the repo. (Found the sharp edge here too: disable: traefik in k3s’s config stops it being deployed, but doesn’t garbage-collect what’s already running — the leftover HelmChart records needed deleting by hand.)

Flux: the repo becomes the steering wheel

flux bootstrap github does something pleasingly recursive: it commits Flux’s own manifests into the repo, installs the controllers in the cluster, creates a deploy key, and from that moment Flux manages itself from Git — even Flux upgrades arrive as Renovate PRs later.

flowchart TB
subgraph repo["📦 mj-moor/homelab (private)"]
FS["clusters/homelab/flux-system/<br/>flux's own manifests"]
INF["infrastructure/traefik/<br/>HelmRepository + HelmRelease"]
end
subgraph cluster["☸️ k3s VM"]
SC["🔄 source-controller<br/>pulls the repo"]
KC["⚙️ kustomize-controller<br/>applies + prunes + decrypts"]
HC["📦 helm-controller<br/>installs charts"]
end
FS -.-> SC
SC --> KC
KC --> HC
HC --> T["🌐 traefik pod<br/>:80 / :443"]

After bootstrap, deploying the ingress controller properly took four small YAML files: a namespace, a HelmRepository (“charts live at this URL”), a HelmRelease (“install traefik, version 40.3.0, these values”), and a kustomize index. Commit, push, and a minute later there’s a reverse proxy answering on the VM’s IP. No helm install, no SSH. git log is now my deployment history.

Deletion works the same way and it’s worth internalizing: removing a file from the repo removes the thing from the cluster (prune: true). You delete by commit, with an audit trail.

SOPS + age: secrets that can live in Git

The repo will eventually need API tokens and passwords, and Git history is forever — so plaintext is never an option. The trick is asymmetric encryption with two keys doing two jobs:

flowchart TB
PT["📝 secret.sops.yaml<br/>plaintext, local only"] -- "sops encrypt<br/>🔓 public key" --> CT["🔒 ciphertext value,<br/>readable YAML structure"]
CT -- "git push" --> GH["📦 repo<br/>(safe to store)"]
GH -- "flux pulls" --> KC["⚙️ kustomize-controller<br/>🔑 private key in cluster"]
KC -- "decrypt at apply time" --> SEC["☸️ k8s Secret"]
SEC -- "env / mount" --> POD["📦 app pod"]

SOPS encrypts only the values — the YAML structure, field names, everything else stays readable, so diffs still make sense. The age private key exists in exactly two places: my workstation and a cluster secret. The repo only ever sees ciphertext. I proved the chain end-to-end with a dummy secret: encrypted → committed → decrypted by Flux in the cluster → deleted by removing the file.

Honest footnote: the write-then-encrypt dance is human-error-prone, and I know it. The endgame is the External Secrets Operator with a 1Password backend — secrets never in Git at all, just references — with SOPS demoted to bootstrap-only duty. That’s a later part.

Renovate: the payoff, without the third party

The hosted Renovate app is the easy route, but it means a third party’s servers continuously reading (and writing to) my private repo. The middle path costs nothing and adds no new trust: Renovate runs as a scheduled GitHub Actions workflow in my own repo, authenticated with a fine-grained PAT scoped to that one repo. GitHub already hosts the code, so nobody new learns anything.

flowchart TB
CRON["⏰ every 3 hours<br/>(GitHub Actions, free tier)"] --> RUN["🤖 renovate run<br/>≈ 66 s"]
RUN --> SCAN["🔍 scan repo:<br/>HelmReleases · image tags ·<br/>flux itself · the action itself"]
SCAN --> UPD{"update<br/>available?"}
UPD -- "yes" --> PR["📬 pull request<br/>with changelog"]
UPD -- "no" --> DASH["📋 Dependency Dashboard<br/>issue stays current"]
PR -- "I read + merge" --> FLUX["🔄 flux deploys it"]

The first run took 66 seconds and produced a Dependency Dashboard issue that proved it understood everything: the traefik chart version, Flux’s controllers, and — my favorite detail — renovatebot/github-action itself. The bot watches its own updater. At every-3-hours it burns ~530 of the 2,000 free Actions minutes per month. Cost: €0.

Where this lands

One afternoon, and the loop is closed:

  • 🖥️ a disposable VM built by cloud-init
  • ☸️ single-node k3s with nothing hand-installed on it
  • 🔄 Flux reconciling a private repo every minute, managing itself included
  • 🔒 encrypted secrets proven end-to-end
  • 🤖 Renovate opening PRs with zero third-party repo access

Nothing I run day-to-day has migrated yet — the media stack, auth, monitoring all still live their docker-compose lives, untouched. That’s deliberate: the foundation came first, and a hybrid end-state is fine. Part 2 is where it gets visible: local DNS and the first real Ingress routes, cert-manager for TLS, External Secrets with 1Password, and the monitoring stack as the first real tenant.

The repo is private for now — but everything in it is built as if it were public. That’s rather the point.