Skip to content
Back to student guides
FluxDevOpsCI/CD & GitOps3 levels125 sectionsCovers Flux 2.9

The Complete Flux Guide

Keep Kubernetes clusters in sync with Git using Flux. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

Official docs AI-drafted · community review in progressHelp review it
22sections
72examples

This is part one of three. It covers everything you need to do real work with Flux, not a teaser. By the end you can install Flux on a cluster, point it at a Git repository, and watch it deploy an application, a Helm chart and a change you made with nothing but git push. You will know how to read what Flux is telling you when something is wrong, how to pause it safely, and how to tear it all down without leaving debris. Mid-level and Senior take the same topics further; nothing here is thrown away.

The guide is written against Flux v2.9.5 (released 31 August 2026), which is the current stable release at the time of writing. Flux tutorials age quickly, because several API versions were removed in 2.7, 2.8 and 2.9, and a large share of the YAML you will find on blog posts and forums no longer works. Every manifest in this guide uses the current versions, and a later section explains how to recognise an outdated one.

Each section ends with a Try it task. Do them as you go. They take a few minutes each, and GitOps only makes sense once you have watched a cluster change itself because of a commit you made.

What Flux is, and the problem it solves

Flux is a set of programs that run inside a Kubernetes cluster and keep the cluster matching a description stored somewhere else, usually a Git repository. You write down what should exist (a Deployment, a Service, a Helm release, a namespace), commit it, and Flux notices the commit, applies it, and then keeps checking that the cluster still looks the way the repository says it should. If someone edits the cluster by hand, Flux puts it back. If you delete a file from the repository, Flux deletes the object from the cluster.

This practice has a name, GitOps, and Flux is one of its two best-known implementations (the other is Argo CD, covered in its own guide). Flux is a graduated project of the Cloud Native Computing Foundation, the same foundation that hosts Kubernetes, and it is free and open source.

GITthe desired state
→
FLUXruns in the cluster
→
KUBERNETESthe actual state
→
STATUSreported back

The diagram is the whole idea. To see why it matters, consider what deploying to Kubernetes looks like without it.

Without Flux you deploy by running kubectl apply -f deployment.yaml from your laptop, or by letting a continuous integration job run the same command. That works on the first day. By the third month the cluster contains objects nobody remembers creating, a hotfix somebody applied directly during an incident that never made it back into the repository, and three different people each believing their copy of a YAML file is the current one. Nobody can answer the simple question "what exactly is running in production, and who changed it?" without detective work.

Flux answers that question structurally. There is one place where the desired state lives, Git, and Git already records who changed what, when, and (if your team writes decent commit messages) why. A rollback becomes git revert. A review becomes a pull request. An audit becomes git log. The cluster stops being a place where people do things and becomes a place where a repository is reflected.

Three consequences of that design explain most of what follows, so notice them now.

Flux pulls, it does not push. A CI job that runs kubectl apply needs credentials to your cluster, stored in your CI system, reachable from wherever the CI runs. Flux inverts this. The controllers live inside the cluster and reach out to Git. The cluster needs only outbound network access to your repository and your container registries. No credential that can change the cluster ever leaves the cluster. For teams whose employers care about where credentials live and which networks can talk to a production environment, which includes many banks, telcos and government-linked employers across the Gulf and Egypt, that is a selling point in its own right.

Flux never stops checking. A deployment script runs once and exits. Flux runs forever. Every few minutes it compares the repository with the cluster again. That is called reconciliation, and it is the word you will see more than any other in Flux documentation and output.

Flux is made of Kubernetes objects. You do not configure Flux through a web form or a config file on a server. You configure it by creating Kubernetes custom resources, which you write as YAML and store in the same Git repository as everything else. Even Flux itself is described in Git, and after installation it manages its own upgrades the same way it manages your applications.

What people use it for:

🔁

Continuous delivery without a pipeline

Merge to main and the cluster updates itself. No deploy job, no cluster credentials in CI.

🛡️

Drift correction

A manual kubectl edit is reverted on the next reconcile, so the repository stays the truth.

📜

An audit trail for free

Every change to the cluster is a commit with an author, a timestamp and a review.

🌍

Many clusters, one pattern

Staging, production and every regional cluster follow the same layout in a repository.

You need little to follow along: a terminal, kubectl, a Kubernetes cluster you are allowed to break (a local one is perfect) and a GitHub account. We build everything from scratch.

Try it
  1. Think of the last time you deployed something to Kubernetes by hand.
  2. Write down where the YAML you applied lived afterwards, and whether it matched the cluster a week later.
  3. Ask a teammate the same question about production: "what changed last Tuesday?"
an answer that needs several minutes and several people. That gap between "what the repo says" and "what the cluster does" is the problem Flux exists to close.

Push versus pull: what came before

It helps to know what Flux replaced, because the old ways still exist in most organisations you will join, and the word "GitOps" is sometimes used loosely for things that are not.

The oldest approach is manual kubectl apply. It is fine for learning and for a one-person prototype. It has no memory and no review.

The next step up is push-based continuous delivery: a pipeline in GitHub Actions, GitLab CI, Jenkins or similar builds your code, and then a final step runs kubectl apply or helm upgrade against the cluster. This is better, because the pipeline definition is in Git and the deploy is repeatable. But it has three weaknesses that Flux was built to remove.

First, the pipeline holds credentials to the cluster. Anyone who can change the pipeline, or who compromises the CI system, can change production. Second, the pipeline runs once. After it finishes, nothing watches the cluster. A colleague who edits a Deployment by hand at 2 a.m. creates a difference that nobody sees until it causes an outage. Third, the pipeline does not know about deletion. If you remove a manifest from the repository, the old object simply stays in the cluster forever, because kubectl apply only creates and updates what you give it.

The original Flux v1 took the pull-based approach, but it was a single monolithic program with its own annotation conventions and limited extensibility. It has been archived, and you should ignore any tutorial that mentions fluxctl or a HelmOperator. Flux v2, which this guide covers and which everyone now just calls Flux, is a rewrite built as a collection of small controllers that cooperate through Kubernetes custom resources. The project calls this collection the GitOps Toolkit, and you will see that name inside resource names such as gotk-components.yaml and in metric names beginning with gotk_. Wherever this guide says "Flux", it means Flux v2.

GitOps is a set of principles, not a product The OpenGitOps principles say that desired state is declarative, versioned and immutable, that agents pull it automatically, and that they continuously reconcile the actual state toward it. A pipeline that pushes `kubectl apply` fails the last two. Flux satisfies all four, which is why it is called a GitOps tool rather than a deployment tool.

Push (CI runs kubectl)

  • CI holds cluster credentials
  • Runs once, then stops looking
  • Deleting a file leaves the object behind
  • Hand edits go unnoticed

Pull (Flux in the cluster)

  • Only outbound access to Git is needed
  • Reconciles continuously
  • Deleting a file deletes the object (prune)
  • Hand edits are reverted (drift correction)

Flux does not replace your CI system, it changes what CI is for. CI still builds your code, runs tests and pushes a container image. What it stops doing is talking to the cluster. The hand-off happens through Git (or through an OCI registry, which you will meet in the Mid-level guide): CI or a person commits a new image tag, and Flux picks it up.

Try it
  1. Take one deployment pipeline you know. List every credential it holds that can change the cluster.
  2. Ask what would notice if someone edited that Deployment by hand an hour after the pipeline finished.
a list of credentials that would not need to exist in a pull-based setup, and a blank for the second question. Those two answers are the pitch for Flux.

The mental model: four nouns and one verb

Flux has a lot of custom resource kinds, but a beginner needs only four ideas. Learn these and every command in the rest of the guide will make sense.

SOURCEwhere to fetch from
→
ARTIFACTa tarball with a revision
→
KUSTOMIZATION / HELMRELEASEwhat to apply
→
CLUSTERobjects created

A Source says where content lives and how often to check it. The Source you will use most is a GitRepository: a repository URL, a branch, and a polling interval. Other kinds fetch from an OCI registry (OCIRepository), a Helm chart repository (HelmRepository), or an object-storage bucket (Bucket). A Source does not deploy anything. It only fetches.

An Artifact is what a Source produces when it fetches successfully: a compressed archive of the repository contents, stored by Flux and served over HTTP inside the cluster, labelled with a revision. For Git the revision looks like main@sha1:696f056d..., which is the branch and the commit. You never create Artifacts yourself, but you read revisions constantly, because the revision is how you know which commit Flux believes it is running.

A Kustomization (Flux's own kind, in the API group kustomize.toolkit.fluxcd.io) says: take this Source, look in this path, and apply whatever Kubernetes YAML you find there to the cluster. It is the workhorse. Despite the name, it does not require you to know the Kustomize tool, although it runs Kustomize under the hood and understands Kustomize files when they exist.

A HelmRelease says: install this Helm chart, from this Source, with these values. Helm is the most popular way to package Kubernetes applications, and Flux manages Helm releases declaratively instead of you typing helm install. See the Helm guide at /student-guides/helm for Helm itself.

The one verb is reconcile. Every Flux object has a spec.interval. On that interval the controller that owns the object looks at the world, compares it with what the object says, and acts if they differ. A GitRepository with interval: 1m asks Git for new commits every minute. A Kustomization with interval: 10m re-applies its path to the cluster every ten minutes even when nothing in Git changed, which is how manual edits get reverted. The result of each attempt is written into the object's status, where both you and the flux command line tool can read it.

Two different things are called "Kustomization" A file named kustomization.yaml with apiVersion: kustomize.config.k8s.io/v1beta1 belongs to the Kustomize tool and lists resources to build. A Flux Kustomization has apiVersion: kustomize.toolkit.fluxcd.io/v1 and is a cluster object that tells Flux what to apply. They often appear in the same repository and work together, but they are not the same thing. When you read an error, check the apiVersion first.

Hold the chain in your head: a Source fetches, producing an Artifact; a Kustomization or HelmRelease takes the Artifact and applies it; and the reconcile loop repeats that forever. Everything else in Flux is a refinement or an add-on to this chain. Flux also has notification objects and image automation objects. We mention them later, but you can be productive without them.

Try it
  1. Without looking back, write the four nouns and the one verb on a card.
  2. For each, write one sentence saying what it does and what it does not do (a Source does not deploy, for example).
a card you can explain to a colleague in thirty seconds. Keep it beside you for the next sections.

The controllers: who does the work

Flux is not one program. It is several small programs, each a Kubernetes controller running as a Deployment in a namespace called flux-system. A controller is a loop that watches some kinds of objects and makes the world match them. Flux splits its work by kind.

Controller Watches What it does
source-controller GitRepository, OCIRepository, HelmRepository, HelmChart, Bucket Fetches content and publishes Artifacts
kustomize-controller Kustomization Builds the YAML from an Artifact and applies it
helm-controller HelmRelease Installs, upgrades, tests and rolls back Helm releases
notification-controller Provider, Alert, Receiver Sends events to Slack and similar, and receives incoming webhooks

Those four are installed by default. Two further controllers, image-reflector-controller and image-automation-controller, are optional. They watch container registries and can write new image tags back into Git. There is also an optional source-watcher. A beginner does not need any of these, and this guide leaves them alone.

Why split it up? Because each controller can be understood, scaled and secured separately. A failure in fetching shows up on the Source, not on the Kustomization. When something breaks you learn to ask "which stage failed", and the answer is almost always visible on the object that owns that stage. Here is how the pieces cooperate on a normal change.

  1. You push a commitA change lands on the branch that your GitRepository tracks.
  2. source-controller noticesOn its next interval (or sooner if a webhook arrives) it fetches the repository and stores a new Artifact with the new revision.
  3. kustomize-controller is toldIt sees that the Artifact its Kustomization points at has a new revision.
  4. It builds and appliesIt renders the YAML at your chosen path and applies it to the cluster with Kubernetes server-side apply.
  5. It checks health and prunesIt can wait for your Deployments to become ready, and it deletes objects that used to be in Git but no longer are.
  6. It reports backThe result, including the revision now applied, lands in the object's status and in Kubernetes events.

All of the controllers run with restricted security settings, and only kustomize-controller and helm-controller hold broad cluster rights, because applying arbitrary YAML needs them. You do not need to change any of that as a beginner. It is worth knowing now because it explains why Flux is comfortable to run in a locked-down cluster.

Try it
  1. Pick a fictional failure: "the Git repository URL has a typo". Which controller reports the error?
  2. Pick another: "the Deployment I committed has an invalid image name". Which controller reports that one, and what would you see?
source-controller for the first, since nothing can be fetched. kustomize-controller for the second, typically as a health-check timeout while the pods sit in ImagePullBackOff. Sorting failures by stage is most of debugging Flux.

Installing the flux CLI and checking your cluster

You interact with Flux through two tools. kubectl you already know. The flux command line tool is new: it installs Flux, prints the state of Flux objects in a readable table, forces a reconcile, and can generate YAML for you. It is a client only. Flux itself runs in the cluster, so you can uninstall the CLI without touching any deployment.

Install it with whichever route suits your machine:

BASH
# macOS or Linux with Homebrew
brew install fluxcd/tap/flux

# Linux or macOS with the official install script
curl -s https://fluxcd.io/install.sh | sudo bash

# Windows with Chocolatey
choco install flux

The Homebrew tap and the script are the usual choices. Chocolatey is the only Windows package manager the official documentation lists. If you use something else, such as winget or scoop, treat the package as unofficial and check its version. You can also download a release archive from the fluxcd/flux2 page on GitHub, or run the CLI from the container image ghcr.io/fluxcd/flux-cli, which includes kubectl.

Confirm it works, and turn on shell completion so the long subcommand names become tab-completable:

BASH
flux --version
. <(flux completion bash)
TEXT
flux version 2.9.5

The second command is for bash; zsh, fish and powershell are also supported through flux completion zsh and so on. Put the line in your shell's startup file so it survives a new terminal.

A cluster you are allowed to break

You need a Kubernetes cluster, and for learning it should be disposable. The most convenient option is a local cluster running inside Docker, created with a tool such as kind (Kubernetes in Docker). If you have not met containers yet, read the Docker guide at /student-guides/docker first, since kind runs each cluster node as a container.

BASH
kind create cluster --name flux-demo
kubectl cluster-info --context kind-flux-demo
kubectl get nodes
TEXT
NAME                      STATUS   ROLES           AGE   VERSION
flux-demo-control-plane   Ready    control-plane   40s   v1.35.0

Any cluster works: Minikube, Docker Desktop's built-in Kubernetes, or a managed cluster in the cloud, including the cloud regions in the Gulf that many readers will use at work. What matters is that you have cluster-admin rights, because installing Flux creates custom resource definitions and cluster-wide roles. Flux 2.9 supports Kubernetes 1.34, 1.35 and 1.36, and the project supports only the three newest Kubernetes minor versions, so an old cluster may refuse to cooperate.

The official install page may show an older version table At the time of writing, the Installation page still lists the Kubernetes versions that Flux 2.8 supported (1.33 to 1.35). The release notes for 2.9 give the correct set of 1.34, 1.35 and 1.36. When the two disagree, trust the release notes of the version you are installing.

The pre-flight check

Before installing anything, ask Flux whether the cluster is ready to receive it:

BASH
flux check --pre
TEXT
► checking prerequisites
✔ Kubernetes 1.35.0 >=1.34.1-0
✔ prerequisites checks passed

Read this output line by line. The first line confirms it could reach the cluster through your current kubectl context, and that the Kubernetes version falls in the supported range. The last line is the summary you care about. If you see a red cross instead, the message tells you whether the problem is an unreachable cluster (check kubectl get nodes works on its own), an unsupported version, or missing rights. The command also tells you when a newer CLI exists.

After Flux is installed, you will run plain flux check with no --pre. It then verifies that each controller is healthy and that the custom resource definitions are present, which makes it the first command to run whenever Flux "feels broken". You can also run flux version to see the CLI version next to the version of the controllers running in the cluster.

Try it
  1. Install the flux CLI and run flux --version.
  2. Create a kind cluster (or pick any cluster you may break) and run kubectl get nodes.
  3. Run flux check --pre.
a green "prerequisites checks passed". If you see a version complaint, create a cluster with a newer node image before continuing.

Bootstrap: Flux installs itself from Git

There are several ways to put Flux into a cluster. The recommended one is called bootstrap, and it is worth understanding why, because it is the feature that makes Flux different from tools that just apply a YAML bundle.

flux bootstrap does two things in one command. It installs the controllers into the cluster, and it commits the manifests of those controllers into your Git repository, together with a GitRepository and a Kustomization that point back at that same repository. From that moment Flux is managing itself: its own definition lives in Git, and if you later want to upgrade Flux or change one of its settings, you change a file and push. That is the GitOps promise applied to the tool delivering it.

Bootstrap is also idempotent. Running it a second time with the same flags does no harm, and running it with a newer CLI is how you upgrade.

You need a repository host. GitHub is the easiest, and the official commands exist for GitHub, GitLab, Gitea, Bitbucket Server and any generic Git server. We use GitHub.

  1. Create a personal access tokenOn GitHub, create a personal access token that has permission to create and write repositories. Bootstrap needs it once, to create the repository and to register a deploy key. The documentation describes the exact scopes.
  2. Export itPut the token in an environment variable so it never appears in your shell history or in a file.
  3. Run bootstrapGive it your GitHub user, a repository name, a branch and a path inside the repository that represents this cluster.
  4. Wait and verifyBootstrap waits for the controllers to become ready. Then you check with flux check.
BASH
export GITHUB_TOKEN=<your-token>
export GITHUB_USER=<your-github-username>

flux bootstrap github \
  --owner=$GITHUB_USER \
  --repository=fleet-infra \
  --branch=main \
  --path=clusters/demo \
  --personal=true

Go through the flags, because each one answers a question you will otherwise be asked later.

  • --owner is the GitHub user or organisation that will own the repository. With a personal account, add --personal=true, which tells the command the owner is a user, not an organisation. If you want the repository to be public, also pass --private=false; the default is a private repository.
  • --repository names the repository. If it does not exist, bootstrap creates it. If it exists, bootstrap uses it.
  • --branch is the branch Flux will watch. The default is main.
  • --path is the folder inside the repository that belongs to this cluster. One repository can serve many clusters, each with its own folder such as clusters/staging and clusters/production. Flux only reads inside the path you give it for its own self-management.
  • --token-auth (not used here) makes Flux authenticate to Git using the token over HTTPS. Without it, bootstrap generates an SSH deploy key and stores its private half in a Secret in the cluster, which is the more common choice because the token then never needs to stay in the cluster.

Typical output, abbreviated:

TEXT
► connecting to github.com
✔ repository "https://github.com/your-user/fleet-infra" created
► cloning branch "main" from Git repository "https://github.com/your-user/fleet-infra.git"
✔ cloned repository
► generating component manifests
✔ generated component manifests
✔ committed component manifests to "main"
► 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 AAAA...
✔ configured deploy key "flux-system-main-flux-system-./clusters/demo"
► generating sync manifests
✔ committed sync manifests to "main"
► 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

Your exact lines will differ, but the shape is the point. Bootstrap generated manifests, committed them, installed the controllers, created credentials, committed a sync configuration, applied it, and then waited for Flux to reconcile itself. The last line, all components are healthy, is what you need to see.

Always run flux check --pre first Bootstrap fails in confusing ways on an unsupported cluster or an unreachable API server. The pre-flight check costs two seconds and catches both. You can even chain them: flux check --pre && flux bootstrap github ....

Two traps cost beginners the most time at this step. The first is a token that lacks repository permissions, which shows up as an authentication or permission error while bootstrap tries to create the repository or the deploy key. Create a fresh token with the right scope and export it again. The second is running bootstrap against the wrong cluster, because kubectl talks to whichever context is current. Always check kubectl config current-context before you bootstrap, and be doubly careful if you have a production context in the same kubeconfig.

If you later need to reuse an existing repository, or to adopt a cluster where Flux was installed another way, the --force flag exists, but read the documentation before using it. For experimenting, a fresh repository and a fresh kind cluster is the safe path.

Try it
  1. Confirm your kubectl context with kubectl config current-context.
  2. Run the bootstrap command above with your own GitHub user.
  3. Open the new repository in your browser and look at the commits bootstrap created.
  4. Run flux check.
a repository named fleet-infra with two commits from Flux, and "all checks passed" from flux check.

What bootstrap created, in Git and in the cluster

Open the repository on GitHub. Inside the path you gave, you will find a folder named flux-system containing three files. Understanding them removes most of the mystery about what Flux is.

TEXT
fleet-infra/
└── clusters/
    └── demo/
        └── flux-system/
            ├── gotk-components.yaml
            ├── gotk-sync.yaml
            └── kustomization.yaml

gotk-components.yaml is a very long file. It contains the definitions of everything Flux needs: the custom resource definitions, the namespace, the roles, the four controller Deployments and their network policies. "gotk" is the GitOps Toolkit. You never edit this file by hand, since bootstrap regenerates it when you upgrade. Reading through it once is a good way to learn that Flux is nothing magical: it is Deployments and CRDs like any other application.

gotk-sync.yaml is short and is the piece that matters most. It holds two objects:

gotk-sync.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: flux-system
  namespace: flux-system
spec:
  interval: 1m0s
  ref:
    branch: main
  secretRef:
    name: flux-system
  url: ssh://git@github.com/your-user/fleet-infra
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: flux-system
  namespace: flux-system
spec:
  interval: 10m0s
  path: ./clusters/demo
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system

You now have a complete example of a Source and a Kustomization. The GitRepository named flux-system says: fetch this repository, branch main, every minute, using the credentials in the Secret called flux-system. The Kustomization named flux-system says: take that GitRepository, look inside ./clusters/demo, apply what you find every ten minutes, and prune (delete) anything that used to be there and no longer is.

This one Kustomization is the key to everything. It applies whatever is inside clusters/demo, including the folder flux-system itself, which is how Flux manages its own definition. And it means that any new YAML file you add anywhere under clusters/demo is applied automatically. You will use that in the next section.

kustomization.yaml is a Kustomize file listing the other two, so the folder is buildable.

Now look at the cluster:

BASH
kubectl -n flux-system get deployments
TEXT
NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
helm-controller           1/1     1            1           3m
kustomize-controller      1/1     1            1           3m
notification-controller   1/1     1            1           3m
source-controller         1/1     1            1           3m

And ask Flux what it knows:

BASH
flux get all
TEXT
NAME                       REVISION        SUSPENDED  READY  MESSAGE
gitrepository/flux-system  main@sha1:a1b2c3d  False    True   stored artifact for revision 'main@sha1:a1b2c3d'

NAME                       REVISION        SUSPENDED  READY  MESSAGE
kustomization/flux-system  main@sha1:a1b2c3d  False    True   Applied revision: main@sha1:a1b2c3d

This is the format you will read hundreds of times, so learn every column. NAME is the kind and name. REVISION is the exact commit Flux last fetched (for the GitRepository) or last applied (for the Kustomization). SUSPENDED says whether someone paused the object. READY is True when the last reconcile succeeded. MESSAGE is the human sentence explaining the state, and it is where error text appears when something goes wrong.

Notice that the two revisions match. That is what "in sync" looks like: the commit that source-controller fetched is the commit kustomize-controller applied.

There is also one Secret to know about, because people sometimes delete it by mistake:

BASH
kubectl -n flux-system get secret flux-system

It holds the credentials that GitRepository flux-system uses to read your repository. If you delete it, Flux can no longer fetch anything and the error will say authentication failed. Re-running the bootstrap command recreates it.

Do not hand-edit the generated files gotk-components.yaml is overwritten by every bootstrap and upgrade, so edits are lost. To change a controller setting, such as a command-line flag, you add a patches entry to kustomization.yaml in that same folder instead. Beginners almost never need this, but remember the rule when you do.
Try it
  1. Run git pull on a local clone of fleet-infra (or browse it on GitHub) and open gotk-sync.yaml.
  2. Run flux get all and find the revision. Compare it with the latest commit hash in git log.
  3. Run kubectl -n flux-system get gitrepository,kustomization and notice that Flux objects are ordinary Kubernetes objects.
the commit hash from git log matches the start of the revision in flux get all, proving Flux is running exactly what is in Git.

Your first application: a Source and a Kustomization

Time to deploy something. We use podinfo, a tiny web application that the Flux project itself uses in its examples. It is a good teaching target because it is small, it shows its own version number on a web page, and its repository already contains ready-made Kubernetes manifests.

Recall the chain: a Source fetches, and a Kustomization applies. So we need two objects. You could write them by hand, but the CLI can generate them for you. The key flag is --export, which prints YAML to the screen instead of creating the object. That matters for GitOps: you generate the file, put it in your repository, commit it, and let Flux create the object. Creating objects directly in the cluster would leave them outside Git, which is exactly the habit we are trying to avoid.

First, make sure you have a local clone of your repository and are inside it:

BASH
git clone https://github.com/$GITHUB_USER/fleet-infra.git
cd fleet-infra

Generate the Source:

BASH
flux create source git podinfo \
  --url=https://github.com/stefanprodan/podinfo \
  --branch=master \
  --interval=1m \
  --export > ./clusters/demo/podinfo-source.yaml

Then the Kustomization that applies it:

BASH
flux create kustomization podinfo \
  --target-namespace=default \
  --source=GitRepository/podinfo \
  --path="./kustomize" \
  --prune=true \
  --wait=true \
  --interval=30m \
  --retry-interval=2m \
  --health-check-timeout=3m \
  --export > ./clusters/demo/podinfo-kustomization.yaml

Open both files. The first is a GitRepository like the one bootstrap created, pointing at the public podinfo repository on its master branch. The second deserves slow reading:

clusters/demo/podinfo-kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: podinfo
  namespace: flux-system
spec:
  interval: 30m0s
  path: ./kustomize
  prune: true
  retryInterval: 2m0s
  sourceRef:
    kind: GitRepository
    name: podinfo
  targetNamespace: default
  timeout: 3m0s
  wait: true

Every field is a decision you will make for every app you ever deploy with Flux:

  • sourceRef names the Source this Kustomization reads from. It is how the two objects are linked.
  • path is the folder inside that Source where the YAML lives, relative to the repository root. It is the most common place for typos.
  • interval: 30m says re-apply and re-check every thirty minutes even with no new commits. That period is also the longest a manual edit can survive before being reverted.
  • prune: true turns on garbage collection: objects that Flux created from this path and that later disappear from Git are deleted from the cluster.
  • targetNamespace: default forces all objects into that namespace, regardless of what the YAML says.
  • wait: true tells Flux to wait until every applied object is healthy. timeout: 3m is how long it waits before declaring failure.
  • retryInterval: 2m is how soon to try again after a failure, instead of waiting for the full 30 minutes.

Now the GitOps moment. Commit and push both files:

BASH
git add -A
git commit -m "Add podinfo application"
git push

You have not run kubectl apply for podinfo, and you will not. The existing flux-system Kustomization already watches clusters/demo, so when its GitRepository next fetches (within a minute) it will find your two new files, apply them, and create the podinfo Source and Kustomization in the cluster. Those then fetch podinfo and deploy it. Two levels of Flux objects, each creating the next.

Watch it happen:

BASH
flux get kustomizations --watch
TEXT
NAME         REVISION             SUSPENDED  READY   MESSAGE
flux-system  main@sha1:d4e5f6a    False      True    Applied revision: main@sha1:d4e5f6a
podinfo      master@sha1:9c8b7a6  False      True    Applied revision: master@sha1:9c8b7a6

Press Ctrl+C to leave the watch. You can see the podinfo revision begins with master@, because it tracks podinfo's own branch, whereas the flux-system Kustomization tracks your main. Finally look at what was created:

BASH
kubectl -n default get deployments,services
kubectl -n default rollout status deployment/podinfo

To see the app itself, forward a local port:

BASH
kubectl -n default port-forward deployment/podinfo 9898:9898

Visit http://localhost:9898 in a browser. You will see a page showing the podinfo version. Stop the forward with Ctrl+C.

Skip the wait with flux reconcile If you do not want to wait a minute for the next fetch, ask Flux to act now: flux reconcile source git flux-system fetches immediately, and flux reconcile kustomization flux-system --with-source does the fetch and the apply in one step. It does not change the desired state, it just brings the next reconcile forward.
Try it
  1. Generate the two podinfo files with --export, commit and push them.
  2. Run flux reconcile kustomization flux-system --with-source, then flux get kustomizations.
  3. Port-forward to podinfo and note the version number on its page.
podinfo running in the default namespace, although you never ran kubectl apply on it. The repository is now the only way you change this application.

Reading status: conditions, revisions and events

A GitOps tool is only as useful as its ability to tell you what went wrong, and Flux is good at this once you know where to look. There are three places, from quickest to most detailed.

The table from flux get. You have already met it. Every command in the flux get family prints the same columns. The family covers sources and the two workload kinds:

BASH
flux get sources git              # only Git sources
flux get sources all -A           # every kind of source in every namespace
flux get kustomizations           # alias: flux get ks
flux get helmreleases -A          # alias: flux get hr
flux get all -A                   # everything Flux owns, all namespaces

The -A flag means "all namespaces". Flux commands default to the flux-system namespace, because that is where Flux keeps its own objects. When you look at objects that live elsewhere, such as a HelmRelease in the default namespace, you either pass -n default or -A. Forgetting this is the classic reason for "flux says there are no helmreleases" when there obviously are.

The most useful filter is --status-selector, which shows only the objects that are not healthy:

BASH
flux get all -A --status-selector ready=false

The events. Flux writes a Kubernetes event each time something noteworthy happens, and the CLI reads them for you:

BASH
flux events                                  # events in flux-system
flux events -A                               # everywhere
flux events --for Kustomization/podinfo      # one object
TEXT
LAST SEEN  TYPE    REASON                OBJECT                   MESSAGE
2m         Normal  ReconciliationSucceeded  Kustomization/podinfo  Reconciliation finished in 1.2s, next run in 30m0s with revision master@sha1:9c8b7a6

Events are history: they tell you what happened and when, so they are good for questions like "when was this last applied?".

The controller logs. When the message on the object is not enough, go to the source:

BASH
flux logs --all-namespaces --level=error
flux logs --kind=Kustomization --name=podinfo

The conditions on the object. Every Flux object carries a list of conditions in its status. You can view them with kubectl describe, for example kubectl -n flux-system describe kustomization podinfo. The condition called Ready is the headline. When it is False, the message next to it is the reason. You may also see Reconciling, which is true while work is in progress, Stalled, which means Flux has decided that retrying will not help until something changes, and, for workloads with health checks, Healthy.

One more field rewards attention: .status.lastAppliedRevision on a Kustomization, and .status.artifact.revision on a Source. Together they tell you which commit was fetched and which was applied. If the source is on abc123 and the Kustomization on 789def, the Kustomization has not caught up, and the question becomes why.

Why errors appear on different objects A typo in the repository URL appears on the GitRepository, because that is the object that tries to clone. A missing folder appears on the Kustomization, because it is the one that looks for the path. If a Kustomization says "Source artifact not found", look at its Source first. Follow the chain from the left.

If you want to see what a Kustomization actually manages, use:

BASH
flux tree kustomization flux-system

which prints the objects owned by that Kustomization as a tree, including the Flux objects it creates, such as your podinfo Kustomization. It is a quick way to answer "what does Flux think it is responsible for here?".

Try it
  1. Run flux get all -A and find the REVISION and MESSAGE of every object.
  2. Run flux events --for Kustomization/podinfo and find when it last reconciled.
  3. Run flux tree kustomization flux-system and locate podinfo.
  4. Run kubectl -n flux-system describe kustomization podinfo and find the Ready condition.
you can now answer, from the terminal, "which commit is running, and is it healthy?" for any object Flux manages.

Changing things the GitOps way: commit, then reconcile

The most important skill in the whole guide is changing something without touching the cluster directly. Let us change how podinfo is configured, and watch the change flow through.

Podinfo's manifests live in another repository that we do not own, so we cannot edit them. Flux gives a clean way to adjust them anyway: patches in the Kustomization, which Flux applies on top of the fetched YAML. Edit clusters/demo/podinfo-kustomization.yaml and add a patch that raises the replica count:

clusters/demo/podinfo-kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: podinfo
  namespace: flux-system
spec:
  interval: 30m0s
  path: ./kustomize
  prune: true
  retryInterval: 2m0s
  sourceRef:
    kind: GitRepository
    name: podinfo
  targetNamespace: default
  timeout: 3m0s
  wait: true
  patches:
    - target:
        kind: Deployment
        name: podinfo
      patch: |
        - op: replace
          path: /spec/replicas
          value: 3

The patch uses JSON Patch syntax: it replaces /spec/replicas on the Deployment called podinfo with the value 3. Commit, push and reconcile:

BASH
git add -A
git commit -m "Run three replicas of podinfo"
git push
flux reconcile kustomization flux-system --with-source
flux reconcile kustomization podinfo
kubectl -n default get deployment podinfo
TEXT
NAME      READY   UP-TO-DATE   AVAILABLE   AGE
podinfo   3/3     3            3           12m

The path a change takes is always the same: edit a file, commit, push, and wait (or reconcile). Notice the two-step reconcile: the first command updated the podinfo Kustomization object itself from your repository, and the second made that updated Kustomization apply its patch. Normally you would not run them by hand, and the next poll handles it, but it is worth understanding that there are two hops.

Drift: the cluster changes behind Flux's back

Now test the promise that Flux keeps the cluster matching Git. Make a manual change and see what happens:

BASH
kubectl -n default scale deployment podinfo --replicas=1
kubectl -n default get deployment podinfo

For a moment the Deployment shows 1 replica. This is drift: the cluster differs from what Git describes. Now ask Flux to reconcile:

BASH
flux reconcile kustomization podinfo
kubectl -n default get deployment podinfo

Back to three replicas. Flux re-applied the desired state and overwrote your manual change. If you had not forced it, the same correction would have happened at the next interval, up to 30 minutes later. That is why the interval matters: it is the maximum lifetime of an unofficial edit.

Your kubectl edit will be reverted The most confusing beginner experience is fixing something with kubectl edit during an incident and watching the fix vanish. It is not a bug. If you need an emergency change, suspend the Kustomization first (see the section on suspend and resume below), make the change, and then put the same change in Git before you resume. Otherwise Flux will undo it.

Prune: deleting a file deletes the object

The opposite direction matters too. Because prune: true is set, removing something from Git removes it from the cluster. To see it, delete the podinfo application by removing its two files:

BASH
git rm clusters/demo/podinfo-source.yaml clusters/demo/podinfo-kustomization.yaml
git commit -m "Remove podinfo"
git push
flux reconcile kustomization flux-system --with-source
kubectl -n default get deployments

The podinfo Deployment disappears. The top-level flux-system Kustomization noticed the two files were gone, pruned the podinfo Kustomization and Source, and because that Kustomization also had prune: true, all the objects it had created went with it. The deletion cascaded down the chain.

Prune works because every Kustomization keeps an inventory of what it applied, recorded in its status, so it knows exactly what belongs to it and never deletes anything else. You can see it in the status of a Kustomization.

Protect what must never be pruned Give an object the annotation kustomize.toolkit.fluxcd.io/prune: Disabled and Flux will not garbage-collect it even when it disappears from Git. This is the usual safeguard for Namespaces and PersistentVolumeClaims that hold data.

Re-add the two files (restore them with git revert HEAD, then push) before you continue, because the following sections use podinfo again.

Try it
  1. Add the replica patch, push it, and confirm three replicas.
  2. Scale the Deployment to 1 with kubectl, then run flux reconcile kustomization podinfo and watch it return to 3.
  3. Delete the podinfo files, push, reconcile, and confirm the Deployment disappears. Restore it with git revert HEAD.
you have now seen all three behaviours that define GitOps: apply on commit, revert on drift, and delete on removal.

Deploying with Helm: HelmRepository and HelmRelease

Not everything you want to run comes as plain YAML. Much of the Kubernetes ecosystem is distributed as Helm charts, which are packaged, parameterised bundles of manifests. Instead of running helm install from your laptop, which would be imperative and leave no record in Git, you describe the release as an object and let Flux's helm-controller manage it. The chain is the same shape as before: a Source that fetches, and an object that applies.

For charts hosted in a classic Helm repository, the Source is a HelmRepository, and the applier is a HelmRelease. Let us install podinfo again, but as a Helm release in its own namespace. Create the files by hand this time, to see the YAML without a generator.

apps/podinfo-helm/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: podinfo
apps/podinfo-helm/source.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: podinfo
  namespace: podinfo
spec:
  interval: 1h
  url: https://stefanprodan.github.io/podinfo
apps/podinfo-helm/release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: podinfo
  namespace: podinfo
spec:
  interval: 10m
  chart:
    spec:
      chart: podinfo
      version: ">=6.0.0"
      sourceRef:
        kind: HelmRepository
        name: podinfo
  values:
    replicaCount: 2

Read the HelmRelease as a sentence. "Every ten minutes, make sure a release called podinfo exists in the namespace podinfo, built from the chart podinfo at any version 6.0.0 or newer taken from the HelmRepository named podinfo, with the value replicaCount set to 2." The values block is exactly what you would put in a values.yaml file for helm install -f, and you can use any value the chart supports. helm show values on a chart lists them.

The version constraint ">=6.0.0" is a semantic-version range. Flux will pick the newest matching chart and will upgrade the release when a newer one is published. For a production system you usually pin an exact version such as 6.7.1 so that upgrades happen only when you change the file. Ranges are convenient for learning and dangerous for production.

To deploy these, tell Flux to apply the folder. A Kustomization does that:

clusters/demo/apps.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  path: ./apps/podinfo-helm
  prune: true
  wait: true
  timeout: 5m
  sourceRef:
    kind: GitRepository
    name: flux-system

This Kustomization reads from flux-system, the GitRepository for your own repository, which is how it reaches the apps folder you just created. Commit and push everything, and then watch:

BASH
flux get helmreleases --namespace podinfo --watch
TEXT
NAME     REVISION  SUSPENDED  READY  MESSAGE
podinfo  6.9.0     False      True   Helm install succeeded for release podinfo/podinfo.v1 with chart podinfo@6.9.0

Read the message. helm-controller fetched the chart, installed release number one (podinfo.v1), and reports the chart version it used. When you later change replicaCount to 3 in release.yaml and push, the release becomes podinfo.v2, and the message becomes "Helm upgrade succeeded".

Some Helm behaviours in Flux 2.9 are worth knowing because older tutorials describe different ones. Flux's helm-controller uses Helm v4. New releases are installed with Kubernetes server-side apply by default, and Flux waits for readiness with the same kstatus health-checking that Kustomizations use. You do not need to configure anything for those defaults, but they explain why an upgrade might behave slightly differently from a Helm 3 tutorial.

Helm charts from OCI registries use a different Source Many charts are now published to container registries, with URLs beginning oci://. Do not use HelmRepository with type: oci for them: that mode is in maintenance and you will meet an error saying the oci URL scheme cannot be used with the default type. Use an OCIRepository and point the HelmRelease at it with chartRef. The Mid-level guide shows the full pattern.

There are two commands for Helm releases that beginners reach for constantly:

BASH
flux reconcile helmrelease podinfo --namespace podinfo --with-source
flux get helmreleases -A

And when a release is stuck after a failure, flux reconcile helmrelease podinfo -n podinfo --reset clears the failure counter so Flux tries again from scratch.

Try it
  1. Create the three files under apps/podinfo-helm/ and the apps Kustomization under clusters/demo/.
  2. Commit, push, and run flux get helmreleases -A until READY is True.
  3. Change replicaCount to 3, push, and watch the release upgrade.
  4. Run helm list -A if you have Helm installed, and see the release Flux created.
a Helm release that you never installed by hand, upgraded purely by editing a file.

Order and health: dependsOn, wait and timeouts

Real systems have ordering constraints. An application that needs a database operator cannot be applied before the operator's custom resource definitions exist, and a Deployment that needs a namespace fails if the namespace is not there yet. Inside one Kustomization, Kubernetes server-side apply and Flux handle the common case by applying namespaces and custom resource definitions first. Between Kustomizations, you express ordering explicitly with dependsOn.

clusters/demo/apps.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  path: ./apps/podinfo-helm
  prune: true
  wait: true
  timeout: 5m
  dependsOn:
    - name: infrastructure
  sourceRef:
    kind: GitRepository
    name: flux-system

Here apps will not be applied until a Kustomization named infrastructure in the same namespace is ready. The usual pattern is a first Kustomization for shared infrastructure (an ingress controller, a certificate manager) and a second for applications that depend on it. dependsOn works on HelmReleases as well. When a dependency is not ready, the dependent object does not fail permanently. It reports a message such as dependency 'flux-system/infrastructure' is not ready and tries again roughly every thirty seconds, so the order sorts itself out as the dependency becomes healthy.

Ordering only helps if "ready" means something. That is what health checking provides. With wait: true, a Kustomization does not consider itself ready once the YAML is applied. It waits until every object it applied reports healthy, using the same readiness logic Kubernetes users know: a Deployment is healthy when its new replicas are available, a Job when it has completed, and so on. If the objects are not healthy within timeout, the Kustomization becomes not ready, with a message like this:

TEXT
health check failed after 3m0s: timeout waiting for: [Deployment/default/podinfo status: 'InProgress']

Learn to read that line. It says which object, in which namespace, and the last status Flux saw, InProgress, which means the pods were still starting or failing to start. The cause is almost never Flux. It is the workload: a wrong image name, a failing readiness probe, insufficient cluster resources. The next step is kubectl describe deployment and kubectl get pods.

If you would rather not wait on everything, healthChecks lists just the objects you care about:

YAML
spec:
  wait: false
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: podinfo
      namespace: default

As a beginner, wait: true is the right default for applications: it makes flux get kustomizations tell the truth about whether the app actually works, not merely whether the YAML was accepted by the API server.

The three time settings, side by side interval is how often Flux re-checks and re-applies when things are fine. retryInterval is how long it waits before trying again after a failure (it falls back to interval if you leave it out, which can mean a long wait). timeout is the most time one attempt, including health checks, may take. Setting a shorter retryInterval than interval is the single most useful tuning for a beginner, because it makes Flux recover within minutes of you pushing a fix.
Try it
  1. Add a dependsOn entry to apps naming a Kustomization that does not exist, and push.
  2. Run flux get kustomizations and read the message.
  3. Remove the entry again and push.
a message saying the dependency was not found. That is the exact text you will see in real life when a name is misspelled.

Customising manifests: Kustomize overlays and variables

You will soon want the same application in two places with small differences: two replicas in staging and five in production, a different hostname, a different image tag. Flux supports the standard answer to that, Kustomize overlays, and a lighter tool for simple values, post-build variable substitution.

An overlay is a folder containing a kustomization.yaml (the Kustomize file this time, not the Flux one) that points at a shared base and adds changes on top. A typical repository layout looks like this:

TEXT
apps/
├── base/
│   └── podinfo/
│       ├── deployment.yaml
│       ├── service.yaml
│       └── kustomization.yaml
├── staging/
│   └── kustomization.yaml
└── production/
    └── kustomization.yaml
apps/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../base/podinfo
patches:
  - target:
      kind: Deployment
      name: podinfo
    patch: |
      - op: replace
        path: /spec/replicas
        value: 5

The staging cluster points its Flux Kustomization at ./apps/staging, the production cluster at ./apps/production, and both share the base. Edit the base once and both change. This is the pattern used in the official flux2-kustomize-helm-example repository, which is worth browsing once you are comfortable with the basics. If there is no kustomization.yaml in a folder you point Flux at, Flux generates one listing every YAML file in it, which is why the simple examples above needed no Kustomize file at all. The moment you need patches, images or overlays, you write one.

Post-build substitution covers the lighter case: replacing placeholders in otherwise identical YAML with values defined in the Flux Kustomization or in a ConfigMap. Write ${variable} where the value goes:

apps/base/podinfo/ingress.yaml
spec:
  rules:
    - host: podinfo.${cluster_domain}
YAML
# in the Flux Kustomization
spec:
  postBuild:
    substitute:
      cluster_domain: staging.example.com

There is one behaviour new in Flux 2.9 that will bite you if you follow an older tutorial. Strict substitution is now on by default. If the YAML contains ${something} and no value is supplied, the Kustomization fails instead of quietly leaving the text in place. The error reads:

TEXT
variable not set (strict mode)

You have three ways out: supply the variable, give it a default with ${something:=default}, or, for a file that legitimately contains a literal dollar-brace (a shell script inside a ConfigMap, for instance), put the annotation kustomize.toolkit.fluxcd.io/substitute: disabled on that object.

Literal ${...} in your YAML now breaks the build Before Flux 2.9, an unset variable was silently left as it was. Now it is an error. If a ConfigMap holds a script or a template that uses ${NAME}, expect variable not set (strict mode) and either escape it with the disable annotation or supply a default.
Try it
  1. Add image: ghcr.io/stefanprodan/podinfo:${tag} to a scratch Deployment manifest in a test folder, with no value for tag.
  2. Point a Kustomization at it and read the error.
  3. Fix it with ${tag:=6.7.1} and push again.
first the strict-mode error, then a healthy Deployment after the default is in place.

Suspend, resume and forcing a reconcile

There will be times you need Flux to stop applying something for a while: you are debugging in production, you are testing a manual change, or you want to freeze a release before a holiday weekend. Flux calls this suspend.

BASH
flux suspend kustomization podinfo
flux get kustomizations
TEXT
NAME     REVISION             SUSPENDED  READY  MESSAGE
podinfo  master@sha1:9c8b7a6  True       True   Applied revision: master@sha1:9c8b7a6

The SUSPENDED column turns to True, and Flux ignores that Kustomization until you resume it. While it is suspended, your manual edits survive, because there is no reconcile to revert them. This is the legitimate way to make an emergency change by hand.

BASH
flux resume kustomization podinfo

Resuming triggers an immediate reconcile. If you had changed the cluster by hand and never updated Git, this is the moment your change is reverted. The discipline is: suspend, change, put the same change in Git, resume.

The same verbs exist for other kinds. flux suspend helmrelease podinfo -n podinfo pauses a Helm release. flux suspend source git podinfo stops a Source fetching. Underneath, suspending simply sets spec.suspend: true on the object, which means you can also do it by editing the YAML in Git. In that case the pause is versioned and reviewed, which is better for anything lasting more than an hour.

A forgotten suspend is a silent outage of your pipeline A suspended Kustomization shows READY True and looks healthy, so nobody notices that new commits are not being applied. Make flux get all -A a habit, and look at the SUSPENDED column. Teams often set an alert for objects suspended longer than a day.

It helps to keep three related commands apart, because beginners mix them up.

Command What it does Changes desired state?
flux reconcile source git NAME Fetch from Git now No
flux reconcile kustomization NAME --with-source Fetch, then apply now No
flux suspend / flux resume Pause or unpause reconciliation Only the pause flag

None of the three edits what your application should look like. That edit always happens in Git.

Try it
  1. Suspend the podinfo Kustomization.
  2. Scale the Deployment to 1 with kubectl, wait a minute and confirm it stays at 1.
  3. Resume, and confirm it returns to the value in Git.
manual changes survive while suspended and vanish on resume, which is the behaviour to keep in mind during incidents.

Secrets: never put them in Git as plain text

The moment you deploy a real application, you need a database password or an API token. Flux reads manifests from Git, and Git history is permanent and widely shared, so a plain-text Secret committed to a repository is compromised from that commit onwards, even if you delete it later. Base64, which is how Kubernetes Secrets are stored, is an encoding and not encryption. Anyone who can read the repository can read it.

Flux has a built-in answer, SOPS (Secrets OPerationS), a tool that encrypts the values inside a YAML file while leaving the keys readable, so diffs and reviews still work. The encrypted file is safe to commit. kustomize-controller decrypts it in the cluster at apply time using a key that only the cluster holds. The decryption is enabled on the Kustomization:

YAML
spec:
  decryption:
    provider: sops
    secretRef:
      name: sops-age

The setup involves generating a key pair (Flux supports Age keys, among others), storing the private key in a cluster Secret called sops-age, and encrypting files with sops before committing them. The Mid-level guide walks through it. Two alternatives exist: the Sealed Secrets controller, and the External Secrets Operator, which keeps the secret in a cloud vault and only a reference in Git. Which you choose is a team decision.

For now, remember the rule and the symptom. The rule is no plaintext secrets in the repository, ever. The symptom of forgetting to configure decryption is this error when an encrypted file reaches a Kustomization that does not know how to decrypt it:

TEXT
Secret/default/db-password is SOPS encrypted, configuring decryption is required for this secret to be reconciled

If your employer has data-residency requirements, which is common in Gulf and Egyptian organisations, the key-holding side matters too: decide where the private key lives, and prefer a key held in a vault inside an approved region over a key file copied onto laptops.

Try it
  1. Search your own Git repositories for files named secret.yaml or containing password:.
  2. For each one, decide whether the value has already left your control (pushed to a shared remote).
at least one candidate worth rotating. Fix the habit before Flux makes it effortless to publish a mistake.

Notifications and webhooks: hearing from Flux, and poking it

By default, Flux tells you things only when you ask. flux get and flux events are pull interfaces. In a team you want Flux to tell you when a deploy fails, and sometimes you want Git to tell Flux immediately instead of waiting for the next poll. The notification-controller handles both directions.

Outbound: Provider and Alert. A Provider describes where messages go (Slack, Microsoft Teams, Discord, a GitHub commit status, and many more). An Alert says which events to forward.

clusters/demo/notifications.yaml
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Provider
metadata:
  name: slack
  namespace: flux-system
spec:
  type: slack
  channel: deployments
  secretRef:
    name: slack-url
---
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
  name: on-call
  namespace: flux-system
spec:
  providerRef:
    name: slack
  eventSeverity: error
  eventSources:
    - kind: Kustomization
      name: "*"
    - kind: HelmRelease
      name: "*"

Note the API version: v1beta3. Alert and Provider are still beta in Flux 2.9, which is correct and current, unlike the older v1beta1 and v1beta2 versions that were removed. The slack-url Secret holds the webhook address and must be created without committing it in plain text (see the previous section). With eventSeverity: error, only failures are sent, which keeps the channel quiet enough to be read.

Inbound: Receiver. With the default one-minute poll, a push can wait up to a minute before Flux notices. A Receiver exposes a webhook URL that GitHub (or another system) calls on every push, so Flux fetches at once. It is an optimisation, not a requirement, and it requires that the webhook endpoint be reachable from the internet or from your CI, which has its own security cost. Most beginners skip it and live happily with polling.

You can be productive without notifications Skip this section on your first pass if the course is long. Come back when a real team needs alerts. The one thing to keep in your head is the direction: a Provider and Alert send messages out, a Receiver lets messages in.
Try it
  1. Run flux create alert-provider --help and flux create alert --help to see the generators.
  2. Generate a Provider with --export for a Slack channel and read the YAML.
a Provider manifest that you could commit, and a clear sense that Flux can notify anyone, not just Slack.

Configuration you will touch, and the ones you will not

Flux has a lot of knobs, but a beginner touches a handful. This section collects them so that nothing in the YAML is a mystery.

On a GitRepository, the fields you will use are url, ref (a branch, tag, semver range or exact commit), interval, and secretRef for private repositories. The secret can be created from the CLI with flux create secret git NAME --url=ssh://git@github.com/org/repo, which prints the public key to add as a deploy key on the repository. You can also create a .sourceignore file at the repository root, in .gitignore syntax, to exclude files from the Artifact. That is the fix when a large folder of irrelevant files is slowing things down.

On a Kustomization, the fields are sourceRef, path, interval, retryInterval, timeout, prune, wait, targetNamespace, dependsOn, patches, postBuild and decryption, all of which you have now met. The field suspend pauses the object. Two more are worth reading about later: serviceAccountName for running with restricted permissions, and force: true for recreating objects when an immutable field changes.

On a HelmRelease, you use chart (or chartRef), values, interval, targetNamespace and dependsOn. The remediation settings are the ones to understand early. A HelmRelease can be told to retry a failed install or upgrade and, on upgrade failure, to roll back:

YAML
spec:
  install:
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3

Without retries, a failed install stays failed until you intervene. With them, Flux tries again. If all the retries fail, the message contains install retries exhausted or upgrade retries exhausted, and you fix the cause and clear it with flux reconcile helmrelease NAME --reset, or by suspending and resuming.

Intervals deserve a rule of thumb. Sources should poll often enough that a push appears quickly (1m for your own Git repository is typical). Helm repositories, which rarely change, are fine at 1h. Kustomizations that apply your YAML can run every 10m to 30m, since that interval is mostly for drift correction. Shorter intervals are not free: each poll is a request to your Git host, and on a busy organisation with many objects the requests add up and can trip API rate limits.

Everything else, such as controller flags, feature gates, sharding and workload identity, is a platform-engineer concern and is covered in the Senior guide.

Try it
  1. Run kubectl -n flux-system get kustomization flux-system -o yaml.
  2. Find the spec fields you recognise, and the status block with conditions and lastAppliedRevision.
the same fields you wrote, with Flux's recorded results underneath. The object is both your instruction and its receipt.

The errors you will see, and how to read them

Flux errors fall into a few families. Once you recognise the family, you know where to look. Start every investigation the same way:

BASH
flux check
flux get all -A --status-selector ready=false
flux events -A
flux logs --level=error -A

The first confirms the controllers themselves are healthy. The second lists every object that is not ready. The third and fourth show the recent history and the error logs. Usually the answer is now on your screen. Here are the messages beginners meet most, with what they mean.

Message Cause Fix
no matches for kind "HelmRelease" in version "helm.toolkit.fluxcd.io/v2beta1" The YAML uses an API version removed in Flux 2.7 or later Change apiVersion to the current one, or run flux migrate
kustomization path not found: ... spec.path does not exist in the fetched repository Correct the path, relative to the repository root
Source artifact not found, retrying in ... The Source is not ready yet, or failed Run flux get sources all -A and fix the Source
dependency 'flux-system/infra' is not ready A dependsOn target is failing or missing Fix the dependency; Flux retries about every 30 seconds
health check failed after 3m0s: timeout waiting for: [Deployment/...] The workload did not become ready in time kubectl describe and kubectl logs the pods, then fix the workload
variable not set (strict mode) A ${var} has no value (Flux 2.9 default) Supply it, give a default ${var:=x}, or disable substitution on that object
... is SOPS encrypted, configuring decryption is required An encrypted file reached a Kustomization with no decryption Add spec.decryption with provider: sops
install retries exhausted A Helm install failed repeatedly Fix the chart or values, then flux reconcile hr NAME --reset
field is immutable / immutable field detected You changed a field Kubernetes forbids changing, such as a Job's template Set force: true on the Kustomization, or the annotation kustomize.toolkit.fluxcd.io/force: Enabled
authentication required or knownhosts: key mismatch Wrong deploy key, token or host key Recreate the secret with flux create secret git, and check token scopes

Two of these teach a broader lesson. The no matches for kind error looks like a Kubernetes problem, but it is a tutorial-age problem: the manifest was written for an older Flux. Flux 2.7 removed the v1beta1 APIs, 2.8 removed source.toolkit.fluxcd.io/v1beta2, kustomize.toolkit.fluxcd.io/v1beta2 and helm.toolkit.fluxcd.io/v2beta2, and 2.9 removed the v1beta2 versions of the image and notification APIs. The current versions are below. Keep the table near you, because you will paste-check other people's YAML against it for years.

Kind Current apiVersion
GitRepository, OCIRepository, HelmRepository, Bucket source.toolkit.fluxcd.io/v1
Kustomization kustomize.toolkit.fluxcd.io/v1
HelmRelease helm.toolkit.fluxcd.io/v2
Provider, Alert notification.toolkit.fluxcd.io/v1beta3
Receiver notification.toolkit.fluxcd.io/v1
ImageRepository, ImagePolicy, ImageUpdateAutomation image.toolkit.fluxcd.io/v1

The other lesson is in field is immutable. Kubernetes refuses some edits, and Flux faithfully reports the refusal. The fix is a deliberate choice between recreating the object (force) and leaving it alone, and you should make that choice knowingly, since recreating a Job or a StatefulSet-adjacent object can interrupt work.

Preview before you push flux diff kustomization podinfo --path ./kustomize shows what a Kustomization would change in the cluster, using files on disk, before you commit. And flux build kustomization podinfo --path ./kustomize prints the rendered YAML. Both catch path and patch mistakes on your laptop rather than in the cluster. They need the Kustomization to exist in the cluster already, so use them for changes to existing apps.
Try it
  1. Break something on purpose: change the podinfo Kustomization path to ./kustomise and push.
  2. Read the message in flux get kustomizations.
  3. Fix the typo and push. Time how long Flux takes to recover and think about which interval governed it.
"kustomization path not found" first, then recovery at the next retry. Break-and-fix in a safe cluster is the fastest way to stop fearing the messages.

Removing Flux cleanly

Learning is easier when you know how to start over. Flux has a proper uninstall command, and it is the only supported way to remove it.

BASH
flux uninstall

The command asks for confirmation, then removes the controllers, their network policies and roles, the finalizers, the custom resource definitions and the custom resources, and finally the flux-system namespace. Add --keep-namespace if you want the namespace to stay. What it does not remove is the workloads that Flux deployed. Your podinfo Deployments keep running, unmanaged, because pruning them would be a dangerous surprise. If you want those gone too, delete them with kubectl, or delete the Kustomizations first, with pruning on, and wait for them to clean up.

Do not remove Flux with kubectl delete Deleting the namespace or the controllers by hand leaves custom resources with finalizers that nothing is left to process, and the namespace can hang in Terminating. The documentation states that removing Flux with kubectl is not supported. Use flux uninstall.

Uninstalling does not touch your Git repository. The fleet-infra repository still has gotk-components.yaml and the rest, and running flux bootstrap again with the same flags will bring everything back and reapply all your applications. That is a good demonstration of the GitOps claim that the repository is the cluster. With a local kind cluster, you can also delete the whole thing with kind delete cluster --name flux-demo and rebuild it in a couple of minutes.

Try it
  1. Run flux uninstall on your practice cluster and confirm.
  2. Run kubectl get namespaces and kubectl get crds | grep fluxcd to check Flux is gone.
  3. Run the same flux bootstrap github command again and watch your applications come back.
Flux returns, finds the repository already populated, and recreates podinfo without any instruction from you. The cluster is rebuilt from Git alone.

Putting it all together

Let us finish with one small project that uses everything: a fresh repository, two applications deployed in a safe order, one from plain YAML and one from Helm, a change made by commit, and a failure you diagnose. The steps assume a new disposable cluster.

  1. PrepareCreate a kind cluster, export GITHUB_TOKEN and GITHUB_USER, and run flux check --pre.
  2. BootstrapRun flux bootstrap github with repository platform-demo and path clusters/demo.
  3. Lay out the repositoryCreate infrastructure/ and apps/ folders, plus Kustomizations under clusters/demo/ that point at them.
  4. Commit and watchPush, then watch flux get kustomizations until everything is ready.
  5. Change somethingEdit a value in Git, push, and watch it roll out.
  6. Break it, then read itIntroduce a typo and diagnose it from flux get and flux events.

The target layout:

TEXT
platform-demo/
├── clusters/demo/
│   ├── flux-system/          (created by bootstrap)
│   ├── infrastructure.yaml   (Flux Kustomization -> ./infrastructure)
│   └── apps.yaml             (Flux Kustomization -> ./apps, depends on infrastructure)
├── infrastructure/
│   ├── kustomization.yaml
│   └── namespace.yaml
└── apps/
    ├── kustomization.yaml
    ├── podinfo-source.yaml
    └── podinfo-release.yaml

The two Flux Kustomizations, which are the glue:

clusters/demo/infrastructure.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: infrastructure
  namespace: flux-system
spec:
  interval: 10m
  retryInterval: 1m
  path: ./infrastructure
  prune: true
  wait: true
  timeout: 3m
  sourceRef:
    kind: GitRepository
    name: flux-system
clusters/demo/apps.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  retryInterval: 1m
  path: ./apps
  prune: true
  wait: true
  timeout: 5m
  dependsOn:
    - name: infrastructure
  sourceRef:
    kind: GitRepository
    name: flux-system

The infrastructure folder creates a namespace for the application. Its Kustomize file lists the resources:

infrastructure/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: demo
  annotations:
    kustomize.toolkit.fluxcd.io/prune: Disabled
infrastructure/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml

The prune annotation protects the namespace from accidental garbage collection, which is the habit you learned earlier for anything that can hold data. The application folder holds the Helm Source and release, and a Kustomize file that lists both:

apps/podinfo-source.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: podinfo
  namespace: demo
spec:
  interval: 1h
  url: https://stefanprodan.github.io/podinfo
apps/podinfo-release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: podinfo
  namespace: demo
spec:
  interval: 10m
  install:
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3
  chart:
    spec:
      chart: podinfo
      version: "6.x"
      sourceRef:
        kind: HelmRepository
        name: podinfo
  values:
    replicaCount: 2
    ui:
      message: "Hello from Git"
apps/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - podinfo-source.yaml
  - podinfo-release.yaml

Commit and push, then watch both layers come up in order:

BASH
git add -A
git commit -m "Add infrastructure and podinfo via Helm"
git push
flux reconcile kustomization flux-system --with-source
flux get kustomizations --watch

You should see infrastructure become ready first, then apps, because of dependsOn. Then confirm the release and look at the page:

BASH
flux get helmreleases -n demo
kubectl -n demo port-forward deployment/podinfo 9898:9898

The page should show "Hello from Git", which came from your values. Now change the message in apps/podinfo-release.yaml, push, reconcile and refresh the page. The release upgrades to v2 without you running any Helm command.

To practise diagnosis, introduce a deliberate mistake. Change the chart name to podinfoo, push, and reconcile. Then follow the method from the error section:

BASH
flux get all -A --status-selector ready=false
flux events -n demo --for HelmRelease/podinfo

You will see the HelmRelease not ready with a message about the chart not being found, and a HelmChart object failing in flux-system or demo. The error is on the Source side of the chain, because a chart that cannot be located is a fetch failure. Fix the name, push, and it recovers by itself.

The project touches every idea in the guide: bootstrap, Git as the source of truth, Kustomizations as glue, ordering with dependsOn, a Helm release, drift protection through prune and intervals, and the debugging ritual. If you can build it from memory, you have the beginner level.

Try it
  1. Build the project above on a fresh cluster without copying from the guide. Use the guide only when stuck.
  2. Delete apps/podinfo-release.yaml from the resources list and from disk, push, and confirm the release is removed.
  3. Delete the whole cluster and rebuild it with one flux bootstrap command. Time how long it takes to reach a running application.
a working stack that is rebuilt from a single command, with every change recorded as a commit. That is the practical meaning of GitOps.

What you can now do, and what comes next

You can now explain GitOps and why pull beats push. You can install the flux CLI, bootstrap a cluster from a Git repository, and describe the files bootstrap creates. You can connect a Git Source to a Kustomization, deploy a Helm chart with a HelmRepository and a HelmRelease, order work with dependsOn, and use wait and timeout so that "ready" means "working". You can read flux get, flux events and flux logs to tell which stage failed, recognise the most common error messages, and spot an outdated API version in somebody else's YAML. You know how to suspend and resume safely, why manual edits disappear, and why secrets never go into Git in plain text.

What you have not yet done is the work that turns this from a demo into a production setup. The Mid-level guide goes under the hood: how reconciliation really works, OCI artifacts and "Gitless" delivery, SOPS encryption step by step, Helm values from Secrets and ConfigMaps, image automation that commits new tags, notifications and webhooks in depth, multi-environment repository layouts, and debugging beyond the basics. The Senior guide treats Flux as a platform: multi-tenancy and impersonation, scaling and sharding, the security model, upgrades and API migrations (including the flux migrate procedure mentioned above), monitoring, and when another tool is the better choice.

Flux sits in a chain of tools in this catalogue. It deploys onto Kubernetes (/student-guides/kubernetes), very often packages from Helm (/student-guides/helm), and is often used together with Kustomize (/student-guides/kustomize) and provisioned infrastructure from Terraform (/student-guides/terraform). The main alternative that many employers run instead is Argo CD (/student-guides/argo-cd), which is worth reading to understand the trade-offs in an interview. Container basics are in /student-guides/docker.

A practical next step for this week: take one real application you know, write it as plain YAML or a Helm release, and deploy it to a practice cluster entirely through a Flux repository. Do not run kubectl apply once. The first time you resist the temptation and fix a problem through a commit, the pull-based habit starts to feel natural, and that habit is what the next two levels build on.

Sources