Merge a change, watch ArgoCD sync it to the cluster, then break a deploy on purpose and watch it heal itself back.
The cluster should match the repo, not your memory
The old way of shipping to Kubernetes is a person running kubectl apply from a terminal — which means the real state of production lives in whatever commands happened to be run, in what order, by whom. GitOps inverts that: a Git repository becomes the single source of truth for what every environment should contain, and an agent in the cluster — here, ArgoCD — continuously compares the live state to the repo and reconciles any drift. Deploying becomes a pull request. Rolling back becomes a revert. The audit trail writes itself, because every change is a commit.
You will stand up ArgoCD on AKS, model an application as a Helm chart promoted across dev → qa → prod by nothing more than which values file each environment points at, make a bad deploy roll itself back, and finally hand the trigger to an Azure DevOps pipeline so a commit to main flows all the way to a running pod untouched by human hands.
If it is not in Git, it is not in production.
ArgoCD runs on a real AKS cluster that bills by the hour — budget $4–8 for a short sitting and do it in one go. The final step deletes everything; set a budget alert first. A single small node pool is plenty for this lab.
You need a paid Azure subscription, the Azure CLI, kubectl, and Helm, plus a Git repository you can push to (GitHub or Azure Repos) and an Azure DevOps project for Step 5. This is an advanced build — it assumes you have met AKS, kubectl, and Helm. If not, start with the identity and container labs first.
A cluster and a repository
Create a small AKS cluster and a Git repository — the two ends of the GitOps loop. The repo is where you will declare desired state; the cluster is where ArgoCD makes it real.
# Windows/Git Bash: leave resource-id args alone (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 RG="campux-gitops-rg" az group create -n "$RG" -l eastus az aks create -g "$RG" -n campux-gitops-aks \ --node-count 2 --node-vm-size Standard_D2s_v3 --generate-ssh-keys az aks get-credentials -g "$RG" -n campux-gitops-aks # a registry for Step 5's pipeline to push to — name must be globally unique ACR="campuxgitops$RANDOM" az acr create -g "$RG" -n "$ACR" --sku Basic az aks update -g "$RG" -n campux-gitops-aks --attach-acr "$ACR" echo "$ACR.azurecr.io" # the registry host to push to in Step 5 # a repo to hold desired state — push an empty one now, fill it in Step 2 git init campux-gitops && cd campux-gitops git commit --allow-empty -m "root" && git branch -M main # create it on GitHub/Azure Repos, then: git remote add origin <url> && git push -u origin main
kubectl get nodes shows two Ready nodes, az acr show -n "$ACR" --query loginServer returns the registry's login server, and you have a Git repo ArgoCD can read. Keep the repo URL and the registry host handy — every ArgoCD Application points at the repo, and Step 5's pipeline pushes to the registry.Install ArgoCD, the agent in the cluster
ArgoCD installs as a set of controllers in its own namespace. It watches your Git repo and drives the cluster toward what it finds there. Expose the UI just long enough to log in and get the initial password.
kubectl create namespace argocd # --server-side avoids "metadata.annotations: Too long" on this manifest's large CRDs kubectl apply -n argocd --server-side --force-conflicts \ -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml kubectl rollout status deploy/argocd-server -n argocd # reach the UI locally (no public LoadBalancer needed for a lab) kubectl port-forward svc/argocd-server -n argocd 8080:443 & # initial admin password: kubectl -n argocd get secret argocd-initial-admin-secret \ -o jsonpath="{.data.password}" | base64 -d; echo
https://localhost:8080 and log in as admin with that password. The UI is empty — no Applications yet. That is the next step: telling ArgoCD what to watch.One Helm chart, three environments
Model the app once as a Helm chart, and let a values file per environment carry the only differences — replica count, image tag, resource limits. Promotion becomes "point prod at the tag dev has been running." Commit this to the repo you made in Setup.
# repo layout campux-gitops/ app/ Chart.yaml values.yaml # defaults values-dev.yaml # image.tag: dev-latest, replicas: 1 values-qa.yaml # image.tag: rc-1.4.0, replicas: 2 values-prod.yaml # image.tag: 1.3.0, replicas: 3 templates/deployment.yaml templates/service.yaml
# app/values-prod.yaml — the only thing that changes between envs
image:
repository: mcr.microsoft.com/azuredocs/aks-helloworld
tag: "v1"
replicaCount: 3
resources:
requests: { cpu: 50m, memory: 64Mi }
helm lint app) and renders (helm template app -f app/values-prod.yaml). You now have one artefact that describes three environments by data, not by copy-paste — the property that makes promotion a one-line diff.Declare an Application per environment
An ArgoCD Application is itself a Kubernetes object: it says "take this path in this repo, render it with this values file, and keep this namespace in sync." Create one per environment. This is GitOps managing GitOps — the Applications can live in the repo too.
kubectl apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: campux-prod
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/YOU/campux-gitops.git
path: app
targetRevision: main
helm:
valueFiles: [values-prod.yaml]
destination:
server: https://kubernetes.default.svc
namespace: prod
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [CreateNamespace=true]
EOF
Repeat the same kubectl apply for campux-dev and campux-qa: change name, valueFiles, and namespace to match each environment's values file. Step 5 syncs campux-dev, so create it now, not only campux-prod.
replicaCount in values-prod.yaml, commit, and watch ArgoCD reconcile within a minute. You just deployed with a git push.Make a bad deploy roll itself back
Automation is only trustworthy if failure is handled. selfHeal: true already reverts manual drift; add real health checks so ArgoCD knows when a rollout is unhealthy, and it will hold or roll back rather than leave production broken.
# app/templates/deployment.yaml — probes make health real
livenessProbe: { httpGet: { path: /, port: 80 }, initialDelaySeconds: 5 }
readinessProbe: { httpGet: { path: /, port: 80 }, initialDelaySeconds: 5 }
Now break it on purpose: commit a values change setting image.tag to something that does not exist. ArgoCD tries the new ReplicaSet, the pods never become ready, the Application goes Degraded, and the old ReplicaSet keeps serving traffic. Fix it the GitOps way — a revert, not a person running a rollback command against the cluster.
# prove the old pods still serve while the bad one never goes Ready kubectl get rs -n prod # the fix is a revert, committed and pushed — the repo stays the source of truth git revert --no-edit HEAD && git push
git push, ArgoCD syncs the reverted values within its polling interval and the Application returns to Synced / Healthy. (The ArgoCD CLI has its own argocd app rollback, but that needs the CLI installed and logged in separately — the repo-only fix above needs nothing but Git.)Close the loop with Azure DevOps
The last piece: a pipeline that turns a commit of application code into a commit of desired state. Azure DevOps builds and pushes the image, then writes the new tag into values-dev.yaml and pushes that — and ArgoCD, watching the repo, syncs it. CI builds the artefact; Git carries the intent; ArgoCD does the deploy. No pipeline ever touches the cluster.
Push needs somewhere real to land: the ACR from Setup for the image, and write access back to the same Git repo for the values commit. In the Azure DevOps project, add a Docker Registry service connection pointing at $ACR.azurecr.io (Project Settings → Service connections → New → Docker Registry → Azure Container Registry) and name it acrConnection; check Allow scripts to access the OAuth token on the pipeline, or grant the Build Service identity Contribute on the repo, so the last step can push.
# azure-pipelines.yml (essentials) trigger: { branches: { include: [main] } } pool: { vmImage: ubuntu-latest } steps: - checkout: self persistCredentials: true # leaves the OAuth token in git config so the push below works - task: Docker@2 inputs: { command: buildAndPush, containerRegistry: acrConnection, repository: campux/app, tags: "$(Build.BuildId)" } - script: | TAG=$(Build.BuildId) yq -i ".image.tag = \"$TAG\"" app/values-dev.yaml git config user.email ci@campux.co && git config user.name "Azure DevOps" git commit -am "ci: dev image $TAG" && git push origin HEAD:main displayName: "Bump dev tag → let ArgoCD sync"
main runs the pipeline, which builds and pushes $ACR.azurecr.io/campux/app:<build id>, then lands a ci: dev image … commit on the repo; within ArgoCD's polling interval the campux-dev Application syncs the new tag. You have built the full path — commit to running pod — with the cluster pulling from Git rather than the pipeline pushing to the cluster. Promotion to qa and prod is now just a values change someone reviews and merges.Tear it down
The cluster and the registry both bill until they are gone, and both live in the same resource group, so one delete removes AKS, ArgoCD, the ACR, and everything they ran.
az group delete -n campux-gitops-rg --yes --no-wait
az group exists -n campux-gitops-rg # -> false once the async delete finishes
What you can now honestly claim
You ran GitOps on AKS with ArgoCD — a Helm chart promoted across dev, qa, and prod by data alone, automated sync with self-heal, health-check-driven rollback, and an Azure DevOps pipeline that delivers by committing desired state rather than pushing to the cluster. That is "implemented GitOps continuous delivery on Kubernetes with ArgoCD and Azure DevOps" — a senior platform-engineering line, done rather than described. The durable idea travels to Flux, to any cloud, and to your own weekend projects: make the repository the truth, and let an agent keep the world in step with it.
- ArgoCD's default polling reconciles roughly every three minutes; for instant syncs, wire a repo webhook to ArgoCD so a push triggers reconciliation immediately rather than on the timer.
- Storing the ArgoCD
Applicationmanifests in Git too — the "app of apps" pattern — means even your delivery configuration is version-controlled and reviewable, closing the last gap where state could live outside the repo. - Real progressive delivery (true canary with traffic weighting) is Argo Rollouts, a sibling project, layered on top of ArgoCD. This lab uses health checks + self-heal as the honest floor; Rollouts is the next rung when a service needs weighted traffic shifts.