A Homelab GitOps Loop With Flux and Gitea

The whole reconcile loop, kept inside your own four walls

Contents

For years my cluster was configured the way most homelabs are configured: by me, at midnight, running kubectl apply -f against whatever YAML happened to be open in a tab. It worked, in the sense that the lights stayed on. But if you had asked me what was actually running in that cluster and why, my honest answer would have been a shrug and a directory of files with names like ingress-final-v2-REAL.yaml. There was no record of who changed what, no way to roll back a bad edit except memory, and no plan for the day the boot drive died beyond “reinstall everything and try to remember.”

GitOps fixes that, and it does so with an idea that sounds almost too tidy to be useful: the desired state of your cluster lives in Git, and a controller running inside the cluster continuously reconciles the real state to match. You stop applying changes by hand. You commit them. The controller notices and does the work. That single move turns Git into three things at once — an audit log of every change with an author and a timestamp, a rollback mechanism that is just git revert, and a disaster-recovery source you can point a fresh cluster at to rebuild it.

The catch, for me, was that most GitOps tutorials assume your source of truth lives on GitHub. In a homelab that grates. The whole point of the exercise is self-reliance, and handing the canonical description of my cluster to a SaaS I do not control undercuts it. So this is the version I actually run: Gitea holds the manifests, Flux v2 reconciles the cluster, and the entire loop stays inside my own four walls.

Why Gitea and Flux, specifically

Advertisement

Gitea is a lightweight self-hosted Git server — a single Go binary, or a small container if you prefer, happy on hardware that would make GitLab weep. It gives you the Git hosting, the SSH endpoint, the web UI for browsing history and diffs, and webhooks. On a homelab that already runs a dozen other things, its resource footprint disappears into the noise. Mine lives at git.mylab.local and has never once been the reason something fell over.

Flux is the other half. Flux v2 is properly called the GitOps Toolkit, and the naming matters because it tells you the shape of the thing: a set of small controllers, each with one job, that you compose into a loop. The pieces you care about:

  • source-controller fetches sources — Git repositories, Helm repositories, OCI artifacts — and makes their contents available inside the cluster. It is the thing that talks to Gitea over SSH.
  • kustomize-controller takes a source and a path, builds the Kustomize overlay (or plain YAML) it finds there, and applies it. This is the workhorse that actually changes your cluster.
  • helm-controller reconciles HelmRelease objects, so Helm charts get the same declarative treatment as everything else.
  • notification-controller handles the edges: inbound webhooks that trigger reconciliation, and outbound alerts to Slack, Discord or a webhook of your choosing.

All four land in a namespace called flux-system. The reason I reach for Flux over the alternatives in a homelab is that it is pull-based and cluster-native. There is no server component to run, no dashboard to secure, no separate control plane. The controllers sit inside the cluster and pull from Git on an interval. If my laptop is off, or the whole house loses internet, the cluster carries on reconciling against the Gitea instance sitting on the same switch.

The Kubernetes underneath all this, in my case, is K3s. If you are following along and have not sorted out where your app data actually lives, read Persistent volumes in K3s without losing your data first, because GitOps will happily reconcile a Deployment whose storage you never thought through, and then you will learn about hostPath the hard way.

The bootstrap: Flux installs itself into your Git repo

The elegant trick at the heart of Flux is that it manages itself. You do not install Flux and then separately teach it about your repo. You run one bootstrap command, and Flux commits its own manifests into the repo, installs those manifests into the cluster, and then reconciles itself from that point on. Flux becomes just another thing in Git that Flux keeps in sync. Upgrading Flux later is a commit.

First, a piece of preparation that trips people up: create the target repository in Gitea before you bootstrap. I have an org called homelab and a repo called cluster. Then point the command at it over SSH:

1
2
3
4
5
6
7
8
9
# Create an empty repo in Gitea first: homelab/cluster

export GITEA_SSH="ssh://[email protected]:22/homelab/cluster.git"

flux bootstrap git \
  --url="${GITEA_SSH}" \
  --branch=main \
  --path=clusters/home \
  --private-key-file=$HOME/.ssh/flux_ed25519

The --path=clusters/home argument tells Flux where in the repo to keep its own manifests and where to look for the top-level Kustomizations that describe this particular cluster. That path prefix is what lets one repo serve several clusters later — a clusters/home and a clusters/edge can coexist without stepping on each other. The --private-key-file is an SSH key that Flux uses as a deploy key; bootstrap will register it in Gitea with write access, because it needs to commit its manifests back.

Once bootstrap finishes, two things exist that you want to understand, because everything else is built from them. The first is a GitRepository, which is a source — it tells source-controller where to fetch from and how often. The second is a Kustomization, which tells kustomize-controller what to build from that source and apply. Here is what a hand-written pair looks like once you start adding your own workloads on top of the bootstrap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: homelab-cluster
  namespace: flux-system
spec:
  interval: 1m
  url: ssh://[email protected]:22/homelab/cluster.git
  ref:
    branch: main
  secretRef:
    name: flux-system          # SSH key + known_hosts, created at bootstrap
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  path: ./apps
  prune: true                  # deletions in Git are removed from the cluster
  sourceRef:
    kind: GitRepository
    name: homelab-cluster
  dependsOn:
    - name: infrastructure     # infra reconciles before apps

Two lines in there do most of the heavy lifting. prune: true is what makes Git authoritative: delete a manifest from the repo and kustomize-controller deletes the corresponding object from the cluster on the next reconcile. Without it, Git can add and update but never remove, and your cluster slowly fills with orphans. The dependsOn block is ordering — it holds the apps Kustomization until the infrastructure one has reconciled successfully, so your ingress controller and storage class exist before the apps that need them come up.

A repo layout that survives growth

Advertisement

The bootstrap gives you clusters/home/ with Flux’s own manifests inside. Everything you add should slot into a structure that still makes sense when you have forty workloads instead of four. The one I have settled on:

1
2
3
clusters/home/          # Flux's own manifests + top-level Kustomizations
infrastructure/         # ingress-nginx, cert-manager, storage, longhorn
apps/                   # the actual workloads

clusters/home/ holds two Flux Kustomization objects that I write by hand — one pointing at ./infrastructure, one at ./apps — with the dependsOn between them shown above. infrastructure/ is the plumbing every app assumes exists: the ingress controller, cert-manager for TLS, the storage layer. apps/ is the interesting stuff. Each app gets its own directory with a kustomization.yaml that lists its resources, which keeps individual services self-contained and easy to reason about.

The reason to separate infrastructure from apps is more than tidiness. When you rebuild a cluster from scratch, you need cert-manager and your storage class up and healthy before anything tries to claim a volume or request a certificate. Encoding that as an explicit dependsOn means the rebuild order is described in Git rather than living in your head, and a bare-metal recovery becomes: install K3s, run flux bootstrap git, wait. The infrastructure reconciles first, the apps follow, and the cluster reassembles itself.

Reconciliation is pull-based by default. Each GitRepository polls on its interval, each Kustomization builds and applies on its own interval, and drift gets corrected on that schedule whether or not anything changed in Git. That is the safety net — even a manual kubectl edit gets reverted on the next pass. For fast feedback on push, add a Gitea webhook pointing at notification-controller. Configure a Receiver in the cluster, expose it, and set Gitea to call it on push events; reconciliation then fires within a second or two of your commit landing instead of waiting out the interval. The interval remains as the fallback for when the webhook misfires.

Secrets, without committing them in the clear

The obvious objection to putting your whole cluster in Git is secrets. You cannot commit a database password to a repo, self-hosted or not, and leave it sitting there in plaintext. Flux’s answer is SOPS: kustomize-controller can decrypt SOPS-encrypted manifests at apply time using an age key held as a secret in the flux-system namespace. You encrypt the values before they ever touch the repo, commit the ciphertext, and only the cluster holds the key to read it back. The workflow — generating the age key, wiring up .sops.yaml, and keeping the private key out of the repo — is exactly the one I walk through in secrets management with SOPS and age, and it drops into this loop with a decryption block on the Kustomization.

The other feed into this same Gitea instance is CI. Once your manifests live in Git, it is natural to want tests and image builds triggered off the same pushes, and Gitea has a built-in runner for exactly that — I covered it in Gitea Actions: self-hosted CI that boots in seconds. A tidy pattern is CI building and pushing an image, then bumping the image tag in the manifests repo, which Flux then reconciles. The whole chain, commit to running container, stays on hardware you own.

Troubleshooting: where the loop actually breaks

The bootstrap is where most first attempts die, and the culprit is almost always SSH. Flux needs the host key for git.mylab.local to verify the connection, and if it does not have one, source-controller refuses to fetch and the GitRepository sits in a not ready state complaining about host key verification. The flux-system secret that bootstrap creates should contain a known_hosts entry, but if you generated the SSH secret yourself, or your Gitea host key changed, you have to supply it. Generate it with ssh-keyscan git.mylab.local and make sure that value lands in the secret. Verify the source with flux get sources git — a healthy source reports the latest commit; a broken one tells you exactly which handshake failed.

The second bootstrap failure is the deploy key lacking write access. Bootstrap does not only read the repo — it commits Flux’s own manifests back into it. If the key you passed is registered in Gitea as read-only, the initial push fails partway through and you are left with a half-configured mess. In Gitea, deploy keys default to read-only; either tick “Enable Write Access” when adding the key, or let bootstrap create the key itself, which registers it with the right permissions. If in doubt, delete the half-made key and repo state and run bootstrap clean.

Once you are up, the commonest complaint is that a change you pushed did nothing. Work through it in order. Check the source is current: flux get sources git should show your latest commit hash. If the source is stale, the interval is probably too long and there is no webhook, so nothing pulled yet — force it with flux reconcile source git homelab-cluster, which triggers an immediate fetch. If the source is fresh but the cluster is unchanged, the Kustomization is the suspect: flux get kustomizations shows each one’s ready state and last-applied revision. A Kustomization stuck with an error usually means the Kustomize build failed — a bad path, a YAML typo, a resource referencing something that does not exist. The build error shows up in that command’s output, and the full detail is in the logs: kubectl -n flux-system logs deploy/kustomize-controller. I keep that log command in my shell history because it answers “why didn’t my change apply” nine times out of ten.

Drift is the subtle one. With prune: true, Git is the authority, and anything you created by hand that Flux thinks it owns is fair game for deletion. If you kubectl apply a resource into a namespace Flux manages and it happens to carry Flux’s labels, the next reconcile may prune it. The fix is discipline: things you want to keep go in Git, and manual experiments go in a namespace Flux does not manage. When you genuinely need to poke the cluster by hand — debugging, or an experiment you are not ready to commit — reach for flux suspend kustomization apps. That pauses reconciliation for that Kustomization so Flux stops fighting you. Do your work, then flux resume kustomization apps to hand control back to Git, and remember to actually commit whatever you changed, or resume will cheerfully undo it.

The honest verdict

GitOps with Flux and Gitea earns its keep, but it is not free. There is real setup cost, the bootstrap has sharp edges around SSH, and the day you introduce SOPS you take on key management as a permanent responsibility. For a two-node cluster running three services, it is probably more machinery than the problem deserves, and I would not blame anyone for staying with a folder of manifests and a shell script.

The moment it pays off is the moment your cluster becomes something you would be genuinely upset to lose. Once there is state you care about, a change history you want to trust, and a recovery story you would rather not test by improvising at 2 a.m., having the entire desired state described in a repo on your own Git server changes the character of the whole thing. My cluster stopped being a pet I nursed by hand and became a document I edit. The reconcile loop runs whether I am watching or not, the audit log writes itself, and rebuilding from bare metal is a bootstrap command and a cup of tea. For a homelabber who has already accepted that self-hosting is the hobby, that trade is an easy one to make.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.