Master GitOps: 7 Practical Hands-on CI/CD Learning Exercises

DevOps

YOUR COSMETIC CARE STARTS HERE

Find the Best Cosmetic Hospitals

Trusted • Curated • Easy

Looking for the right place for a cosmetic procedure? Explore top cosmetic hospitals in one place and choose with confidence.

“Small steps lead to big changes — today is a perfect day to begin.”

Explore Cosmetic Hospitals Compare hospitals, services & options quickly.

✓ Shortlist providers • ✓ Review options • ✓ Take the next step with confidence

Continuous Integration and Continuous Delivery (CI/CD) have reshaped how modern software teams build, test, and release applications. Yet, as cloud-native architectures grow more distributed, maintaining parity between what developers intend to deploy and what actually runs inside a cluster becomes challenging. Traditional push-based deployment pipelines often rely on centralized deployment scripts holding high-privilege cluster credentials, creating operational bottlenecks and security risks. GitOps solves these operational challenges by turning your Git repository into the canonical single source of truth for your entire system infrastructure and application runtime. Rather than running deployment scripts that push changes into an environment, a specialized software agent inside the target environment continuously observes the repository and pulls the declared state inward.

For students, beginners, and early-career DevOps engineers, reading about these concepts is only the starting point. Developing true competence requires working through GitOps hands-on CI/CD learning exercises where you can trigger deployments, observe reconciliation loops, fix configuration drift, and perform safe rollbacks in a disposable sandbox. If you are learning through structured platforms such as DevOpsSchool or practicing independently on your local workstation, this guide walks you through seven progressive, practical exercises designed to build real-world confidence.

Table of Contents

Understand the GitOps Workflow Before Starting

Before running terminal commands, you need a clear mental model of how GitOps alters the traditional deployment paradigm. At its core, GitOps is an operating model for cloud-native applications that relies on four foundational pillars:

+-------------------------------------------------------------+
|                     GITOPS ARCHITECTURE                     |
+-------------------------------------------------------------+

 [Developer] 
      │ (git push)
      ▼
+──────────────────────────+       +──────────────────────────+
|  Git Repository          |       |  CI Pipeline             |
|  - Source of Truth       | ────> |  - Linting & Syntax      |
|  - Declarative Manifests |       |  - Security & Dry-Run    |
+──────────────────────────+       +──────────────────────────+
      │                                       │ (Passes)
      │                                       ▼
      │                             [Approved Pull Request]
      │                                       │
      ▼                                       ▼
+─────────────────────────────────────────────────────────────+
|  Kubernetes Cluster                                         |
|                                                             |
|   +────────────────────────+     Continuous                 |
|   | GitOps Controller      | <── Reconciliation Loop        |
|   | (Argo CD / Flux)       |                                |
|   +────────────────────────+                                |
|               │                                             |
|               ▼ (Applies State)                             |
|   +──────────────────────────────────────────────────────+  |
|   | Cluster Runtime (Pods, Services, Ingresses)          |  |
|   +──────────────────────────────────────────────────────+  |
+─────────────────────────────────────────────────────────────+

The Desired-State Model and Declarative Configuration

In traditional imperative computing, you issue sequential commands telling the system how to do something (for example: docker run, systemctl restart, or kubectl scale). If a command fails halfway through, the system is left in an unknown, partially configured state.

GitOps relies strictly on declarative configuration. Instead of listing commands, you write configuration manifests (such as Kubernetes YAML files) that describe what the system should look like:

  • “There should be three replicas of the web application running image v1.2.0.”
  • “The application must listen on port 8080.”
  • “A ClusterIP service must balance traffic across these replicas.”

Git as the Single Source of Truth

In a true GitOps workflow, your running infrastructure and deployed workloads should mirror the state stored in Git. If a resource is not defined in Git, it should not exist in the cluster. If an attribute changes in Git, that change must be reflected in the cluster. Because Git preserves an immutable, cryptographically signed commit log, every change is auditable, reviewable, and reversible.

The Reconciliation Loop

How does the system ensure that runtime reality matches the declaration in Git? Through an active software loop known as the reconciliation loop.

A GitOps controller runs inside the target cluster. It continuously polls or receives webhooks from your Git repository, compares the declared desired state in Git against the live state inside Kubernetes, and calculates any difference (known as divergence or drift). When drift occurs, the controller automatically acts to bring the live cluster back into alignment with the repository.

Separating CI from CD in GitOps

One common beginner misconception is assuming that GitOps eliminates CI. It does not. Instead, GitOps redraws the boundary between Continuous Integration and Continuous Delivery:

  • Continuous Integration (CI): Focuses on compilation, unit tests, code linting, packaging container images, running security scans, and validating deployment manifests. CI ends when an artifact is built and a declarative configuration file is validated and committed.
  • Continuous Delivery / Continuous Deployment (CD): Managed by the GitOps controller inside the cluster pulling configurations from Git. The CI pipeline never needs administrative credentials to the Kubernetes API.

Crucially, GitOps does not mean running kubectl apply from a developer’s laptop. Direct access to the cluster API is restricted. Every change—without exception—travels through a version-controlled Git commit.

Prepare a Safe Learning Environment

To complete the exercises in this guide, you do not need an expensive cloud account or a multi-node production cluster. You can run every exercise locally on Linux, macOS, or Windows (via WSL2).

+--------------------------------------------------------------+
|                     LOCAL LAB STACK                          |
+--------------------------------------------------------------+
| [Local Workstation]                                          |
|                                                              |
|  - Git CLI (Version Control)                                 |
|  - Docker / Podman (Container Runtime)                       |
|  - K3d / Minikube / Kind (Local Kubernetes Cluster)          |
|  - Kubectl CLI (Kubernetes Client)                           |
|  - GitOps Controller (Argo CD running inside the cluster)    |
|  - Remote Git Hosting (GitHub / GitLab / Gitea account)      |
+--------------------------------------------------------------+

Required Tooling and Component Roles

  1. Git CLI (git): Tracks your declarative manifests, creates branches, manages commits, and pushes updates to your remote repository.
  2. Container Engine (Docker or Podman): Provides the virtualization runtime required to spin up lightweight local Kubernetes nodes.
  3. Local Kubernetes Engine (k3d, minikube, or kind): Creates disposable, lightweight Kubernetes clusters inside container sandboxes. We will use k3d (which runs lightweight K3s in Docker) due to its minimal resource footprint, but minikube or kind work just as well.
  4. Kubernetes CLI (kubectl): Used to inspect cluster state, view controller logs, and observe running pods.
  5. GitOps Controller (Argo CD): The cluster-side agent that monitors your Git repository and reconciles differences.
  6. Remote Git Account (GitHub, GitLab, or personal Gitea): Hosts your public or private repository where the controller fetches manifests.

Step-by-Step Environment Provisioning

Step 1: Verify Core Tools

Open your terminal and confirm that the basic utilities are installed:

Bash

git --version
docker --version
kubectl version --client --output=yaml

Step 2: Create a Disposable Local Cluster

Using k3d, create a lightweight cluster named gitops-lab:

Bash

k3d cluster create gitops-lab --servers 1 --agents 1 --port "8080:80@loadbalancer"
Code language: CSS (css)

Expected output: The command downloads the K3s container image, establishes internal cluster networking, configures your local ~/.kube/config, and switches your current context to k3d-gitops-lab.

Verify cluster communication:

Bash

kubectl get nodes
Code language: JavaScript (javascript)

You should see two nodes (k3d-gitops-lab-server-0 and k3d-gitops-lab-agent-0) reporting a Ready status.

Step 3: Establish Safe Operational Boundaries

  • Never commit credentials: Do not place API keys, private certificates, or plain-text passwords into your Git repositories.
  • Keep the environment disposable: Treat this local cluster as temporary. If an exercise becomes misconfigured, delete the cluster (k3d cluster delete gitops-lab) and recreate it within sixty seconds.

Exercise One: Store an Application’s Desired State in Git

Goal: Structure a declarative Kubernetes deployment repository and store an application’s desired state under version control.

Prerequisites: Git installed, active account on GitHub/GitLab, terminal access.

In GitOps, repository structure matters. Mixing raw application source code (Java, Go, Python) with operational manifests (Kubernetes YAML) can lead to messy CI loops. A clean pattern for beginners is maintaining an environment/manifest repository dedicated solely to operational state.

gitops-manifests/
├── README.md
└── apps/
    └── web-app/
        ├── deployment.yaml
        └── service.yaml

Hands-on Steps

1. Initialize the Manifest Repository

Create a new directory on your machine, initialize Git, and build the directory skeleton:

Bash

mkdir -p gitops-manifests/apps/web-app
cd gitops-manifests
git init -b main

2. Define the Declarative Deployment Manifest

Inside apps/web-app/deployment.yaml, define a deployment using a lightweight, verifiable web server container:

YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-web-app
  namespace: default
  labels:
    app: sample-web-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sample-web-app
  template:
    metadata:
      labels:
        app: sample-web-app
    spec:
      containers:
      - name: web-server
        image: nginxinc/nginx-unprivileged:1.25-alpine
        ports:
        - containerPort: 8080
        resources:
          limits:
            memory: "128Mi"
            cpu: "250m"
          requests:
            memory: "64Mi"
            cpu: "100m"
Code language: JavaScript (javascript)

3. Define the Accompanying Service Manifest

Inside apps/web-app/service.yaml, declare how network traffic routes to the pods:

YAML

apiVersion: v1
kind: Service
metadata:
  name: sample-web-service
  namespace: default
spec:
  type: ClusterIP
  ports:
  - port: 80
    targetPort: 8080
    protocol: TCP
    name: http
  selector:
    app: sample-web-app
Code language: JavaScript (javascript)

4. Review and Commit the Configuration

Before staging, review your files for syntax errors or unintended entries:

Bash

git status
git diff

Stage the files and create your baseline commit:

Bash

git add apps/
git commit -m "feat: add initial manifests for sample-web-app"
Code language: JavaScript (javascript)

5. Push to a Remote Git Repository

Create an empty public repository named gitops-manifests on your Git hosting provider (e.g., GitHub), link the remote, and push:

Bash

# Replace <YOUR-USERNAME> with your actual account name
git remote add origin https://github.com/<YOUR-USERNAME>/gitops-manifests.git
git push -u origin main
Code language: PHP (php)

Verification Method

Visit your remote repository in a web browser. Confirm that:

  • The directory structure apps/web-app/ exists.
  • Both deployment.yaml and service.yaml are present with clean formatting.
  • The commit history shows your initial commit message.

Common Errors and Troubleshooting

  • Error: Git rejects push with Support for password authentication was removed.Fix: Configure an SSH key or create a fine-grained Personal Access Token (PAT) with repository read/write scopes.
  • Error: Indentation syntax errors in YAML.Fix: Ensure you use two spaces per indentation level. Never use tabs in Kubernetes manifests.

Extension Challenge

Add a namespace.yaml file declaring a custom namespace named web-apps, and update the metadata.namespace field in both deployment.yaml and service.yaml to use it.

Exercise Two: Make a Change Through a Pull Request

Goal: Modify an application’s desired state using a feature branch and pull request (PR) workflow, simulating team-based peer review.

Prerequisites: Completion of Exercise 1, remote repository pushed.

Directly pushing changes to your production main branch defeats the governance advantages of GitOps. The pull request creates an auditable review boundary where human operators and automated tools can inspect configuration changes before they affect running infrastructure.

+-------------------------------------------------------------+
|                    PULL REQUEST WORKFLOW                    |
+-------------------------------------------------------------+

 [main branch] ───(Branch)───> [feat/scale-replicas]
                                       │
                                (Edit manifests)
                                       │
                                (git commit & push)
                                       │
                                       ▼
                                [Pull Request Opened]
                                 ├── Automated CI Checks
                                 └── Peer Code Review
                                       │
                                   (Approved)
                                       ▼
 [main branch] <───(Merge)─────────────┘

Hands-on Steps

1. Create and Switch to a Feature Branch

To handle increased traffic, assume we need to scale our web application from two replicas to four:

Bash

git checkout -b feat/scale-replicas

2. Modify the Desired State

Open apps/web-app/deployment.yaml in your editor. Locate the replicas key and update it:

YAML

spec:
  replicas: 4

3. Audit Changes Locally

Inspect the unified diff to confirm that only the intended line changed:

Bash

git diff apps/web-app/deployment.yaml

Expected output:

Diff

@@ -7,3 +7,3 @@ spec:
-  replicas: 2
+  replicas: 4
   selector:
Code language: CSS (css)

4. Commit and Push the Branch

Bash

git add apps/web-app/deployment.yaml
git commit -m "chore(web-app): scale replicas from 2 to 4"
git push -u origin feat/scale-replicas
Code language: JavaScript (javascript)

5. Open and Review the Pull Request

  1. Navigate to your remote repository interface and open a new Pull Request comparing feat/scale-replicas against main.
  2. Title the PR: Scale sample-web-app replicas to 4.
  3. Use the following review checklist in the PR description:
    • [x] Target namespace verified
    • [x] Replica count aligns with resource quotas
    • [x] Pod resource limits remain unchanged
    • [x] No sensitive values exposed in manifests

6. Merge the Change

Approve and execute a standard merge into your main branch. Delete the feature branch on the remote host after merging.

Verification Method

Check out your local main branch, pull the latest changes, and confirm the log:

Bash

git checkout main
git pull origin main
git log -n 1 --oneline

The output should display your merge commit reflecting four declared replicas.

Common Errors and Troubleshooting

  • Error: Merge conflict when opening the PR.Fix: Your feature branch has diverged from main. Run git checkout feat/scale-replicas, rebase against main (git rebase main), resolve conflicting lines, and push with --force-with-lease.

Extension Challenge

Update the deployment.yaml on a new branch to add an environment variable (ENV: "staging") using a Kubernetes env block, open a PR, review the diff, and merge it.

Exercise Three: Connect a GitOps Controller

Goal: Install Argo CD into your local Kubernetes cluster and configure it to continuously synchronize your Git repository with the cluster state.

Prerequisites: Running local cluster from Section 2, public Git repository with manifests from Exercise 1.

The GitOps controller acts as the automation engine. It monitors your Git repository at regular intervals, evaluates the live objects in the cluster, and applies the necessary changes to eliminate differences.

+───────────────────────────+
|   Remote Git Repository   |
|   (Desired State: 4 Pods) |
+───────────────────────────+
              ▲
              │ Polling (Every 3m or Webhook)
              │
+─────────────────────────────────────────────────────────────+
| K3d Cluster                                                 |
|                                                             |
|  +───────────────────────────────────────────────────────+  |
|  | Argo CD Namespace (argocd)                            |  |
|  |                                                       |  |
|  | [Repo Server] <──> [Application Controller]           |  |
|  |                           │                           |  |
|  +───────────────────────────┼───────────────────────────+  |
|                              │                              |
|                              ▼ Compares & Reconciles        |
|  +───────────────────────────────────────────────────────+  |
|  | Default Namespace                                     |  |
|  | [Pod 1]  [Pod 2]  [Pod 3]  [Pod 4]                    |  |
|  +───────────────────────────────────────────────────────+  |
+─────────────────────────────────────────────────────────────+
Code language: HTML, XML (xml)

Hands-on Steps

1. Install Argo CD Components

Create the argocd system namespace and install the official Argo CD manifests:

Bash

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v2.10.4/manifests/install.yaml
Code language: JavaScript (javascript)

2. Monitor Controller Readiness

Wait for all controller pods to transition into a Running state:

Bash

kubectl wait --for=condition=available deployment/argocd-server -n argocd --timeout=180s
kubectl get pods -n argocd
Code language: JavaScript (javascript)

3. Expose the Argo CD Interface (Optional but Helpful)

You can port-forward the web dashboard to your local browser:

Bash

kubectl port-forward svc/argocd-server -n argocd 8081:443 &

Note: The default administrative username is admin. Retrieve the auto-generated initial password:

Bash

kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d; echo
Code language: PHP (php)

4. Declare an Argo CD Application Resource

In true GitOps fashion, we do not configure applications by clicking buttons in a UI. Instead, we declare the Argo CD application using a custom Kubernetes manifest.

Create a file named application.yaml on your workstation (outside the apps/ directory, or in a root directory):

YAML

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: sample-web-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/<YOUR-USERNAME>/gitops-manifests.git
    targetRevision: HEAD
    path: apps/web-app
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true
Code language: JavaScript (javascript)

Important configuration fields:

  • repoURL: Points to your remote Git repository.
  • path: Tells the controller which subdirectory contains the relevant manifests.
  • targetRevision: HEAD: Instructs the controller to track the latest commit on the default branch.
  • selfHeal: true: Enables automatic correction if someone alters the cluster manually.
  • prune: true: Tells the controller to delete cluster objects if their manifests are deleted from Git.

5. Apply the Application Manifest

Replace <YOUR-USERNAME> with your GitHub username, then apply the manifest:

Bash

kubectl apply -f application.yaml
Code language: CSS (css)

Verification Method

Inspect the live status using kubectl:

Bash

kubectl get applications.argoproj.io -n argocd
Code language: CSS (css)

The status should transition to Healthy and Synced.

Next, verify that the application pods exist in the default namespace:

Bash

kubectl get deployments
kubectl get pods -l app=sample-web-app
Code language: JavaScript (javascript)

You should see 4 running pods matching the desired state merged in Exercise 2.

Common Errors and Troubleshooting

  • Error: Application status shows ComparisonError with repository not found.Fix: Verify the repoURL spelling. If the repository is private, you must provide Git credentials to Argo CD via a Kubernetes secret.
  • Error: Pods remain in CrashLoopBackOff.Fix: Run kubectl describe pod -l app=sample-web-app and kubectl logs -l app=sample-web-app. Verify that your container port is non-privileged (e.g., 8080) when running unprivileged container images.

Extension Challenge

Change the targetRevision from HEAD to point to a specific release tag (e.g., v1.0.0). Tag your commit in Git, push it, and observe how the controller pins synchronization to that immutable tag.

Exercise Four: Observe and Fix Configuration Drift

Goal: Manually mutate cluster resources, observe how drift occurs, and watch the GitOps controller reconcile the cluster back to the declared state.

Prerequisites: Completion of Exercise 3, active Argo CD controller running with selfHeal configured.

Configuration drift occurs when an environment’s real runtime state diverges from its formal declaration. In traditional environments, engineers often make quick, unrecorded hotfixes using direct terminal access during emergencies. Over time, nobody knows what is running or why. GitOps systematically eliminates this problem.

+--------------------------------------------------------------+
|                CONFIGURATION DRIFT & HEALING                 |
+--------------------------------------------------------------+

 1. State in Git:               2. Unauthorized Cluster Drift:
    replicas: 4                    replicas: 1 (via manual kubectl)
         │                                   │
         │                                   ▼
         │                          [Cluster Pods Terminated]
         │                                   │
         │         3. GitOps Controller Detection:
         └───────────────> Divergence Detected!
                                 (Git != Live)
                                     │
                                     ▼
                   4. Automated Self-Healing Triggered:
                      Controller overwrites cluster state
                      to match Git.
                                     │
                                     ▼
                          [Replicas restored to 4]
Code language: PHP (php)

Hands-on Steps

1. Inspect the Baseline Cluster State

Confirm that your web application is healthy with 4 active replicas:

Bash

kubectl get deployment sample-web-app
Code language: JavaScript (javascript)

2. Introduce Manual Configuration Drift

Simulate an out-of-band manual intervention. Bypass Git entirely and directly alter the live deployment using kubectl:

Bash

kubectl scale deployment sample-web-app --replicas=1

3. Observe the Cluster Reaction Immediately

List the pods immediately after issuing the command:

Bash

kubectl get pods -l app=sample-web-app
Code language: JavaScript (javascript)

You will notice Kubernetes beginning to terminate three of the four pods.

4. Observe the Controller’s Reconciliation Loop

Because our application.yaml was configured with selfHeal: true, the Argo CD reconciliation loop detects that the live state (replicas: 1) does not match the desired state in Git (replicas: 4).

Within seconds to a few minutes (depending on polling cycles), inspect the pods again:

Bash

kubectl get pods -l app=sample-web-app
Code language: JavaScript (javascript)

Argo CD detects the drift, overwrites the manual scale-down command, and scales the deployment back up to 4 pods.

5. Safe Demonstration of Manual Drift Without Auto-Healing

To see what happens when automated healing is turned off:

  1. Temporarily disable self-heal:Bashkubectl patch application sample-web-app -n argocd --type merge -p '{"spec":{"syncPolicy":{"automated":{"selfHeal":false}}}}'
  2. Manually mutate a label or container image:Bashkubectl set image deployment/sample-web-app web-server=nginx:latest
  3. Check the application status in Argo CD:Bashkubectl get application sample-web-app -n argocd -o jsonpath='{.status.sync.status}' Expected output: OutOfSync. The controller highlights the difference without overwriting it, awaiting approval or manual synchronization.
  4. Restore automated healing:Bashkubectl patch application sample-web-app -n argocd --type merge -p '{"spec":{"syncPolicy":{"automated":{"selfHeal":true}}}}' Notice that the image immediately reverts back to nginxinc/nginx-unprivileged:1.25-alpine.

Verification Method

Review the Argo CD controller logs to observe the reconciliation event being logged:

Bash

kubectl logs -n argocd -l app.kubernetes.io/name=argocd-application-controller --tail=50 | grep "sample-web-app"
Code language: JavaScript (javascript)

Look for log entries indicating that synchronization was initiated due to cluster resource modification.

Common Errors and Troubleshooting

  • Symptom: The manual change persists and the controller does not reconcile.Fix: Confirm whether selfHeal is set to true under syncPolicy.automated. If omitted, Argo CD marks the app as OutOfSync but will not alter running pods automatically.

Extension Challenge

Simulate an accidental resource deletion by running kubectl delete service sample-web-service. Observe how quickly the controller recreates the deleted service manifest from Git.

Exercise Five: Add CI Validation Before Deployment

Goal: Implement a Continuous Integration (CI) pre-merge pipeline that validates manifest syntax and tests policies before changes can be merged into main.

Prerequisites: Remote GitHub repository with manifests, basic familiarity with GitHub Actions.

A GitOps controller blindly applies whatever valid YAML lands in the tracked branch. If someone merges a manifest with invalid indentation, missing mandatory fields, or dangerous security parameters, the controller will fail during sync or deploy an insecure configuration.

To prevent this, we introduce pre-commit and pre-merge CI validation.

+-------------------------------------------------------------+
|                     CI VALIDATION GATES                     |
+-------------------------------------------------------------+

 [Pull Request Submitted]
            │
            ▼
 ┌───────────────────────────────────────────────┐
 │ 1. YAML Syntax & Linting (yamllint)           │
 └──────────────────────┬────────────────────────┘
                        ▼
 ┌───────────────────────────────────────────────┐
 │ 2. Schema Validation (kubeconform)            │
 └──────────────────────┬────────────────────────┘
                        ▼
 ┌───────────────────────────────────────────────┐
 │ 3. Dry-Run Policy / Security Scan (kube-score)│
 └──────────────────────┬────────────────────────┘
                        ▼
               [All Checks Pass?]
                  │           │
             (No) │           │ (Yes)
                  ▼           ▼
             [Block PR]   [Allow Merge to Main]
                              │
                              ▼
                        [GitOps Sync]

The Boundary Between CI and GitOps CD

  • CI Responsibilities: Run tests, execute linters, validate schemas, ensure security compliance, and return pass/fail statuses on PRs. CI never needs cluster access.
  • GitOps CD Responsibilities: Read clean, validated declarations from merged branches and apply them to the cluster runtime.

Hands-on Steps

1. Define the Local Validation Workflow

Create a workflow directory in your Git repository:

Bash

mkdir -p .github/workflows

2. Create the GitHub Actions CI Pipeline

Create .github/workflows/validate-manifests.yaml:

YAML

name: Validate Kubernetes Manifests

on:
  pull_request:
    branches:
      - main
    paths:
      - 'apps/**'

jobs:
  lint-and-validate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python for YAML Linting
        uses: actions/setup-python@v5
        with:
          python-version: '3.x'

      - name: Run yamllint
        run: |
          pip install yamllint
          yamllint -d "{extends: relaxed, rules: {line-length: {max: 120}}}" apps/

      - name: Download Kubeconform for Kubernetes Schema Validation
        run: |
          curl -sL https://github.com/yannh/kubeconform/releases/download/v0.6.4/kubeconform-linux-amd64.tar.gz | tar xz
          sudo mv kubeconform /usr/local/bin/

      - name: Validate Manifests Against Kubernetes OpenAPI Schemas
        run: |
          kubeconform -strict -summary -kubernetes-version 1.28.0 apps/
Code language: JavaScript (javascript)

3. Test the Validation Mechanism Locally

You can test the same commands on your local workstation before committing:

Bash

# Example if yamllint is installed locally:
yamllint -d "{extends: relaxed, rules: {line-length: {max: 120}}}" apps/
Code language: PHP (php)

4. Intentionally Trigger a Pipeline Failure

  1. Create a test branch:Bashgit checkout -b test/invalid-manifest
  2. Introduce an intentional schema error into apps/web-app/deployment.yaml. For example, change replicas: 4 to a string value:YAMLreplicas: "four"
  3. Commit and push:Bashgit add apps/web-app/deployment.yaml git commit -m "test: invalid replica data type" git push -u origin test/invalid-manifest
  4. Open a Pull Request targeting main.

Verification Method

Watch the GitHub Actions tab in your repository. The pipeline will fail on the Validate Manifests Against Kubernetes OpenAPI Schemas step with an error message indicating that spec.replicas expects an integer, not a string.

Because the CI check fails, branch protection rules can block the PR from being merged, protecting your cluster from malformed configurations.

Bash

# Clean up your test branch
git checkout main
git branch -D test/invalid-manifest
Code language: PHP (php)

Common Errors and Troubleshooting

  • Error: kubeconform reports could not find schema for custom resource.Fix: By default, schema validators validate native Kubernetes types (Deployments, Services). For Custom Resource Definitions (CRDs) like Argo CD Application manifests, tell kubeconform to ignore unknown CRDs using the -ignore-missing-schemas flag.

Extension Challenge

Add a security scanning step to the workflow using trivy config apps/ to alert developers if a container runs as root or lacks memory limits.

Exercise Six: Promote a Change Across Environments

Goal: Structure multi-environment configurations using Kustomize overlays, promoting changes from development to staging without duplicating manifest files.

Prerequisites: Completion of earlier exercises, local k3d cluster running.

In real-world engineering, you rarely deploy directly to production. A change must progress through development, staging, and production. A frequent beginner mistake is copying and pasting YAML across directories (dev/, staging/, prod/). This produces configuration drift between environments when someone updates a setting in one folder and forgets the others.

The recommended GitOps solution is using a configuration tool like Kustomize (built directly into kubectl) to create a shared base with environment-specific overlays.

apps/web-app/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── kustomization.yaml
└── overlays/
    ├── dev/
    │   ├── kustomization.yaml
    │   └── replica_patch.yaml
    └── staging/
        ├── kustomization.yaml
        └── replica_patch.yaml

Hands-on Steps

1. Reorganize Your Manifests into Base and Overlays

Restructure your local repository:

Bash

cd gitops-manifests
mkdir -p apps/web-app/base
mkdir -p apps/web-app/overlays/dev
mkdir -p apps/web-app/overlays/staging

# Move original manifests into base
mv apps/web-app/deployment.yaml apps/web-app/base/
mv apps/web-app/service.yaml apps/web-app/base/
Code language: PHP (php)

2. Create the Base Kustomization

Create apps/web-app/base/kustomization.yaml:

YAML

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
Code language: HTTP (http)

3. Define the Development Overlay

In development, we want a lightweight single pod with a dev- name prefix.

Create apps/web-app/overlays/dev/kustomization.yaml:

YAML

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: dev-environment
namePrefix: dev-

resources:
  - ../../base

patches:
  - path: replica_patch.yaml
Code language: HTTP (http)

Create apps/web-app/overlays/dev/replica_patch.yaml:

YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-web-app
spec:
  replicas: 1

4. Define the Staging Overlay

In staging, we want two replicas and higher resource allocations.

Create apps/web-app/overlays/staging/kustomization.yaml:

YAML

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: staging-environment
namePrefix: staging-

resources:
  - ../../base

patches:
  - path: replica_patch.yaml
Code language: HTTP (http)

Create apps/web-app/overlays/staging/replica_patch.yaml:

YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-web-app
spec:
  replicas: 2

5. Verify Overlays Locally

Use kubectl kustomize to render the manifests locally without applying them:

Bash

# Test the Dev configuration rendering:
kubectl kustomize apps/web-app/overlays/dev

# Test the Staging configuration rendering:
kubectl kustomize apps/web-app/overlays/staging
Code language: PHP (php)

Notice how both environments inherit the common container specifications from base, while customizing namespaces, name prefixes, and replica counts without duplicating boilerplate code.

6. Commit and Push Multi-Environment Architecture

Bash

git add apps/web-app/
git commit -m "refactor(web-app): convert architecture to Kustomize base and overlays"
git push origin main
Code language: JavaScript (javascript)

7. Update Argo CD Applications

You can now create separate Argo CD applications pointing to apps/web-app/overlays/dev and apps/web-app/overlays/staging. Promoting an image or configuration change simply means updating the version tag in base/ or altering an overlay patch via a reviewed Pull Request.

Verification Method

Inspect the live namespaces to confirm clean separation:

Bash

kubectl create namespace dev-environment
kubectl create namespace staging-environment

kubectl apply -k apps/web-app/overlays/dev
kubectl apply -k apps/web-app/overlays/staging

kubectl get pods -n dev-environment
kubectl get pods -n staging-environment
Code language: JavaScript (javascript)

You should see one pod prefixed with dev- in dev-environment, and two pods prefixed with staging- in staging-environment.

Common Errors and Troubleshooting

  • Error: kustomize fails with accumulating resources: ... no such file or directory.Fix: Check relative file paths inside kustomization.yaml. Ensure that ../../base correctly navigates up two directory levels to reach the base folder.

Extension Challenge

Add a common label (environment: dev and environment: staging) automatically across all generated resources using Kustomize’s commonLabels directive.

Exercise Seven: Roll Back a Failed Change

Goal: Diagnose a bad release, execute a clean rollback using native Git operations, and verify automated cluster recovery.

Prerequisites: Completion of Exercise 3, working GitOps deployment.

In traditional environments, a failed deployment often causes panic. Engineers scramble to run imperative commands like helm rollback or kubectl rollout undo. In a strict GitOps model, running kubectl rollout undo directly against the cluster introduces immediate configuration drift—the cluster would run the old version while Git still declared the broken one. The controller would promptly re-deploy the broken version!

In GitOps, every rollback is simply a forward Git commit that reverts the repository to a known good state.

+--------------------------------------------------------------+
|                   THE GITOPS ROLLBACK FLOW                   |
+--------------------------------------------------------------+

 Commit 1: (Good State)  ──> Image: nginx:1.25  [Cluster Healthy]
          │
 Commit 2: (Bad Release) ──> Image: broken:9.9  [CrashLoopBackOff]
          │
          │ DO NOT run "kubectl rollout undo"!
          │ Run "git revert HEAD"
          ▼
 Commit 3: (Revert Commit) ─> Image: nginx:1.25 [Cluster Restored]
Code language: PHP (php)

Hands-on Steps

1. Intentionally Introduce a Broken Release

Create a feature branch and point your container image to a non-existent tag:

Bash

git checkout -b fix/image-update

Edit your deployment manifest (or your base manifest) and update the image:

YAML

containers:
- name: web-server
  image: nginxinc/nginx-unprivileged:non-existent-tag-999

2. Commit, Push, and Merge

Bash

git add apps/
git commit -m "feat(web-app): update web server to non-existent tag"
git push origin fix/image-update
Code language: JavaScript (javascript)

Merge this branch into main via PR or direct merge for this lab exercise:

Bash

git checkout main
git merge fix/image-update
git push origin main

3. Observe the Failure in the Cluster

Wait for Argo CD to pick up the change, or trigger a refresh:

Bash

kubectl get pods -l app=sample-web-app
Code language: JavaScript (javascript)

You will observe pods stuck in ImagePullBackOff or ErrImagePull:

NAME                              READY   STATUS             RESTARTS   AGE
sample-web-app-798889989-abcde    0/1     ImagePullBackOff   0          45s

4. Execute the Rollback Using Git

Rather than touching kubectl, identify the commit that introduced the breaking change using the Git log:

Bash

git log --oneline -n 3

Create a revert commit that undoes the changes introduced by the broken commit:

Bash

git revert HEAD --no-edit

This generates a new commit that restores the exact file state prior to the broken release.

5. Push the Revert Commit to the Repository

Bash

git push origin main

Verification Method

Watch your cluster as Argo CD pulls the new revert commit:

Bash

kubectl get pods -l app=sample-web-app -w
Code language: JavaScript (javascript)

Watch the old, broken pods terminate while new pods running the stable, previously working image (1.25-alpine) start cleanly and reach Running status.

Check the deployment history:

Bash

kubectl describe deployment sample-web-app | grep Image:

The image has safely returned to the working tag. The Git history records the failure, the revert, and the author who executed the rollback, maintaining a transparent audit trail.

Common Errors and Troubleshooting

  • Symptom: The cluster continues trying to pull the broken image after pushing the revert commit.Fix: By default, Argo CD polls Git every three minutes. You can trigger an instant sync by running argocd app sync sample-web-app or pressing the “Sync” button in the Argo CD web console.

Extension Challenge

Simulate an out-of-memory crash by setting container memory limits to an impossibly low value (1Mi). Commit the change, observe the OOMKilled status in Kubernetes, and use git revert to recover the service.

Common GitOps Learning Mistakes

When adopting GitOps, beginners often encounter several anti-patterns. Understanding these early prevents architectural debt and operational headaches.

+───────────────────────────────────+───────────────────────────────────+
| Common Anti-Pattern               | Recommended GitOps Solution       |
+───────────────────────────────────+───────────────────────────────────+
| Storing plain-text secrets in Git | Use external secret operators or  |
|                                   | sealed secrets with encryption    |
+───────────────────────────────────+───────────────────────────────────+
| Treating CI as the deployment     | Restrict CI to validation/build;  |
| agent with direct cluster access  | let in-cluster controllers deploy |
+───────────────────────────────────+───────────────────────────────────+
| Bypassing Git during incidents    | Implement audited break-glass     |
| with manual kubectl mutations     | workflows with auto-healing       |
+───────────────────────────────────+───────────────────────────────────+
| Monolithic repo mixing app source | Separate application source code  |
| code with environment manifests   | from deployment manifest repos    |
+───────────────────────────────────+───────────────────────────────────+
Code language: JavaScript (javascript)

1. Committing Plain-Text Secrets to Git

  • The Mistake: Storing API tokens, database passwords, or private keys directly in Git repositories because “it needs to be declarative.”
  • Why it’s dangerous: Git histories are permanent. Even private repositories can be cloned, leaked, or exposed.
  • The Solution: Keep plain-text secrets out of Git. Use solutions like External Secrets Operator (which pulls secrets from managed vaults at runtime) or Sealed Secrets (which encrypts secrets using asymmetric public-key cryptography so they can be stored safely in public Git repositories).

2. Granting Excessive Controller Permissions

  • The Mistake: Giving the GitOps controller unrestricted cluster-admin privileges across all namespaces.
  • Why it’s dangerous: If a misconfigured or malicious manifest is merged into Git, the controller can overwrite critical cluster-wide resources, including RBAC rules and network policies.
  • The Solution: Scope the controller’s ServiceAccount permissions using Kubernetes Role-Based Access Control (RBAC). Restrict the controller to managing only the specific namespaces dedicated to application workloads.

3. Confusing CI with Reconciliation

  • The Mistake: Triggering a webhook that runs kubectl apply from inside a Jenkins, GitLab CI, or GitHub Actions runner.
  • Why it’s dangerous: This is traditional “push-based” deployment, not GitOps. It requires storing sensitive cluster credentials inside your CI tool and fails to detect or correct configuration drift if someone modifies the cluster out of band.
  • The Solution: Let CI build artifacts and validate YAML. Let the in-cluster GitOps controller pull declarations and manage reconciliation.

4. Bypassing Review During Emergencies

  • The Mistake: During an outage, operators run direct imperative commands (kubectl edit, kubectl delete) to fix an issue quickly, intending to “update Git later.”
  • Why it’s dangerous: The changes are almost never backported to Git. The next automated reconciliation run will overwrite the emergency fix, causing the outage to reoccur.
  • The Solution: If you must use a break-glass emergency procedure, do it through a dedicated emergency PR with expedited review, or document the exact commit required immediately following cluster intervention.

5. Failing to Verify the Deployed State

  • The Mistake: Assuming that because git commit and git push succeeded, the application is running successfully.
  • Why it’s dangerous: Git only knows that text was saved. It does not know if your pod crashed, failed a readiness probe, or ran out of disk space.
  • The Solution: Implement automated health checks, configure alerts for unsynced or degraded applications in your GitOps controller, and verify pod statuses after rollouts.

11. Practice Project and Self-Assessment

Now that you have worked through each individual phase of the GitOps lifecycle, test your understanding by completing an end-to-end integration project independently.

+--------------------------------------------------------------+
|              PRACTICE PROJECT INTEGRATION MAP                |
+--------------------------------------------------------------+

 [Microservice Manifests] 
           │
           ▼
 [GitHub Actions CI Gate] ──(Fails?)──> [Halt & Notify]
           │ (Passes)
           ▼
 [Pull Request Approved & Merged]
           │
           ▼
 [Argo CD Reconciliation] ──> [Deploys to "production" Namespace]
           │
           ├── Test 1: Manual Drift Injection ──> [Auto-Healed?]
           └── Test 2: Bad Config Injection   ──> [Revert via Git]
Code language: PHP (php)

The Challenge Scenario

You are tasked with deploying a microservice named order-service across your local cluster. Your implementation must satisfy the following operational requirements:

  1. Repository Layout: Create a standalone manifest repository configured with a Kustomize structure (base/ and an overlay for production/).
  2. Automated CI Gate: Configure a GitHub Actions workflow that automatically runs yamllint and kubeconform on every pull request targeting main.
  3. Branch Protection: Open a PR to introduce the order-service manifests. Require the CI workflow to pass before merging.
  4. Declarative GitOps Delivery: Declare an Argo CD Application that monitors the production/ overlay and deploys into a custom namespace named production.
  5. Drift Resilience: Manually edit the live production deployment to change its environment variables. Confirm that Argo CD detects the divergence and automatically overwrites it within three minutes.
  6. Simulated Incident and Rollback: Push an invalid container port or broken configuration commit to main. Observe the service enter a degraded state. Execute a clean rollback using git revert, push to remote, and observe the application return to a healthy state without using kubectl.

Completion Checklist

Use this checklist to evaluate your implementation:

 Manifests are organized into reusable base/ and overlay/ directories.
 No plain-text passwords or secret keys exist in the Git commit history.
 CI workflow executes successfully on Pull Requests, blocking malformed YAML.
 Argo CD Application is declared using a version-controlled YAML manifest.
 Application status in Argo CD reports "Healthy" and "Synced".
 Manual cluster mutations trigger an automated self-healing reconciliation.
 Rollbacks are executed exclusively through Git commits (git revert).
Code language: PHP (php)

Reflection Questions

  1. Why does the pull-based model of a GitOps controller improve security compared to a push-based CI pipeline?
  2. If an application requires dynamic database credentials that rotate every 24 hours, how should this be handled within a GitOps workflow without violating the declarative principle?
  3. What are the operational tradeoffs between enabling automatic self-healing (selfHeal: true) versus leaving it in manual notification mode?

Frequently Asked Questions (FAQs)

What is GitOps in a CI/CD workflow?

In a modern CI/CD workflow, GitOps is an operating model where Git serves as the single source of truth for declarative infrastructure and application configurations. While Continuous Integration (CI) continues to test code, build container images, and validate manifests, the Continuous Delivery (CD) aspect is handled by an automated controller running inside the target environment. The controller continuously pulls state declarations from Git and reconciles differences, replacing push-based deployment scripts with continuous reconciliation.

How is GitOps different from traditional deployment pipelines?

Traditional pipelines use a push-based model: an external CI/CD server (such as Jenkins or GitHub Actions) holds administrative access credentials to the target cluster and runs deployment commands (kubectl apply, helm install) outward. GitOps uses an in-cluster pull model: an internal agent monitors the repository and synchronizes state inward. This eliminates the need to expose cluster API credentials to external systems, ensures continuous drift detection, and keeps Git as an exact mirror of the running environment.

Do students need a cloud account to practice GitOps?

No. You do not need accounts with AWS, Azure, or Google Cloud to master GitOps concepts. Lightweight local Kubernetes distributions like k3d, minikube, or kind can run directly on a personal laptop inside Docker or Podman. These tools provide fully functional Kubernetes environments that support controllers like Argo CD and Flux, allowing you to practice realistic production workflows locally without infrastructure costs.

What is the role of a GitOps controller?

A GitOps controller (such as Argo CD or Flux) is a cluster-side software agent responsible for the continuous reconciliation loop. It performs three primary duties:

  1. Observing: Periodically reading declared manifests from a designated Git repository.
  2. Comparing: Inspecting the live state of cluster resources and identifying divergence or drift.
  3. Synchronizing: Automatically applying changes to bring the live cluster state into alignment with the repository.

How does GitOps help detect configuration drift?

Because the GitOps controller continuously evaluates both the desired state in Git and the actual runtime state in the cluster, any manual, unrecorded modification (such as someone running kubectl scale or kubectl edit directly on the cluster) creates immediate divergence. The controller flags this divergence as an OutOfSync state. If configured with automated self-healing, it will immediately overwrite the manual modification, restoring the cluster to the version declared in Git.

How can a team roll back a GitOps deployment?

In GitOps, you roll back by changing the repository, not by running imperative rollback commands against the cluster. To revert a failed deployment, an engineer runs git revert <commit-hash> locally and pushes the revert commit to the tracked branch (ideally via a quick pull request). The GitOps controller detects the updated commit, compares it to the running cluster, and gracefully transitions the workloads back to the previous, working state while preserving a transparent, auditable history of the change.

Conclusion

GitOps replaces error-prone imperative scripts and unrecorded cluster tweaks with a disciplined, auditable workflow built on declarative manifests, automated CI validation, and continuous reconciliation. As demonstrated across these exercises, the true power of GitOps lies in enforcing that every operational action—from a routine replica scale-up to an urgent production rollback—travels strictly through version-controlled commits. Continuing to experiment with these hands-on patterns, whether in your own local sandbox or through structured training platforms like DevOpsSchool, is the fastest way to build the practical confidence and muscle memory required to manage production Kubernetes environments safely and reliably.

0 0 votes
Article Rating
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x