GitOps with Flux and ArgoCD: Declarative Deployments Driven by Git
There is a moment every Kubernetes operator eventually hits. You have a cluster running a dozen services. Things are humming along. Then you ssh into a node, run kubectl apply -f to push a quick fix, and forget to update the YAML file in your repo. Three weeks later, someone (probably you) wonders why the running deployment doesn’t match the manifest on disk. The cluster has drifted from what you think it is.
GitOps is the answer to that problem. More precisely, it is a set of operating principles that use git as the single source of truth for your cluster’s desired state. You stop pushing changes to the cluster directly. Instead, you push changes to a git repository, and an operator running inside the cluster pulls those changes and applies them. The cluster continuously reconciles itself toward whatever git says it should be.
The two dominant tools for this in the Kubernetes ecosystem are Flux v2 and ArgoCD. They approach the same problem from different angles — Flux is lean, CLI-first, and Kubernetes-native; ArgoCD is feature-rich, UI-forward, and opinionated about application structure. Both are CNCF-graduated or incubating projects with production use at scale.
This guide covers GitOps from first principles through full production-ready setups for both tools, with complete YAML manifests and working commands throughout.
What is GitOps?
GitOps was coined by Weaveworks in 2017, but the underlying idea — infrastructure as code managed through version control — predates the term. What GitOps adds is precision: it defines exactly what “git as source of truth” means through four concrete principles, formalized by the OpenGitOps project.
The Four GitOps Principles
1. Declarative. The entire desired state of the system must be expressed declaratively. You describe what you want, not the steps to get there. Kubernetes YAML is inherently declarative. A Deployment says “I want three replicas of this container image running” — not “run these three kubectl commands in this order.”
2. Versioned and immutable. The desired state is stored in a version control system (git) in a way that preserves history and makes any version recoverable. Every change is a commit. The full history of your infrastructure is the full history of your repo. Rollback becomes git revert.
3. Pulled automatically. Changes are pulled by software agents running inside the system, not pushed from an external CI pipeline. This is the critical inversion. In traditional CI/CD, your pipeline has credentials to reach into your cluster. In GitOps, the cluster reaches out to git. The cluster’s blast radius for credential compromise shrinks significantly.
4. Continuously reconciled. Automated agents continuously observe the actual state of the system, compare it to the desired state in git, and correct any divergence. If someone runs kubectl scale deployment myapp --replicas=5 on a cluster managed by Flux, Flux will detect the drift and scale it back to whatever git says. Drift is corrected automatically.
GitOps vs Traditional CI/CD: The Push vs Pull Model
In a traditional push-based pipeline:
Developer → git push → CI builds image → CI runs kubectl apply → Cluster
↑
Cluster credentials live here
Your CI runner (Jenkins, GitHub Actions, GitLab CI) holds a kubeconfig with permissions to modify your cluster. Every pipeline that needs to deploy to production has access to production credentials. The cluster is passive — it waits to be told what to do.
In a GitOps pull-based model:
Developer → git push → GitOps operator (in-cluster) polls git → Operator applies
↑
Cluster credentials never leave cluster
Your CI pipeline still builds and publishes container images, but it no longer touches the cluster. It might update an image tag in a git repo (or an automation controller like Flux’s image-automation-controller does this for you), which triggers the GitOps operator to apply the change.
The Benefits in Practice
Audit trail by default. Every change to the cluster is a git commit with an author, timestamp, and message. git log is your change log. No more wondering who scaled that deployment at 2am last Thursday.
Rollback via git revert. Reverting a bad deployment is the same operation as reverting a bad code change. The GitOps operator sees the revert commit and drives the cluster back to the previous state.
No direct cluster access for deployments. Your developers and CI systems don’t need cluster credentials to deploy. They need write access to a git repository. That’s a much smaller, more auditable permission surface.
Drift detection and self-healing. Any change made directly to the cluster — accidental or intentional — is detected and corrected. The cluster always converges toward what git says.
When GitOps Makes Sense (and When It Doesn’t)
GitOps shines for long-running services you want to protect from drift: your core application deployments, ingress rules, certificate issuers, storage classes, RBAC configurations. Anywhere that “what’s running should always match what’s in git” is an important invariant.
It’s less useful for one-off jobs, debugging sessions, or workloads that are intentionally ephemeral. The reconciliation loop will fight you if you’re trying to run a quick kubectl debug pod or scale something temporarily during an incident. Most GitOps tools have a suspend mechanism for these situations.
Flux vs ArgoCD: Choosing Your Tool
Both Flux and ArgoCD solve the same problem, but their philosophies diverge enough that choosing one over the other matters for how your team works.
Flux v2
Flux v2 is a complete rewrite of the original Flux, built from the ground up on Kubernetes controller-runtime. It is a collection of small, composable controllers rather than a single monolithic operator.
Core components:
- source-controller: Watches git repositories, Helm repositories, OCI registries, and S3 buckets. Fetches and stores artifacts locally for other controllers to consume.
- kustomize-controller: Applies Kustomize overlays from git. The primary controller for raw manifest deployments.
- helm-controller: Manages Helm releases declaratively. Handles install, upgrade, rollback, and uninstall based on HelmRelease CRDs.
- notification-controller: Sends alerts to Slack, Discord, Teams, PagerDuty, and others. Also receives webhooks from git providers to trigger immediate reconciliation.
- image-automation-controller (optional): Scans container registries, detects new image tags matching a policy, and automatically commits updated image references back to git.
Flux strengths:
- Extremely lightweight. The full stack runs in around 200MB of memory.
- Excellent native support for both Helm and Kustomize.
- Image automation is a first-class feature.
- Progressive delivery via Flagger (separate but tightly integrated).
- Strong multi-tenancy model via namespace isolation and per-team service accounts.
- CLI (
flux) is powerful and scriptable.
ArgoCD
ArgoCD models deployments as “Applications” — a combination of a source (git repo + path), a destination (cluster + namespace), and a sync policy. Its web UI is a first-class citizen, not an afterthought.
Core components:
- application-controller: The reconciliation engine. Watches Applications, computes diffs against live cluster state, and drives sync.
- repo-server: Fetches and renders manifests from git. Handles Helm templating, Kustomize, jsonnet, and raw YAML.
- api-server: Exposes the REST/gRPC API consumed by the UI and CLI.
- dex (optional but common): An OIDC provider for SSO integration with GitHub, Google, Okta, and others.
- redis: Caches rendered manifests and application state.
ArgoCD strengths:
- Rich visual diff view showing exactly what would change on sync.
- Manual sync mode — perfect for teams that want “propose, review, approve, sync” workflows.
- Strong SSO and RBAC story out of the box.
- Single ArgoCD instance can manage multiple downstream clusters.
- App of Apps and ApplicationSet patterns allow scalable bootstrapping.
- The UI is genuinely useful for incident response and debugging.
Comparison at a Glance
| Feature | Flux v2 | ArgoCD |
|---|---|---|
| Primary interface | CLI / YAML | Web UI + CLI |
| CNCF status | Graduated | Graduated |
| Helm support | HelmRelease CRD | Application CRD |
| Kustomize support | Native (kustomize-controller) | Native (repo-server) |
| Image automation | Built-in controller | Requires external tool or Argo Image Updater |
| Multi-tenancy | Namespace + service account isolation | Projects + RBAC |
| Multi-cluster | One Flux per cluster | One ArgoCD manages many clusters |
| Progressive delivery | Flagger integration | Argo Rollouts (separate project) |
| Resource footprint | ~200MB RAM | ~500MB-1GB RAM |
| UI | Minimal (Weave GitOps, separate) | Rich built-in dashboard |
Recommendation: If your team is comfortable living in the terminal and prefers YAML over UIs, Flux’s composable model is elegant and powerful. If your team wants visibility into deployment state, manual approval gates, or you’re onboarding developers who aren’t Kubernetes experts, ArgoCD’s UI pays dividends quickly.
Repository Structure Strategies
Before touching either tool, you need to decide how to organize your GitOps repository.
Monorepo vs Polyrepo
Monorepo puts all cluster definitions, applications, and infrastructure in a single git repository. This is the most common approach for teams managing a small number of clusters, and it’s what the Flux and ArgoCD documentation recommend for most cases. Everything is in one place, cross-cutting changes are a single PR, and you have one history to query.
Polyrepo separates concerns across multiple repositories — perhaps one repo per application team, with a separate “platform” repo for cluster infrastructure. This works well when you need strong access control between teams (team A should not be able to change team B’s deployments). Both Flux and ArgoCD support multi-repo setups natively.
Recommended Monorepo Layout for Flux
The Flux maintainers recommend this structure, which cleanly separates cluster-level config from application manifests:
gitops-repo/
├── clusters/
│ ├── production/
│ │ ├── flux-system/ # Flux bootstrap manifests (auto-generated)
│ │ ├── apps.yaml # Kustomization pointing to apps/overlays/production
│ │ └── infrastructure.yaml # Kustomization pointing to infrastructure/
│ └── homelab/
│ ├── flux-system/
│ ├── apps.yaml
│ └── infrastructure.yaml
├── apps/
│ ├── base/
│ │ ├── myapp/
│ │ │ ├── deployment.yaml
│ │ │ ├── service.yaml
│ │ │ ├── ingress.yaml
│ │ │ └── kustomization.yaml
│ │ └── another-app/
│ └── overlays/
│ ├── production/
│ │ └── myapp/
│ │ ├── kustomization.yaml # patches base with prod values
│ │ └── replicas-patch.yaml
│ └── homelab/
│ └── myapp/
│ └── kustomization.yaml
└── infrastructure/
├── controllers/
│ ├── cert-manager/ # HelmRelease for cert-manager
│ ├── ingress-nginx/ # HelmRelease for ingress-nginx
│ └── kustomization.yaml
└── configs/
├── cluster-issuers/ # ClusterIssuer for Let's Encrypt
├── storage-classes/
└── kustomization.yaml
The clusters/ directory is the entry point — it tells Flux what to deploy and in what order. The apps/ and infrastructure/ directories are shared manifests that the cluster-level Kustomizations reference.
The Bootstrap Chicken-and-Egg Problem
There is an inherent bootstrapping problem in GitOps: how does the first deployment happen? The GitOps operator needs to be in the cluster before it can deploy anything from git, including itself.
Both Flux and ArgoCD solve this with a bootstrap command. You run the bootstrap once from your local machine (with cluster credentials), the tool installs itself, and from that point on it manages its own updates from git. The bootstrap command commits the initial manifests to your repo, so future updates to the GitOps operator itself are also git-driven.
Secrets in GitOps
The elephant in the room: secrets. You cannot commit plaintext secrets to git, even in private repositories. The three dominant approaches are:
- SOPS + age: Encrypt secret files before committing. The operator decrypts in-cluster using a private key stored as a Kubernetes secret.
- Sealed Secrets: A controller in your cluster holds a key pair. You encrypt secrets with the public key using
kubeseal. The encryptedSealedSecretis safe to commit. The controller decrypts it and creates the realSecret. - External Secrets Operator: Syncs secrets from an external vault (HashiCorp Vault, AWS SSM, Azure Key Vault, 1Password) into Kubernetes Secrets. Nothing secret ever touches git.
We’ll cover all three in the secrets section below.
Flux: Complete Setup Walkthrough
Installing the Flux CLI
On macOS:
|
|
On Linux (or any platform via curl):
|
|
Verify the install:
|
|
Prerequisites Check
Before bootstrapping, confirm your cluster meets requirements:
|
|
This checks Kubernetes version, API group availability, and that you have cluster-admin permissions. A clean output looks like:
► checking prerequisites
✔ Kubernetes 1.28.5 >=1.26.0-0
✔ prerequisites checks passed
Bootstrapping with GitHub
Bootstrap creates a GitHub repository (or uses an existing one), installs Flux into your cluster, generates a deploy key, and commits the initial Flux manifests to the repo. From that point on, Flux manages itself.
|
|
What happens under the hood:
- Flux creates (or uses) the
gitops-reporepository in your GitHub account. - It installs the Flux controllers into the
flux-systemnamespace. - It generates an SSH deploy key and adds it to the repository with read access.
- It commits the Flux component manifests to
clusters/homelab/flux-system/in your repo. - The Flux controllers start reconciling from that path.
For GitLab:
|
|
Verify the bootstrap:
|
|
Expected output:
NAME REVISION SUSPENDED READY MESSAGE
flux-system main/a1b2c3d False True Applied revision: main/a1b2c3d
Core Flux Resources
Flux exposes its functionality through Kubernetes CRDs. Understanding these four is enough to build anything.
GitRepository — defines a git source. Flux polls this on the specified interval and makes the fetched content available to other controllers.
|
|
Kustomization — applies a path in a GitRepository using Kustomize. This is the primary reconciliation unit for raw manifests.
|
|
HelmRepository — a Helm chart repository source.
|
|
HelmRelease — declares a Helm chart deployment with values.
|
|
Complete Example: Deploying an App with Flux
Imagine you have a simple web application called myapp. Here is the complete set of manifests and Flux resources to deploy it.
The application manifests in apps/base/myapp/:
|
|
|
|
|
|
|
|
The Flux Kustomization in clusters/homelab/apps.yaml:
|
|
Push these files to git. Flux polls every minute by default, or you can trigger immediately:
|
|
Image Automation: Auto-Updating Image Tags
Flux can watch a container registry, detect new image tags matching a policy, and automatically commit the updated tag back to your git repo.
|
|
|
|
|
|
In your deployment manifest, mark the image tag with a Flux marker comment:
|
|
When Flux detects a new tag matching >=1.0.0, it updates the tag in the file and pushes the commit. Flux then sees that commit and applies the updated deployment.
Notifications: Alerting on Flux Events
|
|
ArgoCD: Complete Setup Walkthrough
Installing ArgoCD
|
|
Wait for all pods to come up:
|
|
Get the initial admin password:
|
|
Install the ArgoCD CLI:
|
|
Expose the ArgoCD API server and log in:
|
|
For production, expose via Ingress instead of port-forward:
|
|
The Application CRD
The Application is ArgoCD’s core resource. It binds a git source to a destination cluster namespace and describes how to sync.
|
|
With automated.selfHeal: true, ArgoCD becomes truly declarative — any drift from the git state is corrected automatically, within the default 3-minute reconciliation interval.
App of Apps Pattern
The App of Apps pattern solves the bootstrapping problem elegantly. You create one “root” Application that manages a directory of other Application manifests. That root Application bootstraps everything else.
The repo structure:
gitops-repo/
└── argocd/
├── root.yaml # the root Application (applied once by hand)
└── apps/
├── myapp.yaml
├── another-app.yaml
└── monitoring.yaml
The root Application:
|
|
An individual app Application managed by root:
|
|
Bootstrap once:
|
|
From that point on, adding a new application means adding a new YAML file to argocd/apps/. ArgoCD picks it up automatically.
Helm Application in ArgoCD
Deploying a Helm chart via ArgoCD is straightforward — you point the Application at the chart and provide values inline:
|
|
You can also reference values from a separate git repo by using multiple sources:
|
|
ApplicationSet: Templating Multiple Applications
ApplicationSet is an ArgoCD controller (bundled since ArgoCD 2.3) that generates multiple Application resources from a template. This is essential for managing many clusters or many apps.
Git generator — creates one Application per directory in a git path:
|
|
Any directory added under apps/overlays/homelab/ automatically gets an Application created for it. No manual Application manifest needed.
List generator — creates Applications from an explicit list:
|
|
Sync Waves and Hooks: Controlling Deployment Order
Sometimes you need to ensure resources are created in a specific order — CRDs before the controllers that use them, namespaces before namespaced resources, database migrations before application pods.
ArgoCD sync waves use annotations to order resources within a sync:
|
|
|
|
|
|
Hook types: PreSync (runs before sync), Sync (runs during sync), PostSync (runs after all resources are healthy), SyncFail (runs if sync fails — for cleanup or alerting).
Secrets Management in GitOps
The Fundamental Problem
You cannot commit plaintext secrets to git. Even in a private repository, secrets in plaintext are a security liability: every person with repo read access can see them, they appear in git history forever, and a repository exposure becomes a full credential exposure. The GitOps requirement that “everything lives in git” collides directly with the requirement that “secrets must be protected.”
Three patterns solve this cleanly.
SOPS + age
SOPS (Secrets OPerationS) is a tool for encrypting individual values within YAML, JSON, or ENV files. age is a modern file encryption tool. Together, they let you commit encrypted secret files to git safely.
Generate an age key:
|
|
Store the private key securely (not in git). Add it to your cluster as a Kubernetes Secret:
|
|
Create a .sops.yaml config in your repo root to tell SOPS which keys to use:
|
|
Encrypt a secret file:
|
|
Configure Flux to decrypt using SOPS:
|
|
Flux decrypts the secret automatically when applying. The plaintext secret never touches git.
Sealed Secrets
Sealed Secrets takes a different approach: an in-cluster controller holds an asymmetric key pair. You encrypt secrets with the controller’s public key using kubeseal. The encrypted SealedSecret is safe to commit because only the in-cluster controller can decrypt it.
Install the controller via Helm (or Flux HelmRelease):
|
|
Install the kubeseal CLI:
|
|
Seal a secret:
|
|
The resulting SealedSecret looks like:
|
|
The controller watches for SealedSecret resources, decrypts them, and creates the corresponding Secret objects.
External Secrets Operator
For teams with existing secret stores, the External Secrets Operator (ESO) is the most powerful option. It syncs secrets from Vault, AWS SSM Parameter Store, Azure Key Vault, GCP Secret Manager, 1Password, and many others.
|
|
Define a SecretStore pointing to your secret backend:
|
|
Define an ExternalSecret that maps vault paths to Kubernetes Secret keys:
|
|
ESO creates and keeps the Secret in sync with the vault. The ExternalSecret manifest is safe to commit — it contains no secret data, only references.
Progressive Delivery with Flagger
Progressive delivery extends GitOps to the deployment process itself: rather than immediately rolling out a new version to all pods, you shift traffic gradually and roll back automatically if metrics degrade.
Flagger is a Kubernetes operator from Weaveworks (the Flux maintainers) that automates canary releases, blue/green deployments, and A/B tests. It integrates with Flux naturally but works with ArgoCD too.
Install Flagger via Helm:
|
|
Flagger works by taking over your Deployment and creating a primary/canary pair. You define a Canary CRD that describes the traffic shifting strategy and success criteria:
|
|
When you update the image tag in your Deployment manifest and push to git, Flux applies the change. Flagger detects the pod spec change, creates a canary deployment, and starts shifting traffic. It runs the analysis at each interval. If metrics stay healthy, traffic increases by stepWeight until it reaches maxWeight, then the canary is promoted. If metrics fail, Flagger rolls back automatically.
The entire process is observable in real-time:
|
|
Multi-Cluster Management
Flux Multi-Cluster
Flux’s model is one Flux instance per cluster. Multi-cluster management is handled through repository structure: each cluster has its own directory under clusters/, with its own Kustomizations pointing at the appropriate overlays.
gitops-repo/
└── clusters/
├── homelab/ # Flux installed here
│ ├── flux-system/
│ ├── apps.yaml # points to apps/overlays/homelab
│ └── infrastructure.yaml
└── hetzner-vps/ # separate Flux installed here
├── flux-system/
├── apps.yaml # points to apps/overlays/production
└── infrastructure.yaml
Both clusters share the same git repository but apply different overlays. Shared infrastructure manifests live in infrastructure/, cluster-specific overrides live in apps/overlays/.
Bootstrap each cluster separately:
|
|
ArgoCD Multi-Cluster
ArgoCD takes the opposite approach: a single ArgoCD instance can manage multiple external clusters. Register additional clusters using the CLI:
|
|
With clusters registered, ApplicationSets using the cluster generator can deploy to all of them:
|
|
Label your clusters in the ArgoCD cluster secret to control which ApplicationSets target them.
Practical Workflows
Making a Deployment Change
The complete GitOps flow for updating an application:
|
|
That is the entire workflow. No kubectl apply. No cluster credentials in CI.
Rolling Back a Bad Deployment
|
|
Checking Status
|
|
Suspending Reconciliation for Maintenance
Sometimes you need to make temporary changes to the cluster without having Flux or ArgoCD immediately revert them — during an incident, for debugging, or for a maintenance window.
|
|
|
|
The Break-Glass Scenario
There will be situations where you need to act faster than a git commit allows — a zero-day exploit being actively exploited, a production outage requiring immediate mitigation. In these cases, use kubectl directly. This is acceptable in genuine emergencies.
After the incident, always follow up by committing the change to git and resuming reconciliation. The direct kubectl change will show up as drift in ArgoCD (red sync status) or be detected by Flux, giving you a clear reminder that git is out of sync.
Document the incident, the manual change made, and the git commit that captured it. This maintains the audit trail that GitOps is built on.
Migrating Existing Deployments to GitOps
Migrating a running cluster to GitOps doesn’t require downtime. Both Flux and ArgoCD can adopt existing resources without redeploying them.
Step 1: Export Existing Resources
|
|
Step 2: Clean Up Runtime-Injected Fields
Kubernetes adds many fields to exported manifests that don’t belong in git: resourceVersion, uid, creationTimestamp, generation, managedFields, status. The kubectl neat plugin strips these automatically:
|
|
Or strip the common fields manually:
|
|
Step 3: Import into ArgoCD
|
|
ArgoCD’s adoption behavior: if a resource already exists in the cluster and matches what git describes, ArgoCD takes ownership without modifying it. Only genuine diffs result in changes.
Step 4: Enable Auto-Sync
Once you’ve confirmed ArgoCD is accurately representing your running state, enable automated sync and self-heal:
|
|
The cluster is now GitOps-managed. Future changes go through git.
Putting It Together: A Homelab GitOps Starter
For a homelab running K3s with a handful of services, the full bootstrapped setup looks like this:
- Create the
gitops-repoon GitHub. - Structure the repo with
clusters/homelab/,apps/, andinfrastructure/. - Run
flux bootstrap github --path=clusters/homelabonce from your local machine. - Add a
Kustomizationpointing toinfrastructure/controllers/(which contains HelmReleases for cert-manager and ingress-nginx). - Add a
Kustomizationpointing toapps/overlays/homelab/withdependsOn: infrastructure. - Add your application manifests under
apps/base/with overlays inapps/overlays/homelab/. - Commit SOPS-encrypted secrets or SealedSecrets alongside your manifests.
- Push. Watch Flux build your cluster.
Every subsequent change — new service, updated image, changed replica count, new ingress rule — is a git commit. Your cluster’s entire history lives in git history. Rollback is git revert. Audit is git log. Drift is corrected automatically.
GitOps doesn’t eliminate the complexity of running Kubernetes, but it tames the operational chaos that comes from a cluster that’s been touched by too many hands over too many months. The cluster becomes a reflection of git, and git becomes the single place you need to understand to understand what’s running.
That’s the core value proposition, and once you’ve operated a cluster this way, going back feels like driving without a speedometer — technically functional, but uncomfortably blind.
Comments