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
- What GitOps is and its four principles
- Push model versus pull model
- Configuration drift and self-healing
- Argo CD: architecture and installation
- The Application resource field by field
- App of apps and ApplicationSet
- Sync waves and hooks
- Health and sync statuses
- Flux: the controllers and their resources
- Argo CD versus Flux
- The secrets problem
- Fields that change on their own: ignoreDifferences
- Repository structure and promotion between environments
- Common mistakes and tips
- Exercises
- Conclusion
- 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:
- 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.
- 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. - Pulled automatically: approved changes are applied without manual intervention. Nobody runs
kubectl applyorhelm upgrade: a pull request is merged and the system converges. - 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 |
- 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:
- That kubeconfig is a secret stored outside the cluster, often managed by another team or by an external provider.
- 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. - 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.
- The apiserver has to be reachable from outside.
- 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.
- 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 editto test a hypothesis. It stayed that way for six months. - An annotation was added to the Ingress to tune a timeout. When the
precluster 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
selfHealper application. Inrutas-norte-devit can be worth leaving it off so people can experiment; inrutas-norte-proit must be on.
- 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-secretIn 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:readonlyAnd 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
- 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: 20The 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 emergencyThat 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].
- 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 }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.
- 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.
- 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 hsargocd 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 600Careful 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.
- 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/fluxThat 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 hasFor 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.0And 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
- 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/.
- 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: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pstringData:
# 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.
- 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 successfulArgo 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.
- 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, ApplicationSetsFive 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 mainThe 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: HealthyThirty-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-rutasnorteno 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, itsAppProjects limiting the blast radius, the app of apps pattern that reduces manual work to a singlekubectl applyin the cluster's entire lifetime, and theApplicationSets 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
replicaswe have been carrying since 09-01 is resolved: out of the manifest andignoreDifferencesin 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
