LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

GitOps with Flux and ArgoCD: Declarative Deployments Driven by Git

gitopsfluxargocdkubernetesdevopsci-cdk3shomelabdeployment

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.

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 encrypted SealedSecret is safe to commit. The controller decrypts it and creates the real Secret.
  • 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:

1
brew install fluxcd/tap/flux

On Linux (or any platform via curl):

1
curl -s https://fluxcd.io/install.sh | sudo bash

Verify the install:

1
flux version

Prerequisites Check

Before bootstrapping, confirm your cluster meets requirements:

1
flux check --pre

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.

1
2
3
4
5
6
7
8
export GITHUB_TOKEN=ghp_your_personal_access_token

flux bootstrap github \
  --owner=myorg \
  --repository=gitops-repo \
  --branch=main \
  --path=clusters/homelab \
  --personal

What happens under the hood:

  1. Flux creates (or uses) the gitops-repo repository in your GitHub account.
  2. It installs the Flux controllers into the flux-system namespace.
  3. It generates an SSH deploy key and adds it to the repository with read access.
  4. It commits the Flux component manifests to clusters/homelab/flux-system/ in your repo.
  5. The Flux controllers start reconciling from that path.

For GitLab:

1
2
3
4
5
flux bootstrap gitlab \
  --owner=mygroup \
  --repository=gitops-repo \
  --branch=main \
  --path=clusters/homelab

Verify the bootstrap:

1
2
kubectl get pods -n flux-system
flux get kustomizations

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: gitops-repo
  namespace: flux-system
spec:
  interval: 1m
  url: https://github.com/myorg/gitops-repo
  ref:
    branch: main
  secretRef:
    name: flux-system  # SSH key or HTTPS token

Kustomization — applies a path in a GitRepository using Kustomize. This is the primary reconciliation unit for raw manifests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: gitops-repo
  path: ./apps/overlays/homelab
  prune: true        # delete resources removed from git
  wait: true         # wait for all resources to become ready
  timeout: 5m
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: myapp
      namespace: default
  dependsOn:
    - name: infrastructure  # don't apply apps until infrastructure is ready

HelmRepository — a Helm chart repository source.

1
2
3
4
5
6
7
8
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: cert-manager
  namespace: flux-system
spec:
  interval: 12h
  url: https://charts.jetstack.io

HelmRelease — declares a Helm chart deployment with values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
  name: cert-manager
  namespace: cert-manager
spec:
  interval: 30m
  chart:
    spec:
      chart: cert-manager
      version: ">=1.14.0 <2.0.0"
      sourceRef:
        kind: HelmRepository
        name: cert-manager
        namespace: flux-system
  install:
    crds: CreateReplace
  upgrade:
    crds: CreateReplace
  values:
    installCRDs: true
    prometheus:
      enabled: true
      servicemonitor:
        enabled: true

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/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# apps/base/myapp/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:v1.2.3
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# apps/base/myapp/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: myapp
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# apps/base/myapp/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: myapp
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  rules:
    - host: myapp.homelab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80
  tls:
    - hosts:
        - myapp.homelab.example.com
      secretName: myapp-tls
1
2
3
4
5
6
7
8
# apps/base/myapp/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - deployment.yaml
  - service.yaml
  - ingress.yaml

The Flux Kustomization in clusters/homelab/apps.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./apps/overlays/homelab
  prune: true
  dependsOn:
    - name: infrastructure

Push these files to git. Flux polls every minute by default, or you can trigger immediately:

1
flux reconcile kustomization apps --with-source

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ImageRepository: tell Flux to scan this registry
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: myapp
  namespace: flux-system
spec:
  image: ghcr.io/myorg/myapp
  interval: 5m
  secretRef:
    name: ghcr-credentials
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# ImagePolicy: define what "latest valid tag" means
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: myapp
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: myapp
  policy:
    semver:
      range: ">=1.0.0"   # any stable release
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ImageUpdateAutomation: commit the updated tag back to git
apiVersion: image.toolkit.fluxcd.io/v1beta1
kind: ImageUpdateAutomation
metadata:
  name: flux-system
  namespace: flux-system
spec:
  interval: 30m
  sourceRef:
    kind: GitRepository
    name: flux-system
  git:
    checkout:
      ref:
        branch: main
    commit:
      author:
        email: fluxcdbot@users.noreply.github.com
        name: fluxcdbot
      messageTemplate: "chore: update {{range .Updated.Images}}{{println .}}{{end}}"
    push:
      branch: main
  update:
    path: ./apps
    strategy: Setters

In your deployment manifest, mark the image tag with a Flux marker comment:

1
image: ghcr.io/myorg/myapp:v1.2.3  # {"$imagepolicy": "flux-system:myapp"}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Provider: where to send alerts
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Provider
metadata:
  name: slack
  namespace: flux-system
spec:
  type: slack
  channel: "#deployments"
  secretRef:
    name: slack-webhook-url
---
# Alert: what events to send and where
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
  name: on-call-slack
  namespace: flux-system
spec:
  summary: "Homelab cluster"
  providerRef:
    name: slack
  eventSeverity: info
  eventSources:
    - kind: GitRepository
      name: "*"
    - kind: Kustomization
      name: "*"
    - kind: HelmRelease
      name: "*"

ArgoCD: Complete Setup Walkthrough

Installing ArgoCD

1
2
3
4
kubectl create namespace argocd

kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

Wait for all pods to come up:

1
2
3
4
kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/part-of=argocd \
  -n argocd \
  --timeout=300s

Get the initial admin password:

1
argocd admin initial-password -n argocd

Install the ArgoCD CLI:

1
2
3
4
5
6
7
# macOS
brew install argocd

# Linux
curl -sSL -o /usr/local/bin/argocd \
  https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x /usr/local/bin/argocd

Expose the ArgoCD API server and log in:

1
2
kubectl port-forward svc/argocd-server -n argocd 8080:443
argocd login localhost:8080 --username admin --insecure

For production, expose via Ingress instead of port-forward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server
  namespace: argocd
  annotations:
    nginx.ingress.kubernetes.io/ssl-passthrough: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
spec:
  ingressClassName: nginx
  rules:
    - host: argocd.homelab.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: argocd-server
                port:
                  number: 443

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io  # delete app resources on Application delete
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/gitops-repo
    targetRevision: main
    path: apps/overlays/homelab/myapp
  destination:
    server: https://kubernetes.default.svc  # in-cluster
    namespace: myapp
  syncPolicy:
    automated:
      prune: true       # delete resources removed from git
      selfHeal: true    # revert manual changes to cluster
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# argocd/root.yaml — applied once with kubectl, manages everything else
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/gitops-repo
    targetRevision: main
    path: argocd/apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

An individual app Application managed by root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# argocd/apps/myapp.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/gitops-repo
    targetRevision: main
    path: apps/overlays/homelab/myapp
  destination:
    server: https://kubernetes.default.svc
    namespace: myapp
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Bootstrap once:

1
kubectl apply -f argocd/root.yaml

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.jetstack.io
    chart: cert-manager
    targetRevision: v1.14.4
    helm:
      releaseName: cert-manager
      values: |
        installCRDs: true
        prometheus:
          enabled: true
          servicemonitor:
            enabled: true
  destination:
    server: https://kubernetes.default.svc
    namespace: cert-manager
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

You can also reference values from a separate git repo by using multiple sources:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
spec:
  sources:
    - repoURL: https://charts.jetstack.io
      chart: cert-manager
      targetRevision: v1.14.4
      helm:
        valueFiles:
          - $values/infrastructure/cert-manager/values.yaml
    - repoURL: https://github.com/myorg/gitops-repo
      targetRevision: main
      ref: values

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: cluster-apps
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/myorg/gitops-repo
        revision: main
        directories:
          - path: apps/overlays/homelab/*
  template:
    metadata:
      name: "{{path.basename}}"
      namespace: argocd
    spec:
      project: default
      source:
        repoURL: https://github.com/myorg/gitops-repo
        targetRevision: main
        path: "{{path}}"
      destination:
        server: https://kubernetes.default.svc
        namespace: "{{path.basename}}"
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

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:

1
2
3
4
5
6
7
8
9
generators:
  - list:
      elements:
        - cluster: production
          url: https://prod-cluster.example.com
        - cluster: staging
          url: https://staging-cluster.example.com
        - cluster: homelab
          url: https://homelab.example.com

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:

1
2
3
4
5
6
7
# Apply this first (wave -1) — CRDs and namespaces
apiVersion: v1
kind: Namespace
metadata:
  name: myapp
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
1
2
3
4
5
6
7
# Apply this second (wave 0, the default) — deployments
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  annotations:
    argocd.argoproj.io/sync-wave: "0"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Apply this last (wave 1) — run after deployment is healthy
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
    argocd.argoproj.io/sync-wave: "1"
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/myorg/myapp:v1.2.3
          command: ["./migrate", "--up"]

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:

1
2
age-keygen -o age.agekey
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Store the private key securely (not in git). Add it to your cluster as a Kubernetes Secret:

1
2
3
kubectl create secret generic sops-age \
  --namespace flux-system \
  --from-file=age.agekey

Create a .sops.yaml config in your repo root to tell SOPS which keys to use:

1
2
3
4
# .sops.yaml
creation_rules:
  - path_regex: .*/secrets/.*\.yaml
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Encrypt a secret file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Create the plaintext secret
cat > secret.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
  name: myapp-secrets
  namespace: myapp
stringData:
  DATABASE_URL: postgres://user:supersecret@db:5432/myapp
  API_KEY: sk_live_abc123xyz
EOF

# Encrypt it
sops --encrypt secret.yaml > secrets/myapp-secrets.enc.yaml

# Commit the encrypted version
git add secrets/myapp-secrets.enc.yaml

Configure Flux to decrypt using SOPS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# In your Kustomization, add the decryption config
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: myapp
  namespace: flux-system
spec:
  interval: 10m
  sourceRef:
    kind: GitRepository
    name: flux-system
  path: ./apps/overlays/homelab/myapp
  prune: true
  decryption:
    provider: sops
    secretRef:
      name: sops-age

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):

1
2
3
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
  -n kube-system

Install the kubeseal CLI:

1
brew install kubeseal

Seal a secret:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Fetch the public key from the controller
kubeseal --fetch-cert \
  --controller-name sealed-secrets \
  --controller-namespace kube-system \
  > pub-sealed-secrets.pem

# Seal a secret file
kubeseal --format yaml \
  --cert pub-sealed-secrets.pem \
  < secret.yaml > sealedsecret.yaml

# Commit the SealedSecret
git add sealedsecret.yaml

The resulting SealedSecret looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: myapp-secrets
  namespace: myapp
spec:
  encryptedData:
    DATABASE_URL: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq...
    API_KEY: AgBvfQgCBpBUSEUeSMtVzrwDZBetjb7N...
  template:
    metadata:
      name: myapp-secrets
      namespace: myapp
    type: Opaque

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.

1
2
3
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace

Define a SecretStore pointing to your secret backend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      server: "https://vault.homelab.example.com"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"

Define an ExternalSecret that maps vault paths to Kubernetes Secret keys:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-secrets
  namespace: myapp
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: myapp-secrets
    creationPolicy: Owner
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: secret/myapp
        property: database_url
    - secretKey: API_KEY
      remoteRef:
        key: secret/myapp
        property: api_key

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:

1
2
3
4
5
helm repo add flagger https://flagger.app
helm upgrade -i flagger flagger/flagger \
  -n flagger-system --create-namespace \
  --set meshProvider=nginx \
  --set metricsServer=http://prometheus:9090

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: myapp
  namespace: myapp
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  progressDeadlineSeconds: 120
  service:
    port: 80
    targetPort: 8080
    gateways:
      - public-gateway.istio-system.svc.cluster.local
    hosts:
      - myapp.homelab.example.com
  analysis:
    interval: 1m           # run analysis every minute
    threshold: 5           # fail after 5 consecutive analysis failures
    maxWeight: 50          # max traffic to canary: 50%
    stepWeight: 10         # increase traffic by 10% per interval
    metrics:
      - name: request-success-rate
        thresholdRange:
          min: 99          # abort if error rate exceeds 1%
        interval: 1m
      - name: request-duration
        thresholdRange:
          max: 500         # abort if p99 latency exceeds 500ms
        interval: 1m
    webhooks:
      - name: load-test
        url: http://loadtester.flagger-system/
        timeout: 5s
        metadata:
          cmd: "hey -z 1m -q 10 -c 2 http://myapp-canary.myapp/"

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:

1
kubectl get canary -n myapp -w

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Bootstrap homelab cluster
KUBECONFIG=~/.kube/homelab flux bootstrap github \
  --owner=myorg \
  --repository=gitops-repo \
  --path=clusters/homelab \
  --personal

# Bootstrap Hetzner VPS cluster
KUBECONFIG=~/.kube/hetzner flux bootstrap github \
  --owner=myorg \
  --repository=gitops-repo \
  --path=clusters/hetzner-vps \
  --personal

ArgoCD Multi-Cluster

ArgoCD takes the opposite approach: a single ArgoCD instance can manage multiple external clusters. Register additional clusters using the CLI:

1
2
3
4
5
6
# Add a remote cluster (uses your local kubeconfig)
argocd cluster add homelab-context --name homelab
argocd cluster add hetzner-context --name hetzner-vps

# List registered clusters
argocd cluster list

With clusters registered, ApplicationSets using the cluster generator can deploy to all of them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: myapp-all-clusters
  namespace: argocd
spec:
  generators:
    - clusters:
        selector:
          matchLabels:
            environment: production
  template:
    metadata:
      name: "myapp-{{name}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/myorg/gitops-repo
        targetRevision: main
        path: "apps/overlays/{{metadata.labels.environment}}/myapp"
      destination:
        server: "{{server}}"
        namespace: myapp
      syncPolicy:
        automated:
          prune: true
          selfHeal: true

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. Edit the manifest (or image tag is auto-updated by image-automation)
vim apps/base/myapp/deployment.yaml

# 2. Commit and push
git add apps/base/myapp/deployment.yaml
git commit -m "feat: bump myapp to v1.3.0"
git push origin main

# 3. Wait for reconciliation (or trigger immediately)
flux reconcile kustomization apps --with-source
# or
argocd app sync myapp

That is the entire workflow. No kubectl apply. No cluster credentials in CI.

Rolling Back a Bad Deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Find the bad commit
git log --oneline apps/base/myapp/

# Revert it
git revert abc1234
git push origin main

# The GitOps operator sees the revert commit and applies the previous state
# Watch it happen
kubectl rollout status deployment/myapp -n myapp

Checking Status

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Flux: see all managed resources
flux get all

# Flux: see kustomization status
flux get kustomizations

# Flux: see helm release status
flux get helmreleases -A

# ArgoCD: list all applications
argocd app list

# ArgoCD: get detailed application status
argocd app get myapp

# ArgoCD: show diff between git and cluster
argocd app diff myapp

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.

1
2
3
4
5
6
7
8
# Flux: suspend a kustomization
flux suspend kustomization myapp

# Make your manual changes
kubectl scale deployment myapp --replicas=0 -n myapp

# Resume when done
flux resume kustomization myapp
1
2
3
4
5
# ArgoCD: disable auto-sync for an application
argocd app set myapp --sync-policy none

# Re-enable auto-sync
argocd app set myapp --sync-policy automated

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

1
2
3
4
5
# Export a deployment
kubectl get deployment myapp -n myapp -o yaml > apps/base/myapp/deployment.yaml

# Export all resources in a namespace
kubectl get all -n myapp -o yaml > apps/base/myapp/all.yaml

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:

1
2
3
4
5
# Install kubectl-neat
kubectl krew install neat

# Clean an exported manifest
kubectl get deployment myapp -n myapp -o yaml | kubectl neat > deployment.yaml

Or strip the common fields manually:

1
2
3
4
5
6
7
# Remove these fields from exported YAML:
metadata:
  # Remove: resourceVersion, uid, creationTimestamp, generation, managedFields
  # Remove: annotations added by Kubernetes (kubectl.kubernetes.io/last-applied-configuration)
spec:
  # Remove: revisionHistoryLimit (use default), progressDeadlineSeconds (use default)
status: {}  # Remove entirely

Step 3: Import into ArgoCD

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Create the Application pointing to your new path
kubectl apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/gitops-repo
    targetRevision: main
    path: apps/base/myapp
  destination:
    server: https://kubernetes.default.svc
    namespace: myapp
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
EOF

# ArgoCD will show the app as OutOfSync. Review the diff:
argocd app diff myapp

# Sync to bring ArgoCD in sync with existing resources (no changes to running pods)
argocd app sync myapp

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:

1
2
3
4
argocd app set myapp \
  --sync-policy automated \
  --auto-prune \
  --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:

  1. Create the gitops-repo on GitHub.
  2. Structure the repo with clusters/homelab/, apps/, and infrastructure/.
  3. Run flux bootstrap github --path=clusters/homelab once from your local machine.
  4. Add a Kustomization pointing to infrastructure/controllers/ (which contains HelmReleases for cert-manager and ingress-nginx).
  5. Add a Kustomization pointing to apps/overlays/homelab/ with dependsOn: infrastructure.
  6. Add your application manifests under apps/base/ with overlays in apps/overlays/homelab/.
  7. Commit SOPS-encrypted secrets or SealedSecrets alongside your manifests.
  8. 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