Week 17: GitOps & CI/CD for Kubernetes

Every deploy so far has been you, manually, running kubectl apply or helm upgrade. That doesn't scale to a team, and it means the cluster's actual state and what's recorded in Git can silently drift apart. This week wires a CI pipeline that builds and pushes images automatically, and a GitOps controller — ArgoCD — that continuously makes the cluster match what Git says it should be, closing the loop.

Module 17 of 17 Week 17 of 18 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a CI pipeline that builds, tests and pushes an image on every commit
  • Explain GitOps: Git as the single source of truth, continuously reconciled
  • Set up ArgoCD to auto-deploy on a Git commit, and observe it detect and fix drift

1. A CI Pipeline for Images

The first half of the pipeline: every push to main automatically builds, tests, and pushes a new image, tagged with the exact commit SHA — the tagging discipline from Week 6, now automated.

.github/workflows/build.yml
name: Build and Push
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        run: docker build --target test -t my-app:test .   # a "test" stage in the Dockerfile

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        run: |
          docker build -t ghcr.io/my-org/my-app:${{ github.sha }} .
          docker push ghcr.io/my-org/my-app:${{ github.sha }}

Notice the image is tagged with github.sha — the exact Git commit — never latest. This is what makes the GitOps step in Section 3 actually work: a specific commit maps to a specific, traceable image, with no ambiguity.

This is Week 6's scanning step, wired into the pipeline

Add a Trivy or Docker Scout scan step right after the build, configured to fail the pipeline on new critical/high-severity findings — exactly the "scan in CI, not just locally" idea flagged back in Week 6, now enforced automatically on every commit.

2. What GitOps Actually Means

CI (Section 1) handles building and testing. GitOps is about deployment: instead of a pipeline running kubectl apply directly against your cluster, a controller running inside the cluster continuously watches a Git repo and reconciles the cluster to match it.

the two models, compared
TRADITIONAL CI/CD PUSH MODEL:
  CI pipeline --(kubectl apply, needs cluster credentials)--> Cluster
  Cluster state = whatever the last pipeline run pushed. Manual kubectl
  changes made directly against the cluster are invisible to Git.

GITOPS PULL MODEL:
  Git repo (desired state) <--(continuously watched)-- ArgoCD (in-cluster)
  ArgoCD reconciles the CLUSTER to match GIT, continuously — not just at
  deploy time. A manual kubectl change gets silently reverted, because
  Git, not the cluster, is the source of truth.

That last line is the whole philosophy in one sentence: Git is the single source of truth, not the cluster. If it's not in Git, it doesn't count as a legitimate change — and if the cluster drifts from Git for any reason, it gets pulled back automatically.

The security upside is real, not just process hygiene

In the pull model, no external CI system needs cluster-admin credentials at all — ArgoCD runs inside the cluster and pulls from Git, rather than an external pipeline needing broad, standing access to push into it. This meaningfully shrinks the attack surface around your cluster's credentials.

3. ArgoCD, Set Up

Installing ArgoCD and pointing it at a Git repo containing your Helm chart from Week 13:

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

kubectl port-forward svc/argocd-server -n argocd 8080:443
# open https://localhost:8080 — get the initial admin password:
kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d
application.yaml — telling ArgoCD what to watch
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/my-org/my-app-manifests.git
    targetRevision: main
    path: charts/my-app
    helm:
      valueFiles:
        - values-prod.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: prod
  syncPolicy:
    automated:
      prune: true       # remove resources deleted from Git
      selfHeal: true      # revert manual cluster changes back to match Git

selfHeal: true is the setting that makes GitOps's promise real — without it, ArgoCD only syncs on demand; with it, drift is corrected automatically and continuously, which is what Section 4 demonstrates directly.

This "Application" object is itself declarative

Even ArgoCD's own configuration follows the same pattern as everything else in this course — a YAML object describing desired state (what repo, what path, what destination) that a controller reconciles toward. The pattern really is that consistent throughout Kubernetes.

4. Drift Detection, Live

Prove the whole loop works, end to end — from a Git commit to a live cluster change, and separately, from a manual cluster change back to Git's version:

the forward direction — Git change deploys automatically
# Edit values-prod.yaml in your manifests repo, bump replicaCount, commit, push
git commit -am "scale prod to 5 replicas"
git push

# Within ArgoCD's sync interval (default ~3 minutes, or instantly with a webhook):
kubectl get application my-app -n argocd    # STATUS moves to OutOfSync, then Syncing, then Synced
kubectl get deployment my-app -n prod        # replica count updates automatically
the reverse direction — manual drift gets corrected
# Make a manual change directly against the cluster, bypassing Git entirely:
kubectl scale deployment/my-app --replicas=1 -n prod

# With selfHeal enabled, ArgoCD notices the mismatch and reverts it automatically:
kubectl get deployment my-app -n prod -w
# replicas briefly shows 1... then reverts back to 5, matching Git

That reversal is the entire point made concrete: the cluster is not the source of truth, Git is — and any change that didn't go through Git doesn't survive.

Looking ahead to the capstone

Week 18 expects a full CI/CD + GitOps pipeline as part of the final deliverable — this week's exercise builds the exact skeleton you'll extend there: a CI workflow, a manifests repo, and ArgoCD tying them together.

5. Hands-on Exercise

Hands-on

Wire a real CI pipeline to a real ArgoCD deployment

Build both halves end to end: a CI workflow that pushes images on commit, and an ArgoCD Application that keeps your cluster continuously matched to a Git repo.

Part 1 — CI pipeline:

  1. Add a GitHub Actions workflow to your app's repo that builds and pushes an image to GHCR (or your registry of choice), tagged with the commit SHA, on every push to main.
  2. Push a commit and confirm in the Actions tab that it runs successfully and a new image appears in your registry with the expected tag.
  3. Add a scan step (Trivy is free and simple to add as a GitHub Action) after the build, and confirm it runs and reports findings without necessarily failing the build yet.
Hint

The built-in GITHUB_TOKEN secret is usually enough to push to GHCR without any extra credential setup — check your repository's Package permissions if you get an authorization error.

Part 2 — ArgoCD deployment:

  1. Create a separate Git repo (or a folder in your existing one) holding your Week 13 Helm chart and a values-prod.yaml.
  2. Install ArgoCD on your local cluster and create an Application pointing at that repo/path, with selfHeal: true.
  3. Confirm it syncs and your app deploys via ArgoCD rather than a manual helm install.
  4. Perform both drift tests from Section 4: commit a values change and watch it deploy automatically, then manually kubectl scale something and watch ArgoCD revert it. Write two sentences describing what you observed in each case.
Hint

If ArgoCD's default sync interval feels slow to watch during testing, click "Refresh" (or "Sync") manually in the ArgoCD UI or via argocd app sync my-app to trigger reconciliation immediately, rather than waiting for the polling interval.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In the GitOps model, what is the single source of truth for what should be running in the cluster?

Git. The cluster's actual state is continuously reconciled to match what's declared in a Git repository — any change made directly against the cluster that isn't reflected in Git is considered drift and, with self-healing enabled, gets automatically reverted.

Q2

What's the key structural difference between a traditional CI/CD "push" pipeline and a GitOps "pull" model?

In a push model, an external CI system runs kubectl apply against the cluster directly, requiring it to hold cluster credentials. In a pull model, a controller (ArgoCD) runs INSIDE the cluster and pulls changes from Git — no external system needs standing cluster-admin access, reducing the attack surface.

Q3

What does ArgoCD's selfHeal: true setting actually do?

It makes ArgoCD automatically revert any manual, out-of-band change made directly against the cluster (e.g. a manual kubectl scale) back to whatever is declared in Git, without requiring a human to notice the drift and fix it manually.

Q4

Why does the CI pipeline tag images with the Git commit SHA instead of a version number or "latest"?

It guarantees an unambiguous, traceable link from a deployed image back to the exact commit that produced it, with no possibility of the tag being reused or reassigned — the same tagging discipline from Week 6, now essential for GitOps since a Git commit needs to map deterministically to a specific image.