The previous lesson ended with a problem that neither Helm nor Kustomize solves. Both tools only act when somebody runs a command. If a colleague runs kubectl edit in rutas-norte-pro at three in the morning to get out of a jam, neither of them finds out. If the laptop of whoever deploys breaks, nobody knows how to deploy. And if the ci-rutasnorte pipeline needs cluster administrator credentials to run kubectl apply, we have opened a security hole that contradicts the least-privilege RBAC we were so careful about in 08-01.

GitOps is the answer, and it is the natural conclusion of everything we have covered so far. The idea is simple to state and transformative in practice: an agent that lives inside the cluster, watches a Git repository continuously, and makes sure the cluster looks like whatever that repository says. Always. Without anybody running anything.

In this lesson we will build that system for Rutas Norte with Argo CD, we will look at its Flux equivalent, we will finally resolve the conflict between the HPA and the replicas field flagged in 09-01, and we will close the problem of secrets in the repository that has been outstanding since 03-02.

Contents

  1. What GitOps is and its four principles
  2. Push model versus pull model
  3. Configuration drift and self-healing
  4. Argo CD: architecture and installation
  5. The Application resource field by field
  6. App of apps and ApplicationSet
  7. Sync waves and hooks
  8. Health and sync statuses
  9. Flux: the controllers and their resources
  10. Argo CD versus Flux
  11. The secrets problem
  12. Fields that change on their own: ignoreDifferences
  13. Repository structure and promotion between environments
  14. Common mistakes and tips
  15. Exercises
  16. Conclusion

  1. What GitOps is and its four principles

GitOps is an operating model in which the system's desired state lives in Git and an automated agent makes it real continuously. The OpenGitOps working group formalised it in four principles:

  1. Declarative: the whole system is described by what must exist, not by the steps to execute. We have had that since 01-06: Kubernetes is declarative by design.
  2. Versioned and immutable: the desired state is stored so that the history cannot be altered. Git is the obvious implementation. The question "what changed in production on Tuesday?" goes from being a forensic investigation to git log.
  3. Pulled automatically: approved changes are applied without manual intervention. Nobody runs kubectl apply or helm upgrade: a pull request is merged and the system converges.
  4. Continuously reconciled: and this is the one that changes everything. The agent does not merely apply when there is a new commit: it continuously checks that the real state matches the desired one and corrects the differences.
flowchart LR
    G[(Manifest<br/>repository)] -->|the agent watches| A[GitOps agent<br/>in the cluster]
    A -->|compares| K[(Real cluster<br/>state)]
    K -.->|if it differs| A
    A -->|corrects| K
    style A fill:#e8f4ff

That loop never stops. Every three minutes (configurable), the agent asks itself: "is the cluster what Git says?". If it is not, it acts.

Rutas Norte's current problem How GitOps solves it
"Nobody knows which version is in production" The repository, on the main branch, is the answer
"It is applied by hand from a laptop" Nobody has or needs deployment credentials
"VPA changes are carried into the manifest by hand and forgotten" If it is not in Git, it does not exist: it reverts by itself
"Who changed this and why?" git log, git blame, the PR with its discussion
"The cluster has been lost" You recreate it and the agent repopulates it

  1. Push model versus pull model

This is the key architectural difference, and it has serious security implications.

flowchart LR
    subgraph push["PUSH model (today)"]
        D1[Development] --> G1[(Git)] --> CI["ci-rutasnorte"]
        CI -->|"kubectl apply<br/>with credentials"| K1[(rutas-norte-pro)]
    end
    subgraph pull["PULL model (GitOps)"]
        D2[Development] --> G2[(Git)]
        CI2["ci-rutasnorte"] -->|"commits<br/>the tag"| G2
        G2 -.->|"the agent PULLS<br/>(read-only)"| A2[Agent<br/>in the cluster]
        A2 --> K2[(Objects)]
    end
    style CI fill:#ffe8e8
    style A2 fill:#e8ffe8

In the push model, the pipeline runs kubectl apply against the cluster and for that it needs a kubeconfig with permissions. The problem, in detail:

  1. That kubeconfig is a secret stored outside the cluster, often managed by another team or by an external provider.
  2. To be able to deploy anything into any namespace, it usually ends up holding cluster-admin. That is the easiest route, and that is why it is what most companies have.
  3. Anybody who can modify the pipeline definition — a YAML file in the code repository — can run arbitrary commands with those credentials. A malicious pull request against a CI file is equivalent to administrator access to production.
  4. The apiserver has to be reachable from outside.
  5. The credentials have to be rotated, audited and revoked, and with three clusters, three sets of them.

The least-privilege RBAC we defined for ci-rutasnorte in 08-01 mitigates the problem, but it does not eliminate it: the pipeline still holds a permanent credential pointing into the cluster.

Aspect Push model Pull model
Who holds cluster credentials The CI system, permanently Nobody outside the cluster
Direction of the connection CI → cluster (inbound) Cluster → Git (outbound)
Is the apiserver reachable from outside? Yes No
Permissions CI needs Deploy into the cluster Only write to a Git repository
Attack surface if CI is compromised The whole cluster One repository (reviewable, revertible)
Detection of manual changes None Continuous
Clusters on private networks Complicated (tunnels, VPN) Natural: it only needs outbound access to Git

The change runs deep: the most dangerous credential disappears. If somebody steals the Git write token, they can make a commit... which will be recorded, reviewable and revertible.

  1. Configuration drift and self-healing

Configuration drift is the difference between what your files say and what is actually in the system. It accumulates without anybody noticing. Real cases from Rutas Norte:

  • During an incident over the May bank-holiday weekend, somebody ran kubectl scale deploy/bookings-api --replicas=12. It worked. Nobody carried it back into the manifest. Three weeks later, a routine deployment returned the Deployment to 4 replicas and the service was overwhelmed.
  • A memory limit was changed with kubectl edit to test a hypothesis. It stayed that way for six months.
  • An annotation was added to the Ingress to tune a timeout. When the pre cluster was recreated, it vanished and nobody knew why the behaviour had changed.

With GitOps, kubectl scale deploy/bookings-api --replicas=12 in production has a different ending. Three minutes later:

level=info msg="Detected out-of-sync resource" application=rutas-norte-pro
  kind=Deployment name=bookings-api
level=info msg="Initiating self-heal"
level=info msg="Sync successful"

It is back to 4. And that is exactly what we want. It is not that the machine is being stubborn: it is that if 12 replicas was the right decision, it has to be in Git, where it is reviewed, documented and present the next time the environment is recreated.

The cultural change this brings about matters more than the technology: the only way to change production is through a pull request. At first it creates friction; two months in, nobody wants to go back.

Self-healing is a decision, not an obligation. Argo CD lets you enable selfHeal per application. In rutas-norte-dev it can be worth leaving it off so people can experiment; in rutas-norte-pro it must be on.

  1. Argo CD: architecture and installation

flowchart TB
    U["User<br/>(web / CLI)"] --> API["**API server**<br/>authentication, RBAC,<br/>web interface"]
    API --> REPO["**Repository server**<br/>clones Git, runs<br/>kustomize build /<br/>helm template, caches"]
    API --> CTRL["**Application controller**<br/>compares desired vs real,<br/>syncs, evaluates health"]
    REPO -.-> G[(Git repository)]
    CTRL --> K8S[(Kubernetes API)]
    CTRL --> REPO
Component Responsibility Symptom when it fails
argocd-server Web interface, API, authentication, Argo CD's RBAC You cannot log into the web UI, but applications keep syncing
argocd-repo-server Clones Git and renders the manifests "failed to generate manifests"; nothing syncs
argocd-application-controller The reconciliation loop Applications freeze
Redis Manifest and state cache Slowness; everything has to be re-rendered
ApplicationSet controller Generates Applications from templates ApplicationSets produce nothing

A key detail: the application controller is the only one that needs permissions over the cluster's objects. The API server only manages Argo CD resources. That separation matters for RBAC.

The installation consistent with what we have learned is via Helm (10-03), with versioned values:

helm repo add argo https://argoproj.github.io/argo-helm
helm upgrade --install argocd argo/argo-cd -n argocd --create-namespace \
  --version 7.6.12 -f platform/argocd/values-pro.yaml --atomic --timeout 10m
# platform/argocd/values-pro.yaml
global: { domain: argocd.rutasnorte.example }
configs:
  params: { server.insecure: true }      # the Ingress terminates TLS
  cm: { timeout.reconciliation: 180s }
redis-ha: { enabled: true }
controller:
  replicas: 2
  resources: { requests: {cpu: 250m, memory: 1Gi}, limits: {memory: 2Gi} }
repoServer: { replicas: 2 }
server:
  replicas: 2
  ingress:
    enabled: true
    ingressClassName: nginx
    hostname: argocd.rutasnorte.example
    annotations: { cert-manager.io/cluster-issuer: letsencrypt-production }
    tls: true
# Initial password: change it and delete the Secret
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
argocd login argocd.rutasnorte.example --username admin
argocd account update-password
kubectl -n argocd delete secret argocd-initial-admin-secret

In production the admin user is disabled and an identity provider is used (OIDC, corporate SSO). Argo CD's RBAC is configured separately from Kubernetes's, in the argocd-rbac-cm ConfigMap:

p, role:development, applications, get,  */*, allow
p, role:development, applications, sync, rutas-norte/rutas-norte-dev, allow
p, role:platform, applications, *, */*, allow
p, role:support,  applications, get, */*, allow
g, rutasnorte:development, role:development
g, rutasnorte:platform,    role:platform
g, rutasnorte:support,     role:support
policy.default: role:readonly

And the repository is registered with a read-only credential: Argo CD never writes to the manifest repository. It is the least privilege of 08-01 applied here.

argocd repo add [email protected]:platform/k8s.git \
  --ssh-private-key-path ~/.ssh/argocd-rutasnorte-readonly

  1. The Application resource field by field

Application is a CRD (06-06). Each instance says: "from this repository, this path, to this cluster and namespace, with this policy".

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: rutas-norte-pro
  namespace: argocd
  # The finalizer makes deleting the Application also delete the objects
  # it managed. WITHOUT it, they are all left orphaned in the cluster.
  finalizers: [resources-finalizer.argocd.argoproj.io]
spec:
  # Project: groups applications and restricts what they can deploy and where
  project: rutas-norte

  source:
    repoURL: [email protected]:platform/k8s.git
    # We point at the Kustomize overlay we built in 10-04
    path: k8s/environments/pro
    # For production, a TAG or a pinned commit gives more control
    # than 'main', which deploys whatever gets merged.
    targetRevision: main
    kustomize:
      commonAnnotations: { rutasnorte.example/deployed-by: argocd }

  destination:
    # 'kubernetes.default.svc' is the very cluster Argo CD runs in
    server: https://kubernetes.default.svc
    namespace: rutas-norte-pro

  syncPolicy:
    automated:
      prune: true      # deletes from the cluster whatever is no longer in Git
      selfHeal: true   # reverts manual changes: THE SELF-HEALING
      # allowEmpty set to false protects against a path typo that would delete everything
      allowEmpty: false
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true          # (01-06) and needed for large CRDs
      - RespectIgnoreDifferences=true
      - PruneLast=true
    retry:
      limit: 5
      backoff: { duration: 10s, factor: 2, maxDuration: 5m }

  ignoreDifferences:                  # see section 12
    - group: apps
      kind: Deployment
      jsonPointers: ["/spec/replicas"]

  revisionHistoryLimit: 20

The AppProject

An AppProject restricts what a group of applications can do. It is an essential security layer when several teams share an Argo CD.

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata: { name: rutas-norte, namespace: argocd }
spec:
  # Only from OUR repository: nobody can point at an external one
  sourceRepos: ["[email protected]:platform/k8s.git"]
  # Only into OUR namespaces: nobody can deploy into kube-system
  destinations:
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-dev }
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-pre }
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-pro }
  # Creating cluster-scoped objects is forbidden
  clusterResourceWhitelist: []
  namespaceResourceWhitelist: [{ group: '*', kind: '*' }]
  namespaceResourceBlacklist:
    - { group: '', kind: ResourceQuota }
    - { group: '', kind: LimitRange }
  # Deployment window: nothing into production on Friday afternoons
  syncWindows:
    - kind: deny
      schedule: "0 15 * * 5"
      duration: 9h
      applications: [rutas-norte-pro]
      manualSync: true      # it can be forced if there is an emergency

That clusterResourceWhitelist: [] is important: it means that not even a mistake in a manifest can create a ClusterRole or a StorageClass. Without it, whoever can write to the repository can escalate privileges in the cluster.

A Helm source

spec:
  source:
    repoURL: https://charts.jetstack.io
    chart: cert-manager
    targetRevision: v1.16.1            # the CHART's version
    helm:
      releaseName: cert-manager
      valuesObject:
        crds: { enabled: true, keep: true }
        replicaCount: 2
  syncPolicy:
    syncOptions: [CreateNamespace=true, ServerSideApply=true]

Argo CD runs helm template internally, so it does not create Helm releases: you will see nothing in helm list. Argo CD holds the state, not Helm. It is consistent with GitOps (the state is in Git), but it surprises you the first time.

A very useful pattern is multiple sources: the chart comes from a public repository and the values from the company's repository, referenced with ref: values and valueFiles: [$values/platform/.../values-pro.yaml].

  1. App of apps and ApplicationSet

With twenty applications, creating each Application by hand and applying it with kubectl takes us back to the original problem. The solution: an Application that manages a directory full of Applications.

k8s-gitops/
├── root/kustomization.yaml         <- the root Application points here
├── applications/                   <- rutas-norte-{dev,pre,pro}.yaml,
│                                      cert-manager.yaml, ingress-nginx.yaml,
│                                      kube-prometheus-stack.yaml, keda.yaml...
└── projects/                       <- rutas-norte.yaml, platform.yaml
# The ONLY Application applied by hand, once in its lifetime
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
  finalizers: [resources-finalizer.argocd.argoproj.io]
spec:
  project: default
  source:
    repoURL: [email protected]:platform/k8s-gitops.git
    path: root
    targetRevision: main
  destination: { server: https://kubernetes.default.svc, namespace: argocd }
  syncPolicy:
    automated: { prune: true, selfHeal: true }
kubectl apply -f root-application.yaml

That is the last time anybody runs kubectl apply at Rutas Norte. From then on, adding a new application means adding a file to the applications/ directory and merging the pull request.

ApplicationSet: the three environments from a single definition

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata: { name: rutas-norte-environments, namespace: argocd }
spec:
  generators:
    # LIST generator: the most explicit and readable one
    - list:
        elements:
          - { environment: dev, namespace: rutas-norte-dev, revision: main,
              selfHeal: "false" }          # in dev we let people experiment
          - { environment: pre, namespace: rutas-norte-pre, revision: main,
              selfHeal: "true" }
          - { environment: pro, namespace: rutas-norte-pro, revision: release-2.4,
              selfHeal: "true" }           # production tracks a tag
  template:
    metadata:
      name: 'rutas-norte-{{.environment}}'
      labels: { environment: '{{.environment}}' }
      finalizers: [resources-finalizer.argocd.argoproj.io]
    spec:
      project: rutas-norte
      source:
        repoURL: [email protected]:platform/k8s.git
        path: 'k8s/environments/{{.environment}}'
        targetRevision: '{{.revision}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.namespace}}'
      syncPolicy:
        automated: { prune: true, selfHeal: '{{.selfHeal}}' }
        syncOptions: [CreateNamespace=true, ServerSideApply=true]
      ignoreDifferences:
        - { group: apps, kind: Deployment, jsonPointers: ["/spec/replicas"] }

One definition, three applications. There are more generators, and some of them open up notable possibilities:

Generator What it does
list Explicit elements, as above
git.directories One Application per subdirectory: adding an environment = creating a folder
git.files Reads configuration files from the repository and uses their contents as parameters
clusters One Application per registered cluster matching the label (the basis of multi-cluster, 11-05)
matrix A cartesian product: 3 environments × 4 clusters = 12 Applications
pullRequest An ephemeral Application per open PR: automatic preview environments

That last one is spectacular in practice: you open a PR with the preview label and a complete environment appears deployed; you close it and it disappears on its own.

  1. Sync waves and hooks

Argo CD applies objects in an order based on kind (Namespace, CRDs, ConfigMaps, Secrets and finally the workloads). When you need your own ordering, you use waves: argocd.argoproj.io/sync-wave: "-5". They run from lowest to highest, and Argo CD waits for every object in a wave to be healthy before moving to the next.

Wave Objects Why at that point
-10 Namespace, ResourceQuota, LimitRange Everything else lives inside them
-5 ServiceAccount, Role, RoleBinding, NetworkPolicy The pods need them at start-up
-3 ExternalSecret, ConfigMap Configuration before the pods
-1 Schema migration Job Before the new code
0 bookings-postgres, redis-cache The API depends on them
1 bookings-api, notifications-worker They depend on the database
2 web-store It depends on the API
3 Service, Ingress Publish only when everything is ready
5 HPA, ScaledObject, PDB Once the target already exists

Hooks are the equivalent of Helm's (10-03): PreSync, Sync, PostSync, SyncFail and Skip.

apiVersion: batch/v1
kind: Job
metadata:
  name: schema-migration
  annotations:
    argocd.argoproj.io/hook: PreSync           # before applying anything
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
    argocd.argoproj.io/sync-wave: "-1"
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 900
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrator
          image: registry.rutasnorte.example/bookings-api-migrations:2.4.0
          command: ["/app/migrate", "--up-to", "2.4.0"]
          env:
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: bookings-api-credentials, key: db-password }

The deletion policies are HookSucceeded, HookFailed (do not use it: you lose the logs) and BeforeHookCreation (recommended). And a PostSync with a curl --fail against /health/ready is an excellent smoke test: if it fails, the application ends up Degraded and Argo CD tells you even though the pods started.

  1. Health and sync statuses

Argo CD handles two independent statuses. Confusing them is the most common conceptual mistake.

  • Sync status — does the cluster match Git?: Synced, OutOfSync, Unknown.
  • Health status — is the application working?: Healthy, Progressing, Degraded, Suspended, Missing, Unknown.

They are orthogonal. The four combinations that matter:

Sync Health What it means
Synced Healthy All perfect
Synced Degraded Git is telling the truth, but the application is broken. An application problem, not a deployment one: the image does not exist, a probe fails, resources are missing
OutOfSync Healthy It works, but it is not what Git says: manual drift or a pending commit
OutOfSync Degraded The worst: it neither matches nor works

Argo CD knows how to evaluate the health of the standard kinds (Deployment, Service, Ingress, StatefulSet, Job, PVC). For CRDs you write your own evaluators in Lua, in the argocd-cm ConfigMap:

resource.customizations.health.keda.sh_ScaledObject: |
  hs = {}
  if obj.status ~= nil and obj.status.conditions ~= nil then
    for i, condition in ipairs(obj.status.conditions) do
      if condition.type == "Ready" and condition.status == "True" then
        hs.status = "Healthy"; return hs
      end
    end
  end
  hs.status = "Progressing"; return hs
argocd app get rutas-norte-pro
argocd app diff rutas-norte-pro                      # Git vs cluster
argocd app sync rutas-norte-pro --dry-run
argocd app sync rutas-norte-pro --resource apps:Deployment:bookings-api
argocd app history rutas-norte-pro
argocd app rollback rutas-norte-pro 12
argocd app wait rutas-norte-pro --health --timeout 600

Careful with argocd app rollback: it reverts the cluster, but it does not revert Git. With selfHeal on, within three minutes the agent will return the cluster to whatever Git says and your rollback will vanish. Argo CD's rollback is an emergency measure; the real rollback in GitOps is a git revert followed by a pull request, and Argo CD syncs on its own.

  1. Flux: the controllers and their resources

Flux has a different philosophy: instead of one application with a web interface, it is a set of specialised controllers, each with its own CRD.

flowchart TB
    SC["**source-controller**<br/>clones Git, downloads charts"] --> KC["**kustomize-controller**<br/>builds and applies"]
    SC --> HC["**helm-controller**<br/>Helm releases"]
    IRC["**image-reflector**<br/>scans registries"] --> IAC["**image-automation**<br/>commits the tag"]
    KC --> K[(Cluster)]
    HC --> K
    IAC --> G[(Git)]
curl -s https://fluxcd.io/install.sh | sudo bash
flux check --pre
flux bootstrap git --url=ssh://[email protected]/platform/k8s-gitops.git \
  --branch=main --path=clusters/rutas-norte-pro --private-key-file=~/.ssh/flux

That bootstrap is an interesting design decision: Flux installs itself via GitOps. It writes its own manifests into the repository and is then managed from there. Upgrading Flux means making a commit.

apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata: { name: rutas-norte, namespace: flux-system }
spec:
  interval: 1m
  url: ssh://[email protected]/platform/k8s.git
  ref: { branch: main }
  secretRef: { name: flux-ssh-key }
---
# Careful: this Kustomization is Flux's, NOT Kustomize's kustomization.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: rutas-norte-pro, namespace: flux-system }
spec:
  interval: 5m
  path: ./k8s/environments/pro
  prune: true                     # the equivalent of Argo CD's prune
  wait: true
  timeout: 10m
  sourceRef: { kind: GitRepository, name: rutas-norte }
  # EXPLICIT DEPENDENCIES: equivalent to waves, but clearer
  dependsOn: [{ name: infrastructure }]
  healthChecks:
    - { apiVersion: apps/v1, kind: Deployment, name: bookings-api, namespace: rutas-norte-pro }
  postBuild:
    substitute: { environment: pro }  # the closest thing to templating Flux has

For third-party charts there are HelmRepository and HelmRelease, and unlike Argo CD, Flux does create real Helm releases (they show up in helm list), with install.remediation and upgrade.remediation as the equivalent of --atomic.

Image automation

A capability Flux has out of the box and Argo CD only through a separate project:

apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata: { name: bookings-api, namespace: flux-system }
spec:
  imageRepositoryRef: { name: bookings-api }
  policy:
    semver: { range: "~2.4.0" }    # 2.4.0, 2.4.1... but NEVER 2.5.0

And in the manifest you mark which field to update: newTag: 2.4.1 # {"$imagepolicy": "flux-system:bookings-api:tag"}. Flux detects a new tag in the registry, makes a commit in Git updating it, and the normal GitOps cycle deploys it. The state stays in Git: the automation writes to Git, not to the cluster.

flux get kustomizations
flux reconcile kustomization rutas-norte-pro --with-source
flux suspend / resume kustomization rutas-norte-pro
flux diff kustomization rutas-norte-pro --path ./k8s/environments/pro

  1. Argo CD versus Flux

Criterion Argo CD Flux
Web interface Excellent: visual map, logs, diffs None (Weave GitOps as an add-on)
CLI Good Excellent, designed for working without a UI
Mental model One application = one Application resource Several controllers, each with its own CRD
Learning curve Gentler: you can see what is happening Steeper: you have to understand the chain
Multi-tenancy AppProject with its own RBAC Namespaces + Kubernetes RBAC
Helm helm template; creates no releases Real releases, visible in helm list
Image automation A separate project (Image Updater) Built in and mature
Multi-cluster From one central Argo CD out to N clusters One Flux per cluster
Resource consumption Higher (web, Redis, API) Lower: controllers only
Dependencies between applications Waves (annotations) dependsOn (explicit)
Progressive delivery Argo Rollouts Flagger
Bulk generation ApplicationSet with many generators Less flexible

Choose Argo CD if you want development and support to be able to see the state of the deployment without learning kubectl (the web interface is a communication tool, not a whim), you manage several clusters from one central point, or you need the flexibility of ApplicationSet.

Choose Flux if you prefer a lightweight tool driven by CLI and Git, you want image automation out of the box, you need real Helm releases, or you have many small autonomous clusters.

For Rutas Norte we choose Argo CD, for two concrete reasons: the support team needs to see the state of deployments without being a Kubernetes expert, and ApplicationSet generates the three environments from one definition. Neither choice is irreversible: both read the same Kustomize manifests and the same Helm charts, so switching affects the files in k8s-gitops/, not k8s/.

  1. The secrets problem

Here comes the objection that always turns up: if everything goes into Git, where do I put the bookings-postgres password?

In 03-02 we said that Secrets are base64-encoded, not encrypted, and that version-controlling them requires a specific tool. The moment has arrived. And remember: a secret that has been in Git is compromised for ever, even if you commit to delete it. The history keeps it, and anybody who has cloned the repository has it.

Solution A: SOPS (encryption in the file)

SOPS encrypts the values of a YAML file while leaving the keys readable, using a key from a KMS, age or PGP.

# .sops.yaml at the root of the repository
creation_rules:
  - path_regex: k8s/environments/pro/secrets.*\.yaml$
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
sops --encrypt --in-place k8s/environments/pro/secrets.yaml
stringData:
  # The KEY is readable; the VALUE is encrypted
  db-password: ENC[AES256_GCM,data:8f2a91bc4d7e...,iv:3c1a...,tag:9e4f...,type:str]

An enormous advantage: the diffs stay readable, you can see which key changed even if not its value. Flux has native support (decryption: { provider: sops, secretRef: ... }); in Argo CD you need a plugin or ksops, which is a point against it.

Solution B: Sealed Secrets (asymmetric encryption per cluster)

A controller in the cluster generates a key pair. You encrypt with the public one; only that cluster can decrypt.

kubectl create secret generic bookings-api-credentials \
  --from-literal=db-password='super-secret-password' \
  --namespace=rutas-norte-pro --dry-run=client -o yaml > /tmp/plaintext.yaml
kubeseal --format yaml < /tmp/plaintext.yaml > k8s/environments/pro/sealed-secret.yaml
rm /tmp/plaintext.yaml         # important!

The resulting SealedSecret, with its encryptedData, is safe in a public repository. Trade-offs: the diff tells you nothing (the whole block changes even if you change one letter), the secret is tied to a specific namespace and name, and you have to back up the controller's master key or you will lose the ability to decrypt if you recreate the cluster.

Solution C: External Secrets Operator (a reference, no encryption)

Conceptually the cleanest: the secret is not in Git in any form, not even encrypted. Git holds only a reference.

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: bookings-api-credentials, namespace: rutas-norte-pro }
spec:
  # Re-check every hour: if the secret rotates in Vault, the Kubernetes
  # Secret updates itself.
  refreshInterval: 1h
  secretStoreRef: { name: vault-rutasnorte, kind: ClusterSecretStore }
  target: { name: bookings-api-credentials, creationPolicy: Owner }
  data:
    - secretKey: db-password
      remoteRef: { key: rutas-norte/pro/postgres, property: password }
    - secretKey: gateway-token
      remoteRef: { key: rutas-norte/pro/payments, property: token }
Criterion SOPS Sealed Secrets External Secrets
Where the secret lives Encrypted in Git Encrypted in Git Outside Git
Readable diffs Yes No Yes (there is no secret)
Rotation Re-encrypt and commit Re-seal and commit Automatic
Extra infrastructure A KMS or an age key A controller Vault/KMS + an operator
Risk if Git leaks Low Low None
Decrypting in several clusters Yes No (one per cluster) Yes
Argo CD / Flux integration Fair / Native Good / Good Good / Good
Access auditing No No Yes

Recommendation for Rutas Norte: External Secrets Operator with Vault. The platform handles customers' personal data and payment gateway credentials; automatic rotation and access auditing are not luxuries. Sealed Secrets is a reasonable alternative for getting started without extra infrastructure.

One rule does not change with any of the three: if a secret has reached Git in the clear, it has to be rotated. Always.

  1. Fields that change on their own: ignoreDifferences

And so we reach the problem we flagged back in 09-01.

bookings-api has an HPA that adjusts spec.replicas between 4 and 20 depending on load. The manifest in Git says replicas: 4. When the May bank-holiday weekend arrives, the HPA raises it to 15. With nothing else in place:

14:22:11  Detected out-of-sync: Deployment/bookings-api
          Live: spec.replicas = 15  |  Desired: spec.replicas = 4
14:22:13  Sync successful

Argo CD has dropped it to 4 replicas at peak hour. Thirty seconds later, the HPA raises them again. And three minutes after that, Argo CD lowers them once more. It is a tug of war between two controllers, and the one who suffers is the customer trying to buy a ticket.

The solution has two parts. First: do not declare replicas in the manifest, as we already did in 10-04. Second: ignoreDifferences, because even though the field is not in Git, Argo CD compares the live object with the generated one and spots it anyway.

spec:
  ignoreDifferences:
    # 1. The HPA governs the replicas
    - group: apps
      kind: Deployment
      jsonPointers: ["/spec/replicas"]

    # 2. The VPA modifies the resources (09-02). jqPathExpressions is
    #    more expressive than jsonPointers when there are conditions.
    - group: apps
      kind: Deployment
      name: notifications-worker
      jqPathExpressions:
        - '.spec.template.spec.containers[] | select(.name == "worker") | .resources'

    # 3. cert-manager injects the CA bundle into the webhooks (04-05)
    - group: admissionregistration.k8s.io
      kind: ValidatingWebhookConfiguration
      jqPathExpressions: ['.webhooks[]?.clientConfig.caBundle']

    # 4. The Service controller assigns clusterIP and nodePort
    - group: ""
      kind: Service
      jsonPointers: ["/spec/clusterIP", "/spec/ports/0/nodePort"]

    # 5. Ignore EVERYTHING managed by another controller. The most
    #    robust option when server-side apply is in use.
    - group: apps
      kind: Deployment
      managedFieldsManagers: [kube-controller-manager, vpa-updater]

Flux solves it differently: by removing the field from the desired object before applying, with a patch using op: remove on /spec/replicas, so that server-side apply does not claim ownership of it and the HPA is left free.

The list of fields that change on their own at Rutas Norte

Field Who changes it Lesson
Deployment.spec.replicas HPA / KEDA 09-01, 09-04
containers[].resources VPA in Auto mode 09-02
Service.spec.clusterIP, .nodePort The Service controller 04-02
PVC.spec.volumeName The dynamic provisioner 05-05
webhooks[].clientConfig.caBundle cert-manager 04-05
metadata.finalizers Various operators 06-07

Operational tip: when an application shows up as OutOfSync persistently and you cannot see why, argocd app diff tells you in two seconds. It is nearly always a field from this list.

  1. Repository structure and promotion between environments

git.rutasnorte.example/
├── applications/        <- source code + Dockerfile + tests
└── platform/
    ├── k8s/             <- manifests (base + environments, from 10-04)
    └── k8s-gitops/      <- Applications, AppProjects, ApplicationSets

Five reasons to separate code and manifests:

Reason Explanation
Different cycles A code change triggers a build and tests; a manifest change, only a deployment
Infinite loop If the pipeline commits the tag into the code repository, it triggers itself
Different permissions Development writes to the code; only platform approves changes in k8s/environments/pro
Clean auditing git log on k8s/ answers "what changed in production" without code noise
Argo CD's access The agent only needs to read the manifests, never the source code

The complete flow

sequenceDiagram
    participant D as Development
    participant CI as ci-rutasnorte
    participant R as registry
    participant CM as k8s repo
    participant A as Argo CD
    participant K as Cluster

    D->>CI: merges the code into main
    CI->>CI: builds, tests, scans (Trivy, 08-06)
    CI->>R: publishes bookings-api:2.4.1@sha256:... and signs with Cosign (08-05)
    CI->>CM: automatic commit in k8s/environments/dev
    CM-->>A: the agent detects the commit
    A->>K: deploys to rutas-norte-dev + PostSync smoke test
    D->>CM: PR: promote to pre (SAME digest, 1 approval)
    CM-->>A: syncs -> rutas-norte-pre
    Note over K: load tests with k6 (09-06)
    D->>CM: PR: promote to pro (2 approvals + window)
    CM-->>A: syncs -> rutas-norte-pro

The pipeline's final step, which only touches dev:

git clone --depth 1 [email protected]:platform/k8s.git /tmp/k8s
cd /tmp/k8s/k8s/environments/dev
kustomize edit set image \
  "registry.rutasnorte.example/bookings-api=registry.rutasnorte.example/bookings-api:${VERSION}@${DIGEST}"
git -c user.name=ci-rutasnorte -c [email protected] \
  commit -am "dev: bookings-api ${VERSION} (${DIGEST:0:19})"
git push origin main

The digest is promoted, not the tag. It is the guarantee that what was tested in pre is bit for bit what goes to pro. The pull request that promotes to production is literally copying three lines from pre/ to pro/: a diff you can review in ten seconds.

Change Who proposes it Who approves it Automatic
A new image in dev ci-rutasnorte Nobody Yes
dev configuration Development Development No
Promotion to pre Development Platform No
Promotion to pro Platform Platform + the product owner No
A change in k8s/base or in k8s-gitops Anybody / Platform Platform No
Reverting production Anybody 1 approval (fast track) No

That last row matters: reverting has to be fast. If the rollback procedure is as heavy as the deployment one, people will avoid reverting and fix things by hand, which is exactly what we set out to eliminate.

The mandatory checks on every PR are the ones from 10-04: kustomize build of the three overlays, kubeconform against the schema, kyverno apply of the policies (08-03) and argocd app diff posted automatically as a comment on the PR.

The first day somebody deletes something

This is the anecdote that convinces the sceptics, and it happens in every company that adopts GitOps. Somebody from support, debugging a problem, runs kubectl delete deployment bookings-api -n rutas-norte-pro. Their blood runs cold.

14:31:02  Deployment/bookings-api is Missing → Auto-sync (selfHeal)
14:31:04  Deployment/bookings-api created
14:31:39  Application rutas-norte-pro: Healthy

Thirty-seven seconds. The pods are back, the configuration is exactly right, and there is a record of what happened.

That day the team understands that the repository is not a copy of the cluster's documentation: it is the cluster. The cluster is only the current projection, and it is replaceable. The definitive test, worth rehearsing once a year: destroy rutas-norte-pre, create a new cluster, install Argo CD and apply one Application, the root. Twenty minutes later it is fully rebuilt: namespaces, applications, policies, monitoring, certificates. The data is restored separately with Velero (05-06), but the platform rebuilds itself.

Common Mistakes and Tips

1. Forgetting the resources-finalizer.argocd.argoproj.io finalizer. Without it, deleting an Application leaves all of its objects orphaned in the cluster, with nobody managing them.

2. Enabling prune: true without understanding the scope. If you get path wrong and it points at an empty directory, Argo CD will delete everything it was managing. Protect yourself with allowEmpty: false and always test in dev first.

3. selfHeal fighting the HPA. The loop from section 12. Remove replicas from the manifest and add ignoreDifferences. Both.

4. targetRevision: HEAD or main in production. Any commit to main is deployed to rutas-norte-pro immediately. Use a tag or a pinned commit, and promote deliberately.

5. Putting secrets in the repository "for now, we will fix it later". It never gets fixed, and the secret stays in the history for ever. Set up SOPS, Sealed Secrets or External Secrets before the first deployment.

6. Giving Argo CD's credential write access to Git. It only needs to read.

7. Not configuring an AppProject and leaving everything in default. The default project allows deploying anything into any namespace from any repository: whoever can write to the manifest repository can escalate to cluster administrator.

8. Confusing Synced with Healthy. Synced + Degraded means the deployment worked and the application is broken: look at the pods, not the repository.

9. Using argocd app rollback believing it solves the problem. It reverts the cluster, not Git. With selfHeal, the bad state comes back within three minutes. The real rollback is git revert.

10. Everything in one giant Application. When something fails, the entire application goes Degraded and you cannot tell what. Split it by component or by layer, with waves for ordering.

11. Editing production "just this once, it is an emergency". It will revert itself. If the change is correct, the fast track is a PR with one approval, which takes two minutes. Have that procedure ready before you need it.

12. Not monitoring Argo CD itself. If the controller is down, nothing syncs and you do not find out. Alert on a sustained argocd_app_info{sync_status="OutOfSync"} and on the health of the argocd pods (07-04).

13. Sync waves without health checks. Argo CD moves to the next wave when the previous one is healthy; if an object has no health evaluator (a CRD with no resource.customizations), it moves on immediately and the ordering counts for nothing.

Exercises

Exercise 1: an ApplicationSet with per-environment policies

Write an ApplicationSet that generates the three Rutas Norte applications with these differences: dev syncs automatically from main without selfHeal; pre from main with selfHeal and pruning; pro from the release-2.4 tag with selfHeal, pruning and server-side apply. All three must ignore the Deployments' spec.replicas. Add the AppProject restricting deployment to the three namespaces and forbidding the creation of cluster-scoped resources.

Exercise 2: diagnosing and resolving persistent drift

rutas-norte-pro has been OutOfSync for two days even though nobody has touched anything. Describe the complete diagnostic procedure with the exact commands, identify at least three possible causes from what the course has covered, and write the ignoreDifferences that would resolve them.

Exercise 3: ordering the deployment with waves and hooks

Design the annotations needed for a deployment to happen in this order: (1) namespace and quotas, (2) ExternalSecrets and ConfigMaps, (3) the schema migration before anything else, (4) bookings-postgres and redis-cache, (5) bookings-api, (6) web-store, (7) Ingress, (8) HPA and PDB, and a final smoke test that marks the application as degraded if it fails. Explain what happens if the migration fails.

Solutions

Solution 1

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata: { name: rutas-norte, namespace: argocd }
spec:
  sourceRepos: ["[email protected]:platform/k8s.git"]
  destinations:
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-dev }
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-pre }
    - { server: https://kubernetes.default.svc, namespace: rutas-norte-pro }
  clusterResourceWhitelist: []          # creating cluster objects is forbidden
  namespaceResourceWhitelist: [{ group: '*', kind: '*' }]
---
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata: { name: rutas-norte-environments, namespace: argocd }
spec:
  generators:
    - list:
        elements:
          - { environment: dev, rev: main,        selfHeal: "false", prune: "false", ssa: "false" }
          - { environment: pre, rev: main,        selfHeal: "true",  prune: "true",  ssa: "false" }
          - { environment: pro, rev: release-2.4, selfHeal: "true",  prune: "true",  ssa: "true"  }
  template:
    metadata:
      name: 'rutas-norte-{{.environment}}'
      labels: { environment: '{{.environment}}' }
      finalizers: [resources-finalizer.argocd.argoproj.io]
    spec:
      project: rutas-norte
      source:
        repoURL: [email protected]:platform/k8s.git
        path: 'k8s/environments/{{.environment}}'
        targetRevision: '{{.rev}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: 'rutas-norte-{{.environment}}'
      syncPolicy:
        automated: { prune: '{{.prune}}', selfHeal: '{{.selfHeal}}', allowEmpty: false }
        syncOptions: ['CreateNamespace=true', 'ServerSideApply={{.ssa}}']
      ignoreDifferences:
        - { group: apps, kind: Deployment, jsonPointers: ["/spec/replicas"] }

Solution 2

argocd app get rutas-norte-pro                    # which object is out of sync?
argocd app diff rutas-norte-pro                   # in which field? THE DECISIVE COMMAND
kubectl get deploy bookings-api -n rutas-norte-pro \
  --show-managed-fields -o yaml | yq '.metadata.managedFields'   # who manages it?
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp | tail -20
kubectl logs -n argocd deploy/argocd-repo-server --tail=100 | grep -i error
Cause Field Solution
The HPA scaling (09-01) Deployment.spec.replicas Remove it from Git + ignoreDifferences
VPA in Auto mode (09-02) containers[].resources jqPathExpressions
cert-manager injecting the CA (04-05) webhooks[].clientConfig.caBundle jqPathExpressions
ignoreDifferences:
  - { group: apps, kind: Deployment, jsonPointers: ["/spec/replicas"] }
  - group: apps
    kind: Deployment
    name: notifications-worker
    jqPathExpressions: ['.spec.template.spec.containers[] | select(.name=="worker") | .resources']
  - group: admissionregistration.k8s.io
    kind: ValidatingWebhookConfiguration
    jqPathExpressions: ['.webhooks[]?.clientConfig.caBundle']

Solution 3

Object Annotation
Namespace, ResourceQuota sync-wave: "-10"
ExternalSecret, ConfigMap sync-wave: "-5"
Migration Job hook: PreSync, hook-delete-policy: BeforeHookCreation
bookings-postgres, redis-cache sync-wave: "0"
bookings-api / web-store sync-wave: "1" / "2"
Service, Ingress sync-wave: "3"
HPA, PDB sync-wave: "5"
Smoke test Job hook: PostSync

If the migration fails: the PreSync hook does not complete, so Argo CD aborts the sync without applying a single manifest. The cluster stays exactly as it was, serving the previous version. The application shows up as Sync Failed and the Job still exists (thanks to BeforeHookCreation), so kubectl logs job/schema-migration -n rutas-norte-pro gives you the reason. It is the same protective behaviour as Helm's hooks from 10-03.

Conclusion

GitOps closes the circle we opened at the start of this module. Rutas Norte has gone from 120 files applied by hand from a laptop to a system where the repository is the source of truth and the cluster converges towards it continuously.

  • The four principles — declarative, versioned and immutable, pulled automatically, continuously reconciled — are the contract. The fourth is the one that changes everything.
  • The pull model eliminates the most dangerous credential: ci-rutasnorte no longer needs access to the cluster, only write access to a Git repository, and the apiserver no longer has to be reachable from outside.
  • Configuration drift is detected and corrected by itself. The cultural change it brings about — "the only way to change production is a pull request" — is worth more than the technology.
  • Argo CD with its Application, its AppProjects limiting the blast radius, the app of apps pattern that reduces manual work to a single kubectl apply in the cluster's entire lifetime, and the ApplicationSets that generate the three environments from one definition. Waves and hooks order the deployment and protect the schema migration.
  • Flux offers the same with specialised controllers, real Helm releases and built-in image automation. The choice is not irreversible: they read the same manifests.
  • Secrets have three practical solutions: SOPS (readable diffs), Sealed Secrets (no extra infrastructure) and External Secrets Operator (the secret never reaches Git, with automatic rotation). For Rutas Norte, the third.
  • And the conflict between the HPA and replicas we have been carrying since 09-01 is resolved: out of the manifest and ignoreDifferences in the Application, along with the whole family of fields other controllers modify.

One piece of the ecosystem remains. Everything we have built has to run somewhere, and in 10-02 we saw what it costs to operate your own cluster. In the next lesson, Managed Kubernetes: EKS, AKS and GKE, we will look at what a cloud provider saves you and what remains yours: the shared responsibility model, an honest comparison of the big three, the identity federation we left pending in 03-06, using spot instances for notifications-worker and occupancy-reports, and the final decision criteria for Rutas Norte.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved