Homelab as Production/Part 3 of 16
GitOps from Day One: Bootstrapping FluxCD
Why you set up GitOps before deploying any applications
Here is a mistake almost everyone makes: they get their Kubernetes cluster running, open a terminal, and start typing kubectl apply -f. It feels productive. Things appear. Then, six months later, nobody, including the person who ran those commands, can reconstruct exactly what is running or why. The cluster has drifted from any written record. Debugging means reading live cluster state and hoping it matches the intent. Rolling back means hoping your shell history is intact.
I didn’t do this. Not because I’m disciplined by nature, but because the discipline was baked into the workflow from the start: no application goes into the cluster unless it goes through Git first.
Getting FluxCD to work required understanding layers I hadn’t had to think about before: project structure, dependency ordering, the distinction between a “Flux file” and an application manifest. I tried to deploy it myself before but always got stuck on the details. This was where having an AI co-pilot stopped being a curiosity and started being load-bearing.
This post covers how I bootstrapped FluxCD before deploying a single application. It covers the prerequisite step (migrating Terraform remote state to PostgreSQL), the architectural decision about where Flux lives in the repository, the dependency chain that ensures things deploy in the right order, and a couple of real bugs that taught me how Flux actually works under pressure.
The Prerequisite: Terraform Remote State on PostgreSQL
Before FluxCD could be bootstrapped, there was a chicken-and-egg problem to solve.
Flux reads from a Git repository. To bootstrap Flux, I needed a cluster. To provision the cluster with Terraform, I needed a state backend that would survive beyond my local machine. Local .tfstate files are a liability in any collaborative or long-lived setup. Lose the laptop, lose the state.
The solution was to migrate Terraform state to a PostgreSQL backend before touching Flux at all. My homelab already had a PostgreSQL VM (provisioned in the previous session), so the migration was straightforward:
# infrastructure/backend.tf
terraform {
backend "pg" {
conn_str = "postgres://terraform:password@10.0.0.44/terraform_state"
}
}
Migrating existing local state to the remote backend takes one command:
terraform init -migrate-state
Terraform detects the backend change, asks for confirmation, and copies all existing state to PostgreSQL. From that point forward, terraform plan and terraform apply read and write from the database, not the filesystem.
This matters for GitOps because it means my infrastructure state is now as durable as my PostgreSQL cluster. I can run Terraform from any machine, including CI runners, without copying state files around.
With remote state in place, Flux bootstrapping could proceed.
The Repository Structure Decision
Before running flux bootstrap, there’s a decision to make that shapes everything downstream: where does Flux live in the repository?
Three options exist:
| Option | Location | Problem |
|---|---|---|
| A | infrastructure/flux/ |
Mixes Terraform (VM provisioning) with Flux (K8s orchestration): different tools, different layers |
| B | kubernetes/flux/ |
Circular. Flux lives inside the directory it manages; path references become awkward |
| C | clusters/homelab/ |
Clean separation: each top-level directory has a single responsibility |
Option C follows the official Flux community pattern and the flux2-kustomize-helm-example reference repository. Claude surfaced this recommendation when I described the repo layout, explaining the trade-offs with enough specificity that Option C was clearly correct.
The key insight is that the three directories serve three completely different concerns:
infrastructure/answers WHERE: Terraform provisions VMs, networks, and Proxmox resourceskubernetes/answers WHAT: Kubernetes manifests describe desired cluster stateclusters/homelab/answers HOW: Flux Kustomizations control what gets deployed, in what order, with what intervals
graph LR
subgraph WHERE["WHERE — infrastructure/"]
TF["Terraform<br/>VMs · Networks<br/>Cloud-init · Proxmox"]
end
subgraph WHAT["WHAT — kubernetes/"]
K8S["K8s Manifests<br/>HelmReleases<br/>Namespaces · ConfigMaps"]
end
subgraph HOW["HOW — clusters/homelab/"]
FLUX["Flux Kustomizations<br/>Ordering · Dependencies<br/>Intervals · Pruning"]
end
TF -->|"provisions cluster"| K8S
FLUX -->|"reconciles"| K8S
style WHERE fill:#f3f4f6,stroke:#9ca3af
style WHAT fill:#eff6ff,stroke:#93c5fd
style HOW fill:#f0fdf4,stroke:#86efac
Each layer can be understood in isolation. A Terraform operator can work in infrastructure/ without knowing anything about Flux. An application developer can work in kubernetes/apps/ without knowing how Flux Kustomizations are structured. A platform engineer working in clusters/homelab/ controls deployment order and scheduling without touching application manifests.
This separation also scales naturally. Even though there’s currently one cluster, the layout is ready for a second:
clusters/
├── homelab/ ← Production (current)
└── staging/ ← Future staging cluster (same kubernetes/ manifests, different config)
Same kubernetes/ manifests, different clusters/ entry point. No restructuring required later.
The Bootstrap Command
With the directory structure decided, bootstrapping Flux is a single command:
flux bootstrap github \
--owner=<your-github-org> \
--repository=homelab-iac \
--branch=main \
--path=clusters/homelab \
--personal
See the Flux bootstrap for GitHub documentation for full options and token scoping requirements. The GitHub PAT used here goes into 1Password immediately. It’s not left in environment variables or shell history.
What this command does:
- Installs the Flux controllers (source-controller, kustomize-controller, helm-controller, notification-controller) into the
flux-systemnamespace - Creates a
GitRepositoryresource pointing to the GitHub repository - Creates a root
Kustomizationthat watchesclusters/homelab/ - Commits the generated manifests (
gotk-components.yaml,gotk-sync.yaml,kustomization.yaml) toclusters/homelab/flux-system/in the repository
After bootstrap, the clusters/homelab/ directory looks like this:
clusters/homelab/
├── flux-system/
│ ├── gotk-components.yaml ← Flux CRDs + controller Deployments (~560KB, auto-managed)
│ ├── gotk-sync.yaml ← GitRepository + root Kustomization
│ └── kustomization.yaml ← Kustomize config for flux-system resources
├── platform.yaml ← Kustomization → ./kubernetes/platform/{controllers,configs}
├── apps.yaml ← Kustomization → ./kubernetes/apps
└── monitoring.yaml ← Kustomization → ./kubernetes/platform/monitoring/...
The flux-system/ directory is managed by Flux itself. The other files, platform.yaml, apps.yaml, monitoring.yaml, are the ones I write. They’re Flux Kustomization resources that point Flux at specific directories in the repository.
The Dependency Chain
The most important design choice after bootstrap is the dependency chain between Kustomizations. Flux reconciles resources concurrently by default, which would mean cert-manager CRDs might not exist when a ClusterIssuer tries to apply, or the External Secrets Operator might not be running when an ExternalSecret tries to sync.
The solution is explicit dependsOn declarations combined with wait: true. Flux won’t reconcile a downstream Kustomization until the upstream one reports all resources as Ready.
flowchart TD
FS["flux-system<br/>(self-managing)"]
PC["platform-controllers<br/>cert-manager · ingress-nginx · ESO · MetalLB · Longhorn"]
PCfg["platform-configs<br/>ClusterIssuers · ClusterSecretStore · MetalLB pools"]
APPS["apps<br/>Application HelmReleases"]
MC["monitoring-controllers<br/>kube-prometheus-stack · Loki · Promtail"]
MCfg["monitoring-configs<br/>Dashboards · PodMonitors · alerts"]
FS --> PC
PC -->|"wait: true — CRDs must exist"| PCfg
PCfg -->|"wait: true — ingress + certs + secrets ready"| APPS
FS --> MC
MC --> MCfg
style FS fill:#6b7280,color:#fff
style PC fill:#2563eb,color:#fff
style PCfg fill:#1d4ed8,color:#fff
style APPS fill:#16a34a,color:#fff
style MC fill:#7c3aed,color:#fff
style MCfg fill:#6d28d9,color:#fff
The chain works as follows:
flux-systembootstraps itself and starts watching the Git repositoryplatform-controllersdeploys cert-manager, ingress-nginx, MetalLB, the External Secrets Operator, Longhorn, and the Tailscale operator.wait: truemeans Flux blocks here until all controller pods report Ready and all CRDs are registered.platform-configsdeploysClusterIssuers,ClusterSecretStore, and MetalLB IP pools. These are resources that consume the CRDs created in the previous step, anddependsOn: platform-controllersenforces the ordering.appsdeploys application workloads.dependsOn: platform-configsensures ingress, TLS issuance, and secret syncing are all available before any application tries to use them.monitoring-controllersandmonitoring-configsrun in parallel with the platform chain but follow the same internal ordering pattern.
The Key YAML: platform.yaml
Here is the actual clusters/homelab/platform.yaml file that encodes this chain:
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: platform-controllers
namespace: flux-system
spec:
interval: 1h
retryInterval: 1m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/platform/controllers
prune: true
wait: true
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: platform-configs
namespace: flux-system
spec:
dependsOn:
- name: platform-controllers
interval: 1h
retryInterval: 1m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/platform/configs
prune: true
Notice that platform-configs has no wait: true. It depends on platform-controllers being fully Ready before it starts, but downstream Kustomizations (apps) enforce their own ordering separately via dependsOn: platform-configs.
The path values are relative to the repository root, not to the Kustomization file itself. This is because the GitRepository source clones the entire repository, and all paths resolve from the clone root. A common early mistake is writing paths relative to clusters/homelab/, which produces “path not found” errors from Flux.
All path values in Flux Kustomization spec.path fields resolve from the repository root.
AI Collaboration Note
AI Collaboration Note What Claude contributed: The clearest contribution here was the repository structure recommendation. When I described having Terraform in
infrastructure/, Kubernetes manifests inkubernetes/, and being unsure where to put Flux, Claude walked through all three options with concrete trade-offs. It specifically called out the circular reference problem with Option B and the tooling-layer mixing problem with Option A. It also surfaced the community-standard pattern and linked to the official Flux repository structure guide, which I hadn’t read carefully enough.The second contribution was catching a real bug. When reviewing reconciliation status after bootstrap, I noticed one Kustomization wasn’t applying its second resource. Claude asked to see the raw file contents, noticed that the
kustomization.yamlfile had two YAML documents separated by---where the second document referenced a resource path that didn’t exist. Flux’s kustomize-controller parsed the first document successfully and silently ignored the second. No error, no warning, just a missing resource. The fix was a one-line path correction, but finding it required reading the file carefully rather than trusting the reconciliation status output.Where it needed correction: The initial bootstrap command Claude suggested included
--components-extra=image-reflector-controller,image-automation-controllerfor automated image updates. This sounded useful but added controllers I didn’t need yet, and image automation requires additional configuration to avoid unintended tag updates. I removed those flags and bootstrapped with the four standard controllers only.Prompt that worked:
"I have infrastructure/ for Terraform, kubernetes/ for manifests, and I need to decide where to put the Flux entry point. Walk me through the options with trade-offs — I want the community-standard approach."Using a different AI tool? The structure decision prompt works with any LLM that has knowledge of FluxCD. The key is to describe your specific directory layout first, then ask for trade-off analysis rather than “what is the best option.” The latter tends to produce generic answers, while the former forces the model to reason about your actual constraints.
Flux Kustomization Atomicity
One property of Flux Kustomizations that caught me off guard is that reconciliation is atomic: if any single resource in a Kustomization fails server-side validation or apply, the entire Kustomization is blocked. No resources apply, even perfectly valid ones.
This has a direct consequence for how you structure your manifests. A single broken application can block every other application in the apps Kustomization from receiving updates. I hit this when a PVC manifest had an immutable field change. After the PVC was bound to a PV, the dynamically assigned volumeName became part of the spec and couldn’t be changed in-place. Flux’s dry-run detected the immutable field change and refused to apply anything in the apps Kustomization until the conflict was resolved.
See the Flux Kustomization API documentation for the full behavior specification.
Common triggers for atomicity blocks:
| Trigger | Symptom |
|---|---|
PVC with volumeName mismatch after binding |
Dry-run fails with “spec is immutable” |
| PV with immutable field change (e.g., NFS path) | Apply fails with “field is immutable” |
| CRD not yet registered | “no matches for kind X” |
| Schema validation failure | Validation error before apply |
The practical implication for repository structure: keep things that can fail independently in separate Kustomizations. Platform controllers, platform configs, and applications are already separated. But within the apps Kustomization, all applications share the same atomic boundary. One broken app blocks all app updates until it’s fixed.
The mitigation, when you need to unblock quickly, is to temporarily comment out the broken resource from its kustomization.yaml file. Flux prunes the resource from the cluster and reconciliation proceeds. This is a legitimate escape hatch, not a workaround to be ashamed of. It’s faster than debugging under pressure, and the Git history records exactly when and why the resource was temporarily disabled.
When Flux gets stuck: recovery steps
When a Kustomization stops reconciling and you need to diagnose or unblock it quickly, these three commands cover most situations:
1. Find the blocking resource:
kubectl describe kustomization apps -n flux-systemThe
Status.Conditionssection will show the last reconciliation error and which resource triggered it. This is almost always faster than scanning logs.2. Force a reconciliation after fixing the resource:
flux reconcile kustomization apps --with-sourceThe
--with-sourceflag forces Flux to re-fetch the Git source before reconciling, ensuring it picks up your fix rather than replaying a cached version.3. Temporarily suspend a broken Kustomization to unblock others:
flux suspend kustomization apps # fix the broken resource flux resume kustomization appsSuspending stops Flux from trying to reconcile the Kustomization entirely. Use this when one broken Kustomization is blocking others via
dependsOn, and you need to let the healthy ones proceed while you debug.These three commands belong in your runbook. If they’re not documented, you’ll be googling them at 11pm, and that’s exactly when you don’t want to be searching.
Important: If you’ve applied changes directly with
kubectl applyorkubectl edit(bypassing GitOps), Flux will reconcile them away on the next sync, reverting your change. If you need a manual change to persist, either commit it to the repository first, or runflux suspend kustomization <name>to pause reconciliation while you work, then commit and resume.
Lessons
-
GitOps before applications, not after. Retrofitting GitOps onto a running cluster means reconciling drift between actual state and desired state across every resource you’ve already applied. Starting with Flux means your first application deployment is also your first GitOps deployment. The workflow is established from commit one.
-
Migrate Terraform remote state before bootstrapping Flux. They’re both infrastructure concerns, and you don’t want to bootstrap a cluster with local state that’s one laptop failure away from being unrecoverable. PostgreSQL backend is a natural choice if you already have a PostgreSQL VM.
-
The three-directory split (
infrastructure/,kubernetes/,clusters/) pays off when you delegate. When you hand a session scope to an AI, or a colleague, the directory structure tells them exactly where to look and what not to touch. Separation of concerns is also separation of context. -
dependsOnpluswait: trueis not optional. Without it, Flux applies Kustomizations concurrently, and the race conditions are subtle. Resources apply successfully individually but fail at runtime because a CRD isn’t yet registered or a controller isn’t yet healthy. The dependency chain encodes the ordering that would otherwise live only in your head. -
Flux Kustomization atomicity shapes your manifest structure. Know that one failing resource blocks the entire Kustomization. Group resources so that failures are isolated to the smallest possible blast radius. Keep the escape hatch, commenting out broken resources, in mind when you’re debugging under pressure.
Next: Post 4 — The Platform Layer Nobody Talks About — MetalLB, ingress-nginx, cert-manager, and NFS: the boring infrastructure every application depends on.