Bootstrapping Flux
Flux manages itself from Git.
Here is a strange, useful trick. You run one command from your laptop. Instead of leaving a program running that you now have to babysit, it writes its own instructions into a shared notebook, then hires a copy of itself to follow that notebook, including the pages that describe the copy. From then on, the thing keeping your cluster in line with the notebook is itself kept in line by the notebook. That notebook is a Git repository (a version-controlled folder that records every change, who made it, and when). Planting Flux this way, so it manages itself, is called bootstrapping.
Flux is a set of controllers (small background programs that run inside your Kubernetes cluster, the system that schedules and runs your containers across a fleet of machines). Their whole job is to read the desired state of the cluster from Git and make reality match it, over and over. That practice, Git as the single written source of truth for what runs, is called GitOps. Bootstrapping is the first move: getting Flux installed and pointed at a Git path that holds, among other things, Flux itself. Neighboring lessons cover what the controllers do once they are running. This one is about how Flux gets planted, how it comes to manage itself, and what that means for the people defending the cluster.
Check The Building Before You Hand Over Keys
Before you hand someone the keys to a building, you check that the building exists and that the locks fit. Two things to confirm here. First, which cluster you are actually about to change, because a wrong kubeconfig context (the setting that tells your tools which cluster to talk to, and as whom) is exactly how people bootstrap Flux into staging when they meant production. Second, that the cluster is reachable and new enough to host Flux. The command flux check --pre reads your current context and compares the cluster's Kubernetes version against Flux's minimum. Run it, and only move on when it comes back clean.
# Which cluster am I about to change? Check this first, every time.kubectl config current-context# Is the API server reachable and new enough to host Flux?flux check --pre
production-eks► checking prerequisites✔ Kubernetes 1.29.4 >=1.28.0-0✔ prerequisites checks passed
Bootstrap needs one credential: a way to write to your Git host. On GitHub that is a personal access token (PAT, a secret string that stands in for your password when a script logs in). A classic token needs the repo scope. A fine-grained token needs Contents and Administration both set to read and write, because bootstrap may create the repository if it does not exist and will add a deploy key to it. Pass the token through an environment variable, not a command-line flag, so it never lands in your shell history or shows up in a process listing. Keep it short-lived. Once bootstrap finishes, Flux talks to Git using an SSH key (secure shell, the standard way to prove who you are to a Git host without typing a password), not this token, so you can revoke the PAT right after.
The One Command
flux bootstrap github does four jobs in one run. It installs the controllers into a namespace called flux-system (a namespace is a labeled partition inside the cluster, like a folder for related objects). It commits the controllers' manifests (YAML files, the plain-text format Kubernetes uses to describe objects) into your repository under the directory you pass to --path. It generates two small Flux objects, a GitRepository and a Kustomization, that point back at that same path. And it wires up access to the repository with a read-only SSH deploy key by default.
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxflux bootstrap github \--owner=my-org \--repository=fleet-infra \--branch=main \--path=clusters/production \--personal=false
► connecting to github.com► cloning branch "main" from Git repository "https://github.com/my-org/fleet-infra.git"✔ cloned repository► generating component manifests✔ generated component manifests✔ committed component manifests to "main" ("a1b2c3d")► pushing component manifests to "https://github.com/my-org/fleet-infra.git"► installing components in "flux-system" namespace✔ installed components✔ reconciled components► determining if source secret "flux-system/flux-system" exists► generating source secret✔ public key: ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ...✔ configured deploy key "flux-system-main-flux-system-./clusters/production" for "https://github.com/my-org/fleet-infra"► applying source secret "flux-system/flux-system"✔ reconciled source secret► generating sync manifests✔ generated sync manifests✔ committed sync manifests to "main" ("e5f6a7b")► pushing sync manifests to "https://github.com/my-org/fleet-infra.git"► applying sync manifests✔ reconciled sync configuration◎ waiting for GitRepository "flux-system/flux-system" to be reconciled✔ GitRepository reconciled successfully◎ waiting for Kustomization "flux-system/flux-system" to be reconciled✔ Kustomization reconciled successfully► confirming components are healthy✔ helm-controller: deployment ready✔ kustomize-controller: deployment ready✔ notification-controller: deployment ready✔ source-controller: deployment ready✔ all components are healthy
A few flags change what you get and how safe it is. --personal=false tells bootstrap the repository is owned by an organization, not your personal account, which is what you want for shared infrastructure. --path is the cluster's root of truth; give each cluster its own directory, like clusters/production and clusters/staging, so one repository can hold a whole fleet. If you plan to use image automation in a later lesson (where Flux updates image tags and pushes those changes back to Git), add its two controllers now with --components-extra=image-reflector-controller,image-automation-controller, and only then reach for --read-write-key, since pushing commits needs a writable key. Plain GitOps only reads from Git, so the read-only default is the one to keep. The command is idempotent (running it again changes nothing unless something actually differs); re-run it with a newer CLI to upgrade or to add components, and it pushes a commit only when the manifests differ.
What Lands In Git
Pull the repository and look under the path you chose. Inside a flux-system folder you will find three files. gotk-components.yaml is the entire install: every custom resource definition (CRD, which teaches Kubernetes a brand-new kind of object) and every controller Deployment. gotk-sync.yaml holds the GitRepository and the Kustomization that make Flux watch this very repository. kustomization.yaml is a small index that ties the two together so they get applied as a set.
git pullls clusters/production/flux-system/
gotk-components.yamlgotk-sync.yamlkustomization.yaml
apiVersion: source.toolkit.fluxcd.io/v1kind: GitRepositorymetadata:name: flux-systemnamespace: flux-systemspec:interval: 1m0sref:branch: mainsecretRef:name: flux-systemurl: ssh://[email protected]/my-org/fleet-infra---apiVersion: kustomize.toolkit.fluxcd.io/v1kind: Kustomizationmetadata:name: flux-systemnamespace: flux-systemspec:interval: 10m0spath: ./clusters/production # the --path you passed; flux-system/ lives inside itprune: truesourceRef:kind: GitRepositoryname: flux-system
Read the Kustomization closely. Its path is ./clusters/production, the directory you passed to --path, and the flux-system folder holding these three files lives inside it. So on every interval, Flux re-applies the directory that contains its own definition. That is the whole loop. The GitRepository fetches the branch. The Kustomization applies the path. The path re-declares the GitRepository and the Kustomization. Change gotk-components.yaml in Git, and Flux rolls that change onto itself on the next reconcile (one reconcile is a single pass of the make-reality-match-Git loop).
Git Is Now The Control Plane
This is the part defenders need to sit with. Before bootstrap, changing the cluster meant holding credentials to the cluster. After bootstrap, changing the cluster means being able to push a commit to one directory in Git. The write path to your production cluster now runs through your Git host and your review process. So treat that branch like production, because it is production. Turn on branch protection for main. Require at least one review on anything touching clusters/. Require signed commits, so a stolen token cannot forge a trusted maintainer. You can go further and make Flux itself refuse unsigned history: set .spec.verify on the GitRepository to point at a secret of trusted public keys, and the source-controller will reject any revision that is not signed by one of them.
There is a second thing worth seeing with your own eyes. The key Flux uses to read Git is stored inside the cluster as an ordinary Secret in the flux-system namespace. Anyone who can read Secrets in that namespace can read the Git credential.
# What does Flux see, from inside the cluster?flux get all -n flux-system# The Git credential is a Secret. Watch how little guards it.kubectl get secret flux-system -n flux-system \-o jsonpath='{.data.identity}' | base64 -d | head -1
NAME REVISION SUSPENDED READY MESSAGEgitrepository/flux-system main@sha1:3b2c1a4 False True stored artifact for revision 'main@sha1:3b2c1a4'NAME REVISION SUSPENDED READY MESSAGEkustomization/flux-system main@sha1:3b2c1a4 False True Applied revision: main@sha1:3b2c1a4-----BEGIN OPENSSH PRIVATE KEY-----
Self-Healing And Its Limits
Flux does something that feels like magic and trips up newcomers. If you hand-edit a running object with kubectl, the kustomize-controller notices that reality drifted from Git on the next pass and puts it back. Watch it happen. Change a live controller's image, then force a reconcile instead of waiting for the ten-minute interval.
# Pretend an attacker swaps a controller image for a tampered onekubectl set image deployment/source-controller \manager=ghcr.io/evil/source-controller:tampered -n flux-system# Don't wait for the interval; pull Git and re-apply nowflux reconcile kustomization flux-system --with-source
deployment.apps/source-controller image updated► annotating GitRepository flux-system in flux-system namespace✔ GitRepository annotated◎ waiting for GitRepository reconciliation✔ fetched revision main@sha1:3b2c1a4► annotating Kustomization flux-system in flux-system namespace✔ Kustomization annotated◎ waiting for Kustomization reconciliation✔ applied revision main@sha1:3b2c1a4
The tampered image is gone. Git said ghcr.io/fluxcd/source-controller, and Git won. Here is the lesson inside the lesson. Self-healing reverts changes made against the cluster, but a change made in Git is not drift, it is the new truth, and Flux applies it happily. So an attacker who only has kubectl gets reverted, and makes noise doing it. An attacker who can push to your --path gets applied fleet-wide and looks exactly like a normal deploy. That is why the earlier hardening (branch protection, signed commits, .spec.verify) matters more than anything you do to the live Deployments. For detection, watch the revision Flux applies: the notification-controller can send every applied commit to Slack or a webhook, so an unexpected sha1 (the short fingerprint that identifies a Git commit) on flux-system becomes a signal you can alert on.
Upgrading: The Pin Lives In Git
Because Flux reconciles its own components from gotk-components.yaml, the version it runs is whatever that file says, and that file is whatever the flux CLI wrote. You do not upgrade Flux with kubectl. You install a newer flux CLI on your laptop (or in CI, the continuous integration system that runs your pipelines), and you re-run the exact same flux bootstrap command. It regenerates gotk-components.yaml, commits the diff, and Flux rolls itself forward on the next reconcile.
One more thing to do the day you bootstrap: turn on branch protection for main, require a review and a signed commit on anything under clusters/, then revoke the PAT you exported. The deploy key keeps Flux reading Git without it. That token was only ever needed for the first push, and a token left lying around with repo scope is a spare key to your control plane.
Try this
Run kubectl config current-context on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a read-write deploy key raises the blast radius. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.