← All Posts
KubernetesGitOpsFlux

Modernizing Application Deployment to Kubernetes with Flux and GitLab KAS

Steve Searcy · Jun 10, 2026 · 10 min read

Most teams we meet didn’t set out to build a fragile deployment pipeline. It happened gradually: a kubectl apply here, a CI job with cluster credentials there, a handful of Helm releases nobody quite remembers installing. By the time we get involved, nobody can say with confidence what’s actually running in the cluster versus what’s declared in the repo. That drift is the problem GitOps was built to solve, and Flux paired with GitLab’s Kubernetes Agent Server (KAS) is our default answer when a client is ready to fix it properly.

When application deployments live inside the Terraform run

A lot of the clusters we inherit didn’t get here through neglect — they got here because the application was being deployed the same way the infrastructure was: through Terraform. It’s an understandable starting point if Terraform is already how you provision the cluster, the VPC, and the database, but it quietly welds two things together that need to change on completely different schedules. A one-line image tag bump for a hotfix now has to go through the same plan/apply cycle as a VPC peering change, run by the same pipeline, gated by the same approvals, and capable of being blocked by the same unrelated drift.

That coupling cuts both ways. An application-only code change — nothing infrastructure-related at all — could trip a stale state lock, an unrelated provider version bump, or a drifted resource somewhere else in the same Terraform root module, and now a routine deploy is stuck behind an infrastructure problem the application team didn’t cause and can’t fix. And the reverse is just as bad: a genuinely urgent hotfix has to wait on a full Terraform plan/apply against the entire stack, when all it needed was a new container image running. Neither team owns a clean, fast path to ship their own changes independently, and “an unrelated infra pipeline broke my hotfix” is not a sentence any team should have to say during an incident.

Separating “what infrastructure exists” from “what’s currently deployed” is most of what this migration is actually about. Terraform still provisions the cluster itself, the networking, the managed databases — the things that genuinely change on an infrastructure cadence. What it stops doing is deploying the application, because that’s a Git commit to the GitOps repo now, reconciled by Flux on its own cadence, with its own approval path, entirely decoupled from whatever the infrastructure pipeline happens to be doing that day.

Why pull-based deployment wins

Traditional CI/CD pushes changes into a cluster: a pipeline job authenticates with a service account, runs kubectl apply or helm upgrade, and hopes nothing drifts before the next run. That means every CI runner needs broad, long-lived credentials to every cluster it touches — a sprawling blast radius if a pipeline is ever compromised, and a real headache for network policy when clusters live behind private networking.

Flux flips the model. A controller running inside the cluster continuously reconciles what’s actually deployed against what’s declared in a Git repository. Nothing outside the cluster ever holds credentials to it. If someone hand-edits a Deployment during an incident, Flux notices the drift on its next reconciliation loop and quietly puts it back — which turns “someone changed prod and we don’t know why” from a recurring incident into a non-event.

Where GitLab KAS fits in

The piece that’s easy to miss is the network path. Flux still needs to reach the cluster’s API server to reconcile, and for teams already standardized on GitLab, exposing that API server directly — even behind a bastion — is one more thing to lock down and audit. GitLab’s Kubernetes Agent Server solves this by running an agent inside the cluster that opens an outbound, mutually authenticated tunnel back to GitLab. There’s no inbound port to open on the cluster side at all.

We typically deploy KAS in one of two modes depending on the engagement:

  • Agent-initiated pull — the in-cluster agent connects to GitLab and pulls manifests directly, useful for smaller clusters where GitLab is the sole source of truth.
  • KAS as the trusted proxy — Flux talks to the cluster API through the KAS tunnel, letting us keep our existing Flux toolchain and reconciliation model while still avoiding a publicly reachable API endpoint.

For most clients running multiple clusters, we lean toward the second pattern: it keeps Flux as the single reconciliation engine across every environment, while KAS handles the transport securely per cluster.

Operational visibility without leaving GitLab

KAS isn’t only a secure tunnel — it’s also what powers the operational visibility GitLab surfaces directly in its environment and Kubernetes dashboard views. Once an agent is registered against a cluster, a developer can see exactly what’s running for their application without leaving GitLab: the namespace, the pods and services behind it, live log access, Flux’s reconciliation status for that deployment, and the raw Kubernetes events feed — the same events a kubectl describe would surface, including the ones that actually explain most incidents, like CrashLoopBackOff, ImagePullBackOff, a failing readiness probe, or a pod stuck pending on scheduling.

That matters more than it sounds like on paper. A developer debugging a bad deploy can answer “why isn’t my pod starting” without opening a terminal, requesting cluster access, or learning kubectl first — the answer is sitting in the same GitLab UI they already use to open the merge request. That’s the real unlock for teams adopting Kubernetes for the first time: they get the reliability and scaling model of a real container platform without the platform team first having to teach every application developer the full depth of Kubernetes internals just so they can self-serve a debugging session.

Bootstrapping Flux for a tenant and environment

Turning Flux on for a new application isn’t a cluster-wide event — it’s two small objects scoped to that tenant and environment. A GitRepository source resource declares where the manifests live (URL, branch or tag, polling interval); a Kustomization resource then points a sourceRef at that GitRepository, sets path to the tenant/environment’s directory in the repo — something like ./tenants/acme/production — and sets targetNamespace to that tenant’s namespace, with prune: true so anything removed from Git gets removed from the cluster on the next reconciliation instead of lingering forever.

Scoping it this way is what makes the multi-tenant model hold together operationally, not just organizationally. Each tenant/environment pair gets its own Kustomization, which means its own independent reconciliation status, its own health checks, and its own failure domain — a bad manifest in one tenant’s staging Kustomization shows up as a failed condition on that object alone, visible in GitLab’s Kubernetes dashboard for that project, and has no way to block or mask reconciliation for anyone else’s environment. Ordering between them, where it’s needed — a platform-baseline Kustomization that has to land before an application one, for example — is handled with dependsOn rather than by hoping things apply in the right order.

Packaging with Helm and letting helm-controller own the lifecycle

Every application we deploy this way is packaged as a Helm chart, but the helm CLI never runs anywhere in the pipeline. Instead, a HelmRelease custom resource per tenant/environment declares the chart source (an OCIRepository or HelmRepository), the chart version, and a values block — which is where that environment’s configuration, including the container image tag, actually lives. Flux’s helm-controller — the direct successor to what used to ship as a standalone Flux Helm Operator — reconciles that HelmRelease straight against the Helm SDK: it installs the release if it doesn’t exist, upgrades it when the chart version or values drift from what’s declared, and uninstalls it cleanly if the HelmRelease itself is deleted. There’s no Tiller, no CLI invocation a human could get subtly wrong, and no gap between “what’s declared” and “what Helm thinks is installed” — helm-controller’s own release state and Flux’s Git source are the same source of truth.

What the repo structure looks like in practice

We structure the GitOps repo around environments, not applications — a clusters/production, clusters/staging layout, each pointing at a set of Kustomize overlays or Helm releases defined once and promoted between environments via pull request. Promotion becomes a Git operation: merge the PR that bumps the image tag or values file in clusters/production, and Flux takes it from there. No pipeline stage holds cluster credentials, and the full history of what changed, when, and who approved it lives in Git rather than scattered across CI logs.

Image updates are handled by Flux’s image automation controllers, which watch a container registry, open a PR when a new tag matching our policy appears, and let a human (or an automated policy, for lower environments) merge it. Combined with KAS-brokered access, the entire path from “new image pushed” to “running in production” is auditable end to end without a single long-lived credential outside the cluster.

Validating a deployment against its commit SHA in GitLab

Put the whole path together — from a code change to a confirmed, healthy deployment — and it looks like this:

Deployment flow from a code commit through image build, a synthetic GitOps commit, Flux reconciliation, and a commit status posted back to GitLab

That merged PR is a synthetic commit — nobody hand-typed it, it exists purely to bump a HelmRelease’s image tag in the GitOps repo — and it’s also the thing we validate the actual deployment against. Once source-controller picks it up, we want an answer to one question back in GitLab: did that specific commit actually reconcile successfully in the cluster, or is it sitting there broken?

We get that answer with two notification-controller resources. A Provider of type: gitlab points at the GitOps project (by project ID, since GitLab’s v4 API requires it over the project path) and references a Secret holding a GitLab personal access token with api scope. An Alert then sets eventSources to the Kustomization (and, for chart-level failures specifically, the HelmRelease) covering that tenant and environment. Whenever either object’s reconciliation state changes, notification-controller reads the revision — the same commit SHA that triggered the reconcile — off the object’s status and posts a commit status to GitLab against that exact SHA.

The result is that the synthetic commit which bumped the tag ends up carrying its own pass/fail check directly in GitLab: green if Flux applied it and the resulting HelmRelease came up healthy, red if it didn’t. Nobody has to query the cluster, tail a Flux log, or ask in Slack whether a deploy landed — the same commit that triggered it tells you, in the same tool the change was made in.

The payoff

The technology here isn’t the point — teams don’t hire us because they want Flux, they hire us because they want to stop debugging why staging and production have quietly diverged, stop having application hotfixes held hostage by an unrelated Terraform plan, and stop worrying about which CI runner holds which cluster’s keys. GitOps with Flux and GitLab KAS gets us there: application deployments decoupled from infrastructure changes, a per-tenant and per-environment GitRepository/Kustomization boundary instead of one shared blast radius, HelmRelease lifecycles owned entirely by helm-controller, credentials that never leave the cluster, a rollback story that’s just git revert, and a commit-status check back in GitLab that tells you definitively whether a deploy landed — all without every developer needing to become a Kubernetes expert first.