Skip to content

4. GitOps with Argo CD

Until now you deployed with kubectl apply from your laptop: whoever has the credentials changes the cluster, and nothing records what is running. GitOps inverts the direction: the desired state lives in Git, and an agent inside the cluster — Argo CD — pulls it and keeps the cluster equal to it, forever. CI never touches the cluster; it only writes to Git. The theory is in the delivery class.

flowchart LR
    dev[git push] --> ci[CI: test, scan, image]
    ci -->|image sha-…| ghcr[(ghcr.io)]
    ci -->|success| deploy[Deploy workflow]
    deploy -->|"commit: new tag in k8s/overlays/gitops"| git[(GitHub repo)]
    argo[Argo CD in the cluster] -->|polls every 3 min| git
    argo -->|apply, prune, self-heal| k8s[boxoffice namespace]
    k8s -->|pull image| ghcr

The platform components

Use the kind cluster of lab 10 (recreate it with step 1 of lab 10 if you deleted it; Traefik and metrics-server are needed). First remove what lab 10 deployed by hand — from now on Argo CD owns the namespace:

kubectl config use-context kind-boxoffice
kubectl delete namespace boxoffice --wait=true

Install Argo CD, Argo Rollouts (step 5 uses it, and the GitOps overlay already contains a Rollout) and the small Prometheus of step 5. The large CRDs need server-side apply:

kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.5/manifests/install.yaml
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts --server-side \
  -f https://github.com/argoproj/argo-rollouts/releases/download/v1.10.1/install.yaml
kubectl apply -f k8s/monitoring/prometheus.yaml
kubectl -n argocd rollout status deployment/argocd-server --timeout=300s

You should see deployment "argocd-server" successfully rolled out, and kubectl get pods -n argo-rollouts and kubectl get pods -n monitoring should show one running pod each.

Argo CD reports an Ingress as Healthy only when the controller has written an address into its status, and a Traefik exposed as a NodePort writes none: the Application would stay Progressing forever. Make Traefik publish 127.0.0.1:

helm upgrade traefik traefik/traefik --version 42.0.0 --namespace traefik --reuse-values \
  --set providers.kubernetesIngress.publishedService.enabled=false \
  --set providers.kubernetesIngress.ingressEndpoint.ip=127.0.0.1

After a minute, kubectl -n boxoffice get ingress shows 127.0.0.1 under ADDRESS (once the Application has created the Ingress, below).

Open the Argo CD UI in a second terminal and log in as admin:

kubectl -n argocd port-forward svc/argocd-server 8443:443          # keep it running
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo

Browse to https://localhost:8443 (accept the self-signed certificate) and use that password.

Access to your repository and images

Your repository and your packages are private, so the cluster needs read-only credentials. Create a fine-grained personal access token on GitHub (Settings → Developer settings → Fine-grained tokens) for your boxoffice repository with Contents: read, and a classic token with read:packages for the images (or make the boxoffice-catalog package public and skip the second secret). Never commit them:

export GH_USER=<your GitHub user, lowercase>
export REPO_TOKEN=<the fine-grained token>
export PKG_TOKEN=<the read:packages token>

kubectl -n argocd create secret generic boxoffice-repo \
  --from-literal=type=git \
  --from-literal=url=https://github.com/$GH_USER/boxoffice.git \
  --from-literal=username=$GH_USER --from-literal=password=$REPO_TOKEN
kubectl -n argocd label secret boxoffice-repo argocd.argoproj.io/secret-type=repository

kubectl create namespace boxoffice
kubectl -n boxoffice create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io --docker-username=$GH_USER --docker-password=$PKG_TOKEN

The GitOps overlay

A new overlay, next to lab 10's local one: the same base, the image from GHCR, the Rollout of step 5, and a patch that exposes /actuator/prometheus.

k8s/overlays/gitops/kustomization.yaml
# The environment Argo CD deploys (lab 11). CI changes only the "images" entry below.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: boxoffice
resources:
  - ../../base
  - catalog-services.yaml
  - catalog-rollout.yaml
  - catalog-analysis.yaml
images:
  - name: boxoffice/catalog
    newName: ghcr.io/your-user/boxoffice-catalog   # your GitHub user, lowercase
    newTag: sha-0000000000000000000000000000000000000000   # written by .github/workflows/deploy.yaml
secretGenerator:
  - name: db-credentials             # lab only: use Sealed Secrets / External Secrets in real projects
    namespace: boxoffice
    literals:
      - DB_USER=boxoffice
      - DB_PASSWORD=boxoffice
patches:
  # the Rollout runs the pods from now on: the Deployment only lends it its pod template
  - target: { kind: Deployment, name: catalog }
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 0
      - op: add
        path: /spec/template/spec/imagePullSecrets
        value: [{ name: ghcr-pull }]
      - op: add                      # expose /actuator/prometheus for the canary analysis
        path: /spec/template/spec/containers/0/env/-
        value: { name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE, value: "health,info,metrics,prometheus" }
      - op: add                      # publish the latency buckets (_bucket) that the p99 analysis needs
        path: /spec/template/spec/containers/0/env/-
        value: { name: MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILESHISTOGRAM_HTTP_SERVER_REQUESTS, value: "true" }
  # the HPA now scales the Rollout
  - target: { kind: HorizontalPodAutoscaler, name: catalog }
    patch: |-
      - op: replace
        path: /spec/scaleTargetRef
        value: { apiVersion: argoproj.io/v1alpha1, kind: Rollout, name: catalog }
k8s/overlays/gitops/catalog-services.yaml
# Argo Rollouts points these two Services at the stable and the canary pods (it adds the
# rollouts-pod-template-hash to their selectors). Prometheus scrapes them, so every series
# carries service="catalog-stable" or service="catalog-canary".
apiVersion: v1
kind: Service
metadata:
  name: catalog-stable
  annotations:
    prometheus.io/scrape: "true"
spec:
  selector:
    app: catalog
  ports:
    - name: http
      port: 8082
      targetPort: http
---
apiVersion: v1
kind: Service
metadata:
  name: catalog-canary
  annotations:
    prometheus.io/scrape: "true"
spec:
  selector:
    app: catalog
  ports:
    - name: http
      port: 8082
      targetPort: http
k8s/overlays/gitops/catalog-rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: catalog
spec:
  # no "replicas": the HPA owns it
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: catalog
  workloadRef:                       # reuse the pod template of the Deployment in base/
    apiVersion: apps/v1
    kind: Deployment
    name: catalog
    scaleDown: never                 # Git already sets the Deployment to 0 replicas
  strategy:
    canary:
      stableService: catalog-stable
      canaryService: catalog-canary
      analysis:                      # runs in the background from the first step
        templates:
          - templateName: catalog-slo
        startingStep: 1
      steps:
        - setWeight: 25
        - pause: { duration: 2m }
        - setWeight: 50
        - pause: { duration: 2m }
        # after the last step the canary becomes the new stable
k8s/overlays/gitops/catalog-analysis.yaml
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: catalog-slo
spec:
  metrics:
    - name: error-rate
      interval: 30s
      failureLimit: 1                # a second bad measurement aborts and rolls back
      successCondition: len(result) == 0 || isNaN(result[0]) || result[0] < 0.01
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_server_requests_seconds_count{service="catalog-canary", status=~"5.."}[1m]))
            /
            sum(rate(http_server_requests_seconds_count{service="catalog-canary"}[1m]))
    - name: p99-latency
      interval: 30s
      failureLimit: 1
      successCondition: len(result) == 0 || isNaN(result[0]) || result[0] < 0.5
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            histogram_quantile(0.99, sum by (le) (
              rate(http_server_requests_seconds_bucket{service="catalog-canary"}[1m])))

Replace your-user in newName with your GitHub user (lowercase) and leave newTag as it is: the Deploy workflow writes it after the next successful CI run. kubectl kustomize k8s/overlays/gitops must print the manifests without errors.

The Deploy workflow

.github/workflows/deploy.yaml
# Continuous delivery by GitOps (lab 11): when CI succeeds on main, point the GitOps overlay at
# the image built from that commit. Argo CD, inside the cluster, sees the commit and deploys it.
# This workflow never talks to the cluster: it only writes to Git.
name: Deploy

on:
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]

permissions:
  contents: write           # commit the new image tag

concurrency:
  group: deploy
  cancel-in-progress: false # deploys run one after the other, never in parallel

jobs:
  promote:
    if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: main
      - name: Set the catalog image of the gitops overlay to this commit
        env:
          SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          OWNER="${GITHUB_REPOSITORY_OWNER,,}"
          cd k8s/overlays/gitops
          kustomize edit set image "boxoffice/catalog=ghcr.io/${OWNER}/boxoffice-catalog:sha-${SHA}"
          git diff --stat
      - name: Commit and push (a commit made with GITHUB_TOKEN does not trigger CI again)
        env:
          SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add k8s/overlays/gitops/kustomization.yaml
          git diff --cached --quiet && { echo "already deployed"; exit 0; }
          git commit -m "deploy: catalog sha-${SHA::7}"
          git push

It runs when CI completes on main, edits the images entry of the overlay with kustomize edit set image (Kustomize is preinstalled on GitHub's Ubuntu runners) and pushes the commit. Kustomize rewrites the whole file when it edits it: the comments disappear and the lists are re-indented, which is expected. A commit pushed with the workflow's GITHUB_TOKEN does not start new workflow runs, and CI ignores changes to k8s/** anyway, so there is no loop. Because Deploy commits to main, your local main falls behind after every deploy: from now on run git pull --rebase before each git push.

Branch protection

If main requires pull requests, the bot cannot push to it. Either allow the GitHub Actions bot to bypass the rule, or change the workflow to open a pull request with the new tag (a human — or a policy — approves each deployment).

The Application

k8s/argocd/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: boxoffice
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-user/boxoffice.git
    targetRevision: main
    path: k8s/overlays/gitops
  destination:
    server: https://kubernetes.default.svc
    namespace: boxoffice
  syncPolicy:
    automated:
      prune: true                    # delete what was removed from Git
      selfHeal: true                 # revert manual changes in the cluster
    syncOptions:
      - CreateNamespace=true

Replace your-user in repoURL, commit and push everything, then register the Application (the only kubectl apply of the flow — from now on Argo CD applies):

git add .github k8s && git commit -m "lab 11: gitops" && git push
kubectl apply -f k8s/argocd/application.yaml
kubectl -n argocd get applications -w

Until the first Deploy commit lands, catalog cannot start (newTag is still the placeholder) and the Application shows Degraded — expected. When the push triggers CI and then Deploy, Argo CD picks up the new commit (it polls every 3 minutes; Refresh in the UI forces it). You should see the Application go to Synced and Healthy, the tree of resources in the UI (Namespace, StatefulSet db, Deployment redis, Rollout catalog with its pods…), and curl localhost/catalog/events/1 answering 200 through Traefik.

Drift and self-healing

Change the cluster by hand and watch Argo CD undo it:

kubectl -n boxoffice scale deployment redis --replicas=0
kubectl -n boxoffice get deployment redis -w

Within seconds Argo CD reports OutOfSync and, with selfHeal: true, restores 1 replica: the cluster can only be changed through Git. To roll back a release, you git revert the deploy commit.

Next: Canary with Argo Rollouts