
Once the cluster is bootstrapped, nothing in it is deployed by hand but the nodes’ own config, and nothing outside the lab holds a credential into it. Flux, running inside the cluster, polls my git repository every minute and makes the cluster match main: twenty apps, about sixty pods, on two Talos Linux VMs.
This post explains why the lab runs Flux on Talos rather than Docker Compose hosts or k3s, then how the reconcile is ordered, what a commit goes through, and what happens when one breaks.
How I run my homelab in 2026 has the big picture: one Proxmox host, network tiers, Tailscale. This one zooms in on the deploy.
What the deploy answers to
The lab has a service contract, and the deploy is judged against it: lose at most 24 hours of data, recover in hours from a software problem, no availability promise, and an audience of one, me. That is why the cluster has one control plane and no high availability.
Two rules from the rest of the lab shape the deploy:
- Nothing pushes into the lab. Flux runs in the cluster and pulls the repository, so nothing outside holds a credential into the lab. CI, GitHub Actions on a runner of mine in the lab’s untrusted tier, validates commits and reaches nothing else in the lab.
- Secrets stay in git, encrypted with SOPS. The repository plus one key rebuilds the setup; the data comes back from the backups.
Pulling moves the door rather than removing it: whoever can push to main deploys into the lab within a minute. Only my GitHub account can push. The deploy keys Flux uses are read-only, and the only other writers are the CI job that commits image digests and Renovate’s auto-merge. A private repository on GitHub’s free plan gets no branch protection, so nothing forces a commit through CI first, and the commits aren’t signed. Images are pinned by digest, not checked against a signature. The contract accepts the consequence: a breakage is an alert and a revert.
Why Talos and Flux, not Docker Compose or k3s
The lab’s decision log weighs each alternative in a sentence or two, and I quote it.
Docker Compose hosts each bring their own deploy agent, socket proxy and log agent, and by default their containers leave under the host’s one address, so a firewall can’t tell them apart. The cluster has one deploy loop, one route table and one isolation model: a namespace, its tier and its network policies, enforced per pod.
k3s needs an OS under it: “k3s needs a Debian under it, which Ansible would keep patched, rebooted and hardened, and it bundles a CNI, a DNS and a load balancer the repo does not describe. Talos is an OS with no SSH and nothing to drift, and the plumbing is Flux’s.” On Talos, the network (Cilium), the DNS (CoreDNS), the ingress (Traefik) and the certificates (cert-manager) are all charts in the repository, reconciled like any app.
Argo CD “needs a plugin to read SOPS and brings a UI, a Redis and its own login for one person who works from a terminal.” Flux decrypts SOPS on its own.
Ansible applying the manifests “would rebuild by hand what Flux does alone: pruning, ordering by health, Helm’s lifecycle”, and putting back what someone changed by hand. Ansible still has a job in the lab, for the machines outside the cluster.
Terraform for the cluster, with the Talos provider, keeps the cluster’s certificate authorities, tokens and an admin kubeconfig in a plaintext state file, where the repository keeps them only sealed by SOPS. It doesn’t upgrade nodes either, so it would be a second tool for one job.
The decision log also says when to reopen it: if Talos stops shipping a driver a passed-through device needs, or Flux loses SOPS.
Two nodes, split by trust
The two nodes are both VMs on the same Proxmox host, so they buy no redundancy, and aren’t meant to. They are drawn on trust: what handles traffic nobody vouches for, like downloads through a VPN or a remote browser, runs on the untrusted node, so a process that escapes one of those apps finds no single sign-on, no database and no decryption key. One cluster spans both tiers, with each node tainted for its own, rather than one cluster per tier: two control planes for one person would be too many.
What the cluster costs here
Across the cluster, the plumbing (the control plane, Cilium, CoreDNS, Flux, Traefik and cert-manager) uses about 2 GiB of memory and a tenth of a core. The whole cluster, apps included, uses about 9.5 GiB and well under one core. Talos’s own services aren’t in those numbers: I don’t measure them separately.
No SSH doesn’t mean no way to look inside. talosctl reads a node’s services, logs and kernel messages through Talos’s API, the control-plane node’s service and kernel logs also land in Loki, and just talos check compares a node’s running config with the repository.
Three steps that wait for each other

Flux reads one directory of the repository, kubernetes/, through a single GitRepository that checks main every minute:
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: flux-system
namespace: flux-system
spec:
interval: 1m
url: ssh://[email protected]/<repository>.git
ref:
branch: main
# …
# Replaces Flux's default exclusions, which drop *.png and the like: the cluster's tree,
# binaries included, and nothing else of the repository.
ignore: |
/*
!/kubernetes/
Flux fetches the repository over SSH with a read-only deploy key. A Flux Kustomization is one directory Flux applies and keeps applied, not to be confused with the kustomization.yaml file in each directory, which lists what the directory holds. The first one, flux-system, applies kubernetes/flux/: Flux itself, and the definitions of three more steps. Each of those depends on the one before and waits until it is healthy, not just applied:
- platform: Cilium, CoreDNS, Traefik, cert-manager, the Gateway API definitions, storage, the device plugin and node-exporter.
- platform-config: the resources whose type or webhook comes from a chart in the platform. Applied with the platform, their first dry run would fail, because the type doesn’t exist until the chart installs it.
- apps: twenty apps, one namespace each.
The last one, with the name of its key masked:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
dependsOn:
- name: platform-config
interval: 10m
timeout: 5m
path: ./kubernetes/apps
prune: true
wait: true
sourceRef:
kind: GitRepository
name: flux-system
decryption:
provider: sops
secretRef:
name: <age key secret>
# …
A broken platform holds the apps back instead of leaving them half applied. interval: 10m re-applies each step every ten minutes even without a commit, so a change made by hand is put back. prune: true deletes from the cluster whatever is deleted from git. The one exception is a persistent volume of the local class, which stays, with its directory on the node.
An app is a namespace and a directory, and the directory holds everything it needs:
kubernetes/apps/open-webui/
├── deployment.yaml
├── httproute.yaml
├── kustomization.yaml
├── namespace.yaml
├── pvc.yaml
├── secret.sops.yaml
└── service.yaml
There is one chicken-and-egg problem. Flux can’t run before the cluster has a network and a DNS, so the bootstrap installs Cilium and CoreDNS once with Helm, from the same values files, and Flux adopts them from there.
Flux’s own manifests are generated, never edited, and only move when Flux itself is upgraded. Flux runs 2.9.6 today.
Secrets are decrypted by Flux
An app’s secret lives next to its manifests, as secret.sops.yaml, with only the values encrypted, to two age keys: mine and the cluster’s. The decryption block above is on every step, so Flux turns the file into a Kubernetes Secret when it applies the app. The untrusted node holds no key at all.
Rotating a secret is edit, commit, push, then a restart of the app that reads it: a changed Secret restarts nothing on its own.
What a commit goes through

The commit comes from me, from a coding agent working in a session I supervise, instructed to commit only when I ask, or from Renovate.
CI runs on every push to main and every pull request, alongside the deploy rather than before it: on a commit pushed straight to main, Flux doesn’t wait for CI. Only Renovate’s pull requests wait for a green CI before they reach main. It runs on a runner in the lab’s untrusted tier with no secret and no route into the lab. It builds every directory Flux reconciles and validates the result against its schema, checks the repository’s own rules (every namespace has exactly one tier, no toleration written in an app’s manifest, no host network, port or PID outside kube-system, every image pinned by digest, every container probed or saying why), validates the Talos configs with the same talosctl version the nodes run, and scans for secrets with gitleaks.
Renovate runs on weekends and opens one pull request per app, because an app’s directory is what Flux applies and what I revert. An app’s patch or digest bump merges itself once CI is green: a breakage is an alert and a revert, which the contract accepts. A major version waits until I have read the upstream notes. Every image is pinned by tag and digest, every chart by version. The Talos version is bumped as one group, the nodes’ installer, the talosctl I run and the one CI validates with, but the nodes themselves are upgraded by hand, not by Flux.
Images with no upstream are built by CI on main and pushed to a private registry, and their digest is committed back into the app, with the job’s own GitHub token. Flux then deploys that commit like any other: CI writes to GitHub, never into the lab.
When a reconcile fails
Flux retries at every interval. If a Flux object (a step, a Helm release or a source) stays not ready for ten minutes, Grafana mails me through Cloudflare’s Email Sending API. The rule reads Flux’s own readiness:
- uid: alert-flux-not-ready
title: Flux object not ready
for: 10m
labels: { severity: warning }
annotations:
summary: "Flux {{ $labels.customresource_kind }} {{ $labels.exported_namespace }}/{{ $labels.name }} is not ready: kubectl get kustomizations,helmreleases -A"
data:
- refId: A
model:
expr: gotk_resource_info{ready!="True", suspended!="true"}
# …
A suspended object is held on purpose, so it doesn’t count. Since the apps step waits for its workloads to be healthy, a pod that never starts counts as a failed deploy too. A container that later stops answering its liveness probe is restarted by Kubernetes; the probe is meant to test the process itself, never a dependency.
To undo, git revert and push. To take the wheel back, I suspend the step in question. What I never do is kubectl apply by hand: the next reconcile overwrites it, or prunes it.
The price of a deploy that runs itself
A bad commit reaches the machines on its own too: an app change is live within a minute, and on a direct push nothing checks a value before it lands, only after, when it fails. My safety net is that undoing it is the same gesture, git revert and a push.
Cartoon illustrations generated with Gemini 3.1 Flash Image, through OpenRouter.
Icons in the diagrams: Simple Icons (CC0) and Lucide (ISC).