Build a complete GitOps pipeline on your laptop from writing your first Deployment to watching pods autoscale under load.


We work on AWS infrastructure day-to-day, and wanted a structured, hands-on reference for Kubernetes and GitOps outside of client work. This post is the result a self-contained learning project we built, written up as the tutorial we wish existed when we started. Everything here runs locally; no cloud bill required.

What We’re Building

By the end of this guide, you’ll have:

  • A Go microservice running in Kubernetes (minikube)

  • ArgoCD deploying your app via GitOps (App of Apps pattern)

  • GitHub Actions CI that builds, pushes, and triggers deployments automatically

  • HPA scaling pods up/down based on CPU load

  • A conceptual understanding of Karpenter for node-level autoscaling on AWS

No AWS account needed for steps 1–6. Everything runs locally on minikube.

Architecture


Two repos, two concerns:

  • learning-app-src: your application code, Dockerfile, CI pipeline

  • learning-app-gitops: Kubernetes manifests as Helm charts, ArgoCD Application definitions

Why two repos? If manifests lived alongside source code, every CI commit (updating the image tag) would trigger another CI run, creating an infinite loop. Separating them cleanly splits “build” from “deploy.”

Prerequisites

Tool & Purpose

Docker | Container runtime + image building kubectl | Kubernetes CLI minikube | Local K8s cluster Helm | Package manager (installs ArgoCD, templates charts) Git + GitHub account | Version control + CI/CD Docker Hub account | Public image registry
Docker | Container runtime + image building kubectl | Kubernetes CLI minikube | Local K8s cluster Helm | Package manager (installs ArgoCD, templates charts) Git + GitHub account | Version control + CI/CD Docker Hub account | Public image registry
Docker | Container runtime + image building kubectl | Kubernetes CLI minikube | Local K8s cluster Helm | Package manager (installs ArgoCD, templates charts) Git + GitHub account | Version control + CI/CD Docker Hub account | Public image registry
Docker | Container runtime + image building kubectl | Kubernetes CLI minikube | Local K8s cluster Helm | Package manager (installs ArgoCD, templates charts) Git + GitHub account | Version control + CI/CD Docker Hub account | Public image registry

Why minikube over kind or k3d?

  • Built-in addons: `minikube addons enable metrics-server` / `ingress`, one command, no manual debugging

  • Convenient extras: `minikube tunnel` for LoadBalancer services, built-in dashboard for visual learning

  • Single-binary install with sensible defaults, less time fighting the tool, more time learning Kubernetes

Step 1: Start Your Cluster

minikube startkubectl config current-context # Should show: minikubekubectl cluster-info # Verify connectivity
minikube startkubectl config current-context # Should show: minikubekubectl cluster-info # Verify connectivity
minikube startkubectl config current-context # Should show: minikubekubectl cluster-info # Verify connectivity
minikube startkubectl config current-context # Should show: minikubekubectl cluster-info # Verify connectivity

Always verify your context before applying anything. In production you might have 5 clusters configured one wrong `kubectl apply` and you’ve deployed dev code to prod.

Step 2: The Microservice

A Go HTTP service with four endpoints:

Endpoint &Purpose

GET / Pod info (hostname, version, Redis status)

GET /healthz Liveness/readiness: checks Redis connectivity

GET /metrics Prometheus format for monitoring

GET /load?duration=10s Burns CPU: triggers HPA scaling |

GET /ui/ Web dashboard with load generator button

Why Go? The final Docker image is 12MB (scratch base + static binary). Fast startup means HPA can react quickly, so you’re not waiting 30 seconds for Python containers to boot during scale-up.

Dockerfile (multi-stage)

FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod ./COPY main.go .COPY static/ ./static/RUN go mod tidyRUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .FROM scratchCOPY --from=builder /server /serverEXPOSE 8080ENTRYPOINT ["/server"
FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod ./COPY main.go .COPY static/ ./static/RUN go mod tidyRUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .FROM scratchCOPY --from=builder /server /serverEXPOSE 8080ENTRYPOINT ["/server"
FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod ./COPY main.go .COPY static/ ./static/RUN go mod tidyRUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .FROM scratchCOPY --from=builder /server /serverEXPOSE 8080ENTRYPOINT ["/server"
FROM golang:1.22-alpine AS builderWORKDIR /appCOPY go.mod ./COPY main.go .COPY static/ ./static/RUN go mod tidyRUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .FROM scratchCOPY --from=builder /server /serverEXPOSE 8080ENTRYPOINT ["/server"

Build and load into minikube:

docker build -t learning-app:0.1.0 .minikube image load learning-app:0.1.0
docker build -t learning-app:0.1.0 .minikube image load learning-app:0.1.0
docker build -t learning-app:0.1.0 .minikube image load learning-app:0.1.0
docker build -t learning-app:0.1.0 .minikube image load learning-app:0.1.0

Step 3: Kubernetes Manifests (By Hand First)

Before ArgoCD automates anything, deploy manually so you understand each resource.

Namespace

apiVersion: v1kind: Namespacemetadata:  name: learning-app
apiVersion: v1kind: Namespacemetadata:  name: learning-app
apiVersion: v1kind: Namespacemetadata:  name: learning-app
apiVersion: v1kind: Namespacemetadata:  name: learning-app

A logical boundary, all resources live together, easy to nuke with `kubectl delete ns learning-app`.

ConfigMap

apiVersion: v1kind: ConfigMapmetadata:  name: learning-app-config  namespace: learning-appdata:  REDIS_ADDR: "redis:6379"
apiVersion: v1kind: ConfigMapmetadata:  name: learning-app-config  namespace: learning-appdata:  REDIS_ADDR: "redis:6379"
apiVersion: v1kind: ConfigMapmetadata:  name: learning-app-config  namespace: learning-appdata:  REDIS_ADDR: "redis:6379"
apiVersion: v1kind: ConfigMapmetadata:  name: learning-app-config  namespace: learning-appdata:  REDIS_ADDR: "redis:6379"

Externalizes config from the image. Change Redis address without rebuilding or editing the Deployment.

Deployment (key concepts)

apiVersion: apps/v1kind: Deploymentmetadata:  name: learning-app  namespace: learning-appspec:  replicas: 2  selector:    matchLabels:      app: learning-app  template:    metadata:      labels:        app: learning-app    spec:      containers:        - name: learning-app          image: learning-app:0.1.0          imagePullPolicy: Never          ports:            - containerPort: 8080          envFrom:            - configMapRef:                name: learning-app-config          resources:            requests:              cpu: 100m              memory: 64Mi            limits:              cpu: 500m              memory: 128Mi          livenessProbe:            httpGet:              path: /healthz              port: 8080          readinessProbe:            httpGet:              path: /healthz              port: 8080
apiVersion: apps/v1kind: Deploymentmetadata:  name: learning-app  namespace: learning-appspec:  replicas: 2  selector:    matchLabels:      app: learning-app  template:    metadata:      labels:        app: learning-app    spec:      containers:        - name: learning-app          image: learning-app:0.1.0          imagePullPolicy: Never          ports:            - containerPort: 8080          envFrom:            - configMapRef:                name: learning-app-config          resources:            requests:              cpu: 100m              memory: 64Mi            limits:              cpu: 500m              memory: 128Mi          livenessProbe:            httpGet:              path: /healthz              port: 8080          readinessProbe:            httpGet:              path: /healthz              port: 8080
apiVersion: apps/v1kind: Deploymentmetadata:  name: learning-app  namespace: learning-appspec:  replicas: 2  selector:    matchLabels:      app: learning-app  template:    metadata:      labels:        app: learning-app    spec:      containers:        - name: learning-app          image: learning-app:0.1.0          imagePullPolicy: Never          ports:            - containerPort: 8080          envFrom:            - configMapRef:                name: learning-app-config          resources:            requests:              cpu: 100m              memory: 64Mi            limits:              cpu: 500m              memory: 128Mi          livenessProbe:            httpGet:              path: /healthz              port: 8080          readinessProbe:            httpGet:              path: /healthz              port: 8080
apiVersion: apps/v1kind: Deploymentmetadata:  name: learning-app  namespace: learning-appspec:  replicas: 2  selector:    matchLabels:      app: learning-app  template:    metadata:      labels:        app: learning-app    spec:      containers:        - name: learning-app          image: learning-app:0.1.0          imagePullPolicy: Never          ports:            - containerPort: 8080          envFrom:            - configMapRef:                name: learning-app-config          resources:            requests:              cpu: 100m              memory: 64Mi            limits:              cpu: 500m              memory: 128Mi          livenessProbe:            httpGet:              path: /healthz              port: 8080          readinessProbe:            httpGet:              path: /healthz              port: 8080

Critical fields explained:

  • resources.requests: what the scheduler guarantees your pod. HPA uses this as the baseline: `utilization = actual / requests × 100%`

  • resources.limits: the ceiling. CPU gets throttled; memory gets OOM-killed.

  • livenessProbe: “Is this container stuck?” → Kubernetes restarts it

  • readinessProbe: “Can it serve traffic?” → removed from Service endpoints if failing

  • imagePullPolicy: Never: for locally-loaded images in minikube

Service

apiVersion: v1kind: Servicemetadata:  name: learning-app  namespace: learning-appspec:  selector:    app: learning-app  ports:    - port: 80      targetPort: 8080
apiVersion: v1kind: Servicemetadata:  name: learning-app  namespace: learning-appspec:  selector:    app: learning-app  ports:    - port: 80      targetPort: 8080
apiVersion: v1kind: Servicemetadata:  name: learning-app  namespace: learning-appspec:  selector:    app: learning-app  ports:    - port: 80      targetPort: 8080
apiVersion: v1kind: Servicemetadata:  name: learning-app  namespace: learning-appspec:  selector:    app: learning-app  ports:    - port: 80      targetPort: 8080

Creates a stable DNS name (learning-app.learning-app.svc.cluster.local) and load-balances across pods. The selector must match your pod labels that’s how Services find their backends.

apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  name: learning-app  namespace: learning-appspec:  ingressClassName: nginx  rules:    - http:        paths:          - path: /            pathType: Prefix            backend:              service:                name: learning-app                port:                  number: 80
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  name: learning-app  namespace: learning-appspec:  ingressClassName: nginx  rules:    - http:        paths:          - path: /            pathType: Prefix            backend:              service:                name: learning-app                port:                  number: 80
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  name: learning-app  namespace: learning-appspec:  ingressClassName: nginx  rules:    - http:        paths:          - path: /            pathType: Prefix            backend:              service:                name: learning-app                port:                  number: 80
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  name: learning-app  namespace: learning-appspec:  ingressClassName: nginx  rules:    - http:        paths:          - path: /            pathType: Prefix            backend:              service:                name: learning-app                port:                  number: 80

L7 HTTP routing. Requires an Ingress Controller (nginx) running in the cluster:

minikube addons enable ingress
minikube addons enable ingress
minikube addons enable ingress
minikube addons enable ingress

Deploy everything:

kubectl apply -f k8s/namespace.yamlkubectl apply -f k8s/configmap.yamlkubectl apply -f k8s/redis.yamlkubectl apply -f k8s/deployment.yamlkubectl apply -f k8s/service.yamlkubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/namespace.yamlkubectl apply -f k8s/configmap.yamlkubectl apply -f k8s/redis.yamlkubectl apply -f k8s/deployment.yamlkubectl apply -f k8s/service.yamlkubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/namespace.yamlkubectl apply -f k8s/configmap.yamlkubectl apply -f k8s/redis.yamlkubectl apply -f k8s/deployment.yamlkubectl apply -f k8s/service.yamlkubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/namespace.yamlkubectl apply -f k8s/configmap.yamlkubectl apply -f k8s/redis.yamlkubectl apply -f k8s/deployment.yamlkubectl apply -f k8s/service.yamlkubectl apply -f k8s/ingress.yaml

Step 4: GitOps with ArgoCD

Now that you understand what each manifest does, let’s automate deployment with ArgoCD.

What is GitOps?

Git is the single source of truth. You declare desired state in a repo, a controller (ArgoCD) continuously makes the cluster match.

  • Change a manifest in Git → ArgoCD applies it

  • Someone does kubectl edit manually → ArgoCD reverts it (self-healing)

  • Delete a resource from Git → ArgoCD deletes it from the cluster (pruning)

Install ArgoCD

kubectl create namespace argocdhelm repo add argo https://argoproj.github.io/argo-helmhelm repo updatehelm install argocd argo/argo-cd --namespace argocd --wait
kubectl create namespace argocdhelm repo add argo https://argoproj.github.io/argo-helmhelm repo updatehelm install argocd argo/argo-cd --namespace argocd --wait
kubectl create namespace argocdhelm repo add argo https://argoproj.github.io/argo-helmhelm repo updatehelm install argocd argo/argo-cd --namespace argocd --wait
kubectl create namespace argocdhelm repo add argo https://argoproj.github.io/argo-helmhelm repo updatehelm install argocd argo/argo-cd --namespace argocd --wait

Gitops Repo Structure

learning-app-gitops/├── root-app.yaml # Apply ONCE - manages everything below├── apps/ └── learning-app.yaml # ArgoCD Application → points to chart└── charts/└── learning-app/├── Chart.yaml├── values.yaml # CI updates image.tag here└── templates/ # Your K8s manifests as Helm templates
learning-app-gitops/├── root-app.yaml # Apply ONCE - manages everything below├── apps/ └── learning-app.yaml # ArgoCD Application → points to chart└── charts/└── learning-app/├── Chart.yaml├── values.yaml # CI updates image.tag here└── templates/ # Your K8s manifests as Helm templates
learning-app-gitops/├── root-app.yaml # Apply ONCE - manages everything below├── apps/ └── learning-app.yaml # ArgoCD Application → points to chart└── charts/└── learning-app/├── Chart.yaml├── values.yaml # CI updates image.tag here└── templates/ # Your K8s manifests as Helm templates
learning-app-gitops/├── root-app.yaml # Apply ONCE - manages everything below├── apps/ └── learning-app.yaml # ArgoCD Application → points to chart└── charts/└── learning-app/├── Chart.yaml├── values.yaml # CI updates image.tag here└── templates/ # Your K8s manifests as Helm templates

App of Apps Pattern

Instead of manually applying each Application, one root Application watches a directory:

 root-app.yaml  the ONLY kubectl apply you ever do !!!apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: root-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: apps  destination:    server: https://kubernetes.default.svc    namespace: argocd  syncPolicy:    automated:      prune: true      selfHeal: true
 root-app.yaml  the ONLY kubectl apply you ever do !!!apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: root-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: apps  destination:    server: https://kubernetes.default.svc    namespace: argocd  syncPolicy:    automated:      prune: true      selfHeal: true
 root-app.yaml  the ONLY kubectl apply you ever do !!!apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: root-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: apps  destination:    server: https://kubernetes.default.svc    namespace: argocd  syncPolicy:    automated:      prune: true      selfHeal: true
 root-app.yaml  the ONLY kubectl apply you ever do !!!apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: root-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: apps  destination:    server: https://kubernetes.default.svc    namespace: argocd  syncPolicy:    automated:      prune: true      selfHeal: true

Add a new microservice? Drop a YAML in `apps/`. Root app picks it up automatically.

The Application resource

apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: learning-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: charts/learning-app    helm:      valueFiles:        - values.yaml  destination:    server: https://kubernetes.default.svc    namespace: learning-app  syncPolicy:    automated:      prune: true      selfHeal: true    syncOptions:      - CreateNamespace=true
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: learning-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: charts/learning-app    helm:      valueFiles:        - values.yaml  destination:    server: https://kubernetes.default.svc    namespace: learning-app  syncPolicy:    automated:      prune: true      selfHeal: true    syncOptions:      - CreateNamespace=true
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: learning-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: charts/learning-app    helm:      valueFiles:        - values.yaml  destination:    server: https://kubernetes.default.svc    namespace: learning-app  syncPolicy:    automated:      prune: true      selfHeal: true    syncOptions:      - CreateNamespace=true
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata:  name: learning-app  namespace: argocdspec:  project: default  source:    repoURL: https://github.com/mahirberkano/learning-app-gitops.git    targetRevision: main    path: charts/learning-app    helm:      valueFiles:        - values.yaml  destination:    server: https://kubernetes.default.svc    namespace: learning-app  syncPolicy:    automated:      prune: true      selfHeal: true    syncOptions:      - CreateNamespace=true

Apply the root app once, then delete your hand-deployed resources; ArgoCD recreates them from Git:

kubectl delete ns learning-appkubectl apply -f root-app.yaml
kubectl delete ns learning-appkubectl apply -f root-app.yaml
kubectl delete ns learning-appkubectl apply -f root-app.yaml
kubectl delete ns learning-appkubectl apply -f root-app.yaml

Make sure `apps/learning-app.yaml` is already committed to the gitops repo before applying `root-app.yaml`. Otherwise the root app will sync successfully but find nothing to deploy.

Accessing the ArgoCD UI

 Get the initial admin passwordkubectl -n argocd get secret argocd-initial-admin-secret \  -o jsonpath="{.data.password}" | base64 -d# Port-forward the UIkubectl port-forward svc/argocd-server -n argocd 8081:443
 Get the initial admin passwordkubectl -n argocd get secret argocd-initial-admin-secret \  -o jsonpath="{.data.password}" | base64 -d# Port-forward the UIkubectl port-forward svc/argocd-server -n argocd 8081:443
 Get the initial admin passwordkubectl -n argocd get secret argocd-initial-admin-secret \  -o jsonpath="{.data.password}" | base64 -d# Port-forward the UIkubectl port-forward svc/argocd-server -n argocd 8081:443
 Get the initial admin passwordkubectl -n argocd get secret argocd-initial-admin-secret \  -o jsonpath="{.data.password}" | base64 -d# Port-forward the UIkubectl port-forward svc/argocd-server -n argocd 8081:443

Open https://localhost:8081, log in as admin, and you’ll see the App of Apps tree.


ArgoCD UI showing root-app with the learning-app as a child, both synced (green).

Test self-healing

# Manually scale to 1 replicakubectl scale deployment learning-app -n learning-app --replicas=1# Watch ArgoCD revert it back to what Git sayskubectl get pods -n learning-app -w
# Manually scale to 1 replicakubectl scale deployment learning-app -n learning-app --replicas=1# Watch ArgoCD revert it back to what Git sayskubectl get pods -n learning-app -w
# Manually scale to 1 replicakubectl scale deployment learning-app -n learning-app --replicas=1# Watch ArgoCD revert it back to what Git sayskubectl get pods -n learning-app -w
# Manually scale to 1 replicakubectl scale deployment learning-app -n learning-app --replicas=1# Watch ArgoCD revert it back to what Git sayskubectl get pods -n learning-app -w

Within seconds, ArgoCD notices the drift and restores the desired state. Git always wins.

Step 5: CI/CD Pipeline

GitHub Actions builds images and updates the gitops repo automatically.

The Flow

You push to learning-app-src

GitHub Actions:

  1. Build Docker image

  2. Tag with git SHA (e.g., “a4f2b1c”)

  3. Push to Docker Hub

  4. Clone gitops repo

  5. Update values.yaml: tag: “a4f2b1c”

  6. Push to gitops repo

ArgoCD detects change → deploys new version

Workflow file (.github/workflows/ci.yaml)

name: Build and Deployon:  push:    branches: [main]env:  IMAGE: mahirberkano/learning-appjobs:  build-and-push:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - name: Set image tag        id: tag        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"      - name: Login to Docker Hub        uses: docker/login-action@v3        with:          username: ${{ secrets.DOCKERHUB_USERNAME }}          password: ${{ secrets.DOCKERHUB_TOKEN }}      - name: Build and push        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}            ${{ env.IMAGE }}:latest      - name: Update gitops repo        run: |          git clone https://x-access-token:${{ secrets.GIT_TOKEN }}@github.com/mahirberkano/learning-app-gitops.git          cd learning-app-gitops          sed -i "s|tag: .*|tag: \"${{ steps.tag.outputs.tag }}\"|" charts/learning-app/values.yaml          git config user.name "github-actions"          git config user.email "actions@github.com"          git commit -am "ci: bump image to ${{ steps.tag.outputs.tag }}"          git push
name: Build and Deployon:  push:    branches: [main]env:  IMAGE: mahirberkano/learning-appjobs:  build-and-push:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - name: Set image tag        id: tag        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"      - name: Login to Docker Hub        uses: docker/login-action@v3        with:          username: ${{ secrets.DOCKERHUB_USERNAME }}          password: ${{ secrets.DOCKERHUB_TOKEN }}      - name: Build and push        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}            ${{ env.IMAGE }}:latest      - name: Update gitops repo        run: |          git clone https://x-access-token:${{ secrets.GIT_TOKEN }}@github.com/mahirberkano/learning-app-gitops.git          cd learning-app-gitops          sed -i "s|tag: .*|tag: \"${{ steps.tag.outputs.tag }}\"|" charts/learning-app/values.yaml          git config user.name "github-actions"          git config user.email "actions@github.com"          git commit -am "ci: bump image to ${{ steps.tag.outputs.tag }}"          git push
name: Build and Deployon:  push:    branches: [main]env:  IMAGE: mahirberkano/learning-appjobs:  build-and-push:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - name: Set image tag        id: tag        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"      - name: Login to Docker Hub        uses: docker/login-action@v3        with:          username: ${{ secrets.DOCKERHUB_USERNAME }}          password: ${{ secrets.DOCKERHUB_TOKEN }}      - name: Build and push        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}            ${{ env.IMAGE }}:latest      - name: Update gitops repo        run: |          git clone https://x-access-token:${{ secrets.GIT_TOKEN }}@github.com/mahirberkano/learning-app-gitops.git          cd learning-app-gitops          sed -i "s|tag: .*|tag: \"${{ steps.tag.outputs.tag }}\"|" charts/learning-app/values.yaml          git config user.name "github-actions"          git config user.email "actions@github.com"          git commit -am "ci: bump image to ${{ steps.tag.outputs.tag }}"          git push
name: Build and Deployon:  push:    branches: [main]env:  IMAGE: mahirberkano/learning-appjobs:  build-and-push:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - name: Set image tag        id: tag        run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"      - name: Login to Docker Hub        uses: docker/login-action@v3        with:          username: ${{ secrets.DOCKERHUB_USERNAME }}          password: ${{ secrets.DOCKERHUB_TOKEN }}      - name: Build and push        uses: docker/build-push-action@v5        with:          context: .          push: true          tags: |            ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}            ${{ env.IMAGE }}:latest      - name: Update gitops repo        run: |          git clone https://x-access-token:${{ secrets.GIT_TOKEN }}@github.com/mahirberkano/learning-app-gitops.git          cd learning-app-gitops          sed -i "s|tag: .*|tag: \"${{ steps.tag.outputs.tag }}\"|" charts/learning-app/values.yaml          git config user.name "github-actions"          git config user.email "actions@github.com"          git commit -am "ci: bump image to ${{ steps.tag.outputs.tag }}"          git push

sed works for a learning project but it’s fragile, rewriting every line matching `tag:` in the file. For anything beyond a demo, use “https://github.com/mikefarah/yq” instead:

yq -i '.image.tag = "${{ steps.tag.outputs.tag }}"' charts/learning-app/values.yaml
yq -i '.image.tag = "${{ steps.tag.outputs.tag }}"' charts/learning-app/values.yaml
yq -i '.image.tag = "${{ steps.tag.outputs.tag }}"' charts/learning-app/values.yaml
yq -i '.image.tag = "${{ steps.tag.outputs.tag }}"' charts/learning-app/values.yaml

Required secrets

Secret & Purpose

DOCKERHUB_USERNAME: Docker Hub login

DOCKERHUB_TOKEN: Docker Hub access token (read/write)

GIT_TOKEN: GitHub PAT with repo scope (push to gitops repo)

Step 6: HPA (Horizontal Pod Autoscaler)

HPA automatically scales pod count based on CPU (or memory, or custom metrics).

How it works

Every 15 seconds, HPA calculates:

desiredReplicas = ceil(currentReplicas × (currentMetric / targetMetric))

Example: 2 pods at 120% CPU, target 50%:

ceil(2 × 120/50) = ceil(4.8) = 5 pods

Prerequisites

minikube addons enable metrics-server# Verify:kubectl top pods -n learning-app
minikube addons enable metrics-server# Verify:kubectl top pods -n learning-app
minikube addons enable metrics-server# Verify:kubectl top pods -n learning-app
minikube addons enable metrics-server# Verify:kubectl top pods -n learning-app

HPA manifest

apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:  name: learning-app  namespace: learning-appspec:  scaleTargetRef:    apiVersion: apps/v1    kind: Deployment    name: learning-app  minReplicas: 2  maxReplicas: 10  metrics:    - type: Resource      resource:        name: cpu        target:          type: Utilization          averageUtilization: 50  behavior:    scaleDown:      stabilizationWindowSeconds: 60
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:  name: learning-app  namespace: learning-appspec:  scaleTargetRef:    apiVersion: apps/v1    kind: Deployment    name: learning-app  minReplicas: 2  maxReplicas: 10  metrics:    - type: Resource      resource:        name: cpu        target:          type: Utilization          averageUtilization: 50  behavior:    scaleDown:      stabilizationWindowSeconds: 60
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:  name: learning-app  namespace: learning-appspec:  scaleTargetRef:    apiVersion: apps/v1    kind: Deployment    name: learning-app  minReplicas: 2  maxReplicas: 10  metrics:    - type: Resource      resource:        name: cpu        target:          type: Utilization          averageUtilization: 50  behavior:    scaleDown:      stabilizationWindowSeconds: 60
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata:  name: learning-app  namespace: learning-appspec:  scaleTargetRef:    apiVersion: apps/v1    kind: Deployment    name: learning-app  minReplicas: 2  maxReplicas: 10  metrics:    - type: Resource      resource:        name: cpu        target:          type: Utilization          averageUtilization: 50  behavior:    scaleDown:      stabilizationWindowSeconds: 60
 Terminal 1: port-forward the servicekubectl port-forward svc/learning-app -n learning-app 8080:80# Terminal 2: watch HPA in real timekubectl get hpa -n learning-app -w# Terminal 3: generate load (burns CPU for 30 seconds)curl "http://localhost:8080/load?duration=30s"
 Terminal 1: port-forward the servicekubectl port-forward svc/learning-app -n learning-app 8080:80# Terminal 2: watch HPA in real timekubectl get hpa -n learning-app -w# Terminal 3: generate load (burns CPU for 30 seconds)curl "http://localhost:8080/load?duration=30s"
 Terminal 1: port-forward the servicekubectl port-forward svc/learning-app -n learning-app 8080:80# Terminal 2: watch HPA in real timekubectl get hpa -n learning-app -w# Terminal 3: generate load (burns CPU for 30 seconds)curl "http://localhost:8080/load?duration=30s"
 Terminal 1: port-forward the servicekubectl port-forward svc/learning-app -n learning-app 8080:80# Terminal 2: watch HPA in real timekubectl get hpa -n learning-app -w# Terminal 3: generate load (burns CPU for 30 seconds)curl "http://localhost:8080/load?duration=30s"


The /ui/ dashboard with the load generator button.

What you’ll observe:

  1. CPU % climbs above 50%

  2. HPA scales replicas up (toward max 10)

  3. Load finishes → CPU drops

  4. After 60 seconds of low usage → scales back to 2

Why resource requests matter

HPA calculates utilization as actual_cpu / requests.cpu. Without requests defined, HPA can’t compute a percentage and won’t scale. This is the most common HPA “it’s not working” issue.

Karpenter: Node-Level Autoscaling (AWS EKS)

Note: Karpenter requires an AWS EKS cluster. It cannot run on minikube. This section is conceptual.

The Problem HPA Can’t Solve

HPA adds pods, but what if there are no nodes with enough capacity to schedule them? The new pods stay Pending. You need something to add nodes.

What Karpenter Does

Karpenter is an AWS-native node autoscaler that provisions right-sized EC2 instances when pods can’t be scheduled.

Pod unschedulable (no node capacity)        ↓Karpenter reads pod requirements (CPU, memory, GPU, topology)        ↓Calls EC2 CreateFleet API  provisions optimal instance        ↓Node registers  pod gets scheduled
Pod unschedulable (no node capacity)        ↓Karpenter reads pod requirements (CPU, memory, GPU, topology)        ↓Calls EC2 CreateFleet API  provisions optimal instance        ↓Node registers  pod gets scheduled
Pod unschedulable (no node capacity)        ↓Karpenter reads pod requirements (CPU, memory, GPU, topology)        ↓Calls EC2 CreateFleet API  provisions optimal instance        ↓Node registers  pod gets scheduled
Pod unschedulable (no node capacity)        ↓Karpenter reads pod requirements (CPU, memory, GPU, topology)        ↓Calls EC2 CreateFleet API  provisions optimal instance        ↓Node registers  pod gets scheduled

Karpenter vs Cluster Autoscaler

How it works Scales existing node groups (ASGs). Provisions individual instances directly

Instance selection: Limited to what’s in the node group. Picks from entire EC2 catalog dynamically

Speed: Minutes (ASG scaling). Seconds (direct EC2 API).

Right-sizing: One size fits all in a node group. Picks exact instance for the workload.

Consolidation: None Actively moves pods to fewer nodes

Spot: Mixed-instance ASG policies. Native interruption handling + automatic fleet diversification.

Key Concepts

NodePool: defines constraints for provisioned nodes:

apiVersion: karpenter.sh/v1kind: NodePoolspec:  template:    spec:      requirements:        - key: karpenter.sh/capacity-type          operator: In          values: ["on-demand", "spot"]        - key: node.kubernetes.io/instance-type          operator: In          values: ["m5.large", "m5.xlarge", "c5.large", "c5.xlarge"]  disruption:    consolidationPolicy: WhenEmptyOrUnderutilized
apiVersion: karpenter.sh/v1kind: NodePoolspec:  template:    spec:      requirements:        - key: karpenter.sh/capacity-type          operator: In          values: ["on-demand", "spot"]        - key: node.kubernetes.io/instance-type          operator: In          values: ["m5.large", "m5.xlarge", "c5.large", "c5.xlarge"]  disruption:    consolidationPolicy: WhenEmptyOrUnderutilized
apiVersion: karpenter.sh/v1kind: NodePoolspec:  template:    spec:      requirements:        - key: karpenter.sh/capacity-type          operator: In          values: ["on-demand", "spot"]        - key: node.kubernetes.io/instance-type          operator: In          values: ["m5.large", "m5.xlarge", "c5.large", "c5.xlarge"]  disruption:    consolidationPolicy: WhenEmptyOrUnderutilized
apiVersion: karpenter.sh/v1kind: NodePoolspec:  template:    spec:      requirements:        - key: karpenter.sh/capacity-type          operator: In          values: ["on-demand", "spot"]        - key: node.kubernetes.io/instance-type          operator: In          values: ["m5.large", "m5.xlarge", "c5.large", "c5.xlarge"]  disruption:    consolidationPolicy: WhenEmptyOrUnderutilized

EC2NodeClass: AWS-specific configuration:

apiVersion: karpenter.k8s.aws/v1kind: EC2NodeClassspec:  amiSelectorTerms:    - alias: al2023@latest  subnetSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster  securityGroupSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster
apiVersion: karpenter.k8s.aws/v1kind: EC2NodeClassspec:  amiSelectorTerms:    - alias: al2023@latest  subnetSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster  securityGroupSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster
apiVersion: karpenter.k8s.aws/v1kind: EC2NodeClassspec:  amiSelectorTerms:    - alias: al2023@latest  subnetSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster  securityGroupSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster
apiVersion: karpenter.k8s.aws/v1kind: EC2NodeClassspec:  amiSelectorTerms:    - alias: al2023@latest  subnetSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster  securityGroupSelectorTerms:    - tags:        karpenter.sh/discovery: my-cluster

Consolidation: Karpenter continuously evaluates:

  • Can this expensive on-demand node be replaced with a cheaper spot instance?

  • Can pods on an underutilized node fit elsewhere so we terminate it?

  • Are there empty nodes we can remove?

This runs continuously, saving significant cloud costs without manual intervention.

When to Use Karpenter vs Cluster Autoscaler

  • Use Karpenter if you’re on EKS and want fast, cost-efficient, intelligent scaling

  • Use Cluster Autoscaler if you’re on GKE/AKS or need simpler node group management

  • Use neither if your workload is stable and you can pre-provision nodes

The Full Scaling Picture

Traffic increases        ↓HPA: "CPU is high, I need more pods"        ↓HPA creates new pods  some are Pending (no capacity)        ↓Karpenter: "Pods need 2 CPU + 4GB, let me find the cheapest instance"        ↓Provisions c5.large spot instance in 30 seconds        ↓Pods scheduled  traffic served        ↓Traffic decreases        ↓HPA scales down pods  node becomes underutilized        ↓Karpenter consolidation: "This node is 20% used, moving pods elsewhere"        ↓Node terminated  cost saved
Traffic increases        ↓HPA: "CPU is high, I need more pods"        ↓HPA creates new pods  some are Pending (no capacity)        ↓Karpenter: "Pods need 2 CPU + 4GB, let me find the cheapest instance"        ↓Provisions c5.large spot instance in 30 seconds        ↓Pods scheduled  traffic served        ↓Traffic decreases        ↓HPA scales down pods  node becomes underutilized        ↓Karpenter consolidation: "This node is 20% used, moving pods elsewhere"        ↓Node terminated  cost saved
Traffic increases        ↓HPA: "CPU is high, I need more pods"        ↓HPA creates new pods  some are Pending (no capacity)        ↓Karpenter: "Pods need 2 CPU + 4GB, let me find the cheapest instance"        ↓Provisions c5.large spot instance in 30 seconds        ↓Pods scheduled  traffic served        ↓Traffic decreases        ↓HPA scales down pods  node becomes underutilized        ↓Karpenter consolidation: "This node is 20% used, moving pods elsewhere"        ↓Node terminated  cost saved
Traffic increases        ↓HPA: "CPU is high, I need more pods"        ↓HPA creates new pods  some are Pending (no capacity)        ↓Karpenter: "Pods need 2 CPU + 4GB, let me find the cheapest instance"        ↓Provisions c5.large spot instance in 30 seconds        ↓Pods scheduled  traffic served        ↓Traffic decreases        ↓HPA scales down pods  node becomes underutilized        ↓Karpenter consolidation: "This node is 20% used, moving pods elsewhere"        ↓Node terminated  cost saved

The Path Forward (When You Have an AWS Account)

If you want to take this learning project to EKS and try Karpenter for real, the rough steps are:

 1. Create a small EKS cluster (use eksctl for the quickest path)eksctl create cluster --name karpenter-demo \  --region eu-central-1 \  --node-type t3.medium \  --nodes 2
 2. Install Karpenter via Helmhelm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \  --version "1.0.0" \  --namespace kube-system \  --create-namespace \  --set "settings.clusterName=karpenter-demo" \  --wait
 3. Apply your NodePool and EC2NodeClass (the YAMLs shown above)kubectl apply -f nodepool.yamlkubectl apply -f ec2nodeclass.yaml 
 4. Point the same ArgoCD setup at this cluster
 5. Hammer the /load endpoint hard enough that HPA exhausts node capacity
 6. Watch Karpenter spin up new EC2 instances in real time
 1. Create a small EKS cluster (use eksctl for the quickest path)eksctl create cluster --name karpenter-demo \  --region eu-central-1 \  --node-type t3.medium \  --nodes 2
 2. Install Karpenter via Helmhelm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \  --version "1.0.0" \  --namespace kube-system \  --create-namespace \  --set "settings.clusterName=karpenter-demo" \  --wait
 3. Apply your NodePool and EC2NodeClass (the YAMLs shown above)kubectl apply -f nodepool.yamlkubectl apply -f ec2nodeclass.yaml 
 4. Point the same ArgoCD setup at this cluster
 5. Hammer the /load endpoint hard enough that HPA exhausts node capacity
 6. Watch Karpenter spin up new EC2 instances in real time
 1. Create a small EKS cluster (use eksctl for the quickest path)eksctl create cluster --name karpenter-demo \  --region eu-central-1 \  --node-type t3.medium \  --nodes 2
 2. Install Karpenter via Helmhelm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \  --version "1.0.0" \  --namespace kube-system \  --create-namespace \  --set "settings.clusterName=karpenter-demo" \  --wait
 3. Apply your NodePool and EC2NodeClass (the YAMLs shown above)kubectl apply -f nodepool.yamlkubectl apply -f ec2nodeclass.yaml 
 4. Point the same ArgoCD setup at this cluster
 5. Hammer the /load endpoint hard enough that HPA exhausts node capacity
 6. Watch Karpenter spin up new EC2 instances in real time
 1. Create a small EKS cluster (use eksctl for the quickest path)eksctl create cluster --name karpenter-demo \  --region eu-central-1 \  --node-type t3.medium \  --nodes 2
 2. Install Karpenter via Helmhelm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \  --version "1.0.0" \  --namespace kube-system \  --create-namespace \  --set "settings.clusterName=karpenter-demo" \  --wait
 3. Apply your NodePool and EC2NodeClass (the YAMLs shown above)kubectl apply -f nodepool.yamlkubectl apply -f ec2nodeclass.yaml 
 4. Point the same ArgoCD setup at this cluster
 5. Hammer the /load endpoint hard enough that HPA exhausts node capacity
 6. Watch Karpenter spin up new EC2 instances in real time

The IAM setup (Karpenter Controller role, Node IAM role, instance profile, SQS interruption queue) is the only fiddly part: follow the official Karpenter getting-started guide which gives you the CloudFormation template that creates everything in one shot.

Cost warning: a t3.medium EKS cluster runs ~$0.10/hour for the control plane + ~$0.04/hour per node. If you tear down the cluster the same day you create it, the whole experiment costs less than a coffee. Just don’t forget the `eksctl delete cluster` at the end.

Key Takeaways

  1. Separation of concerns: source code and infrastructure live in separate repos

  2. Git is the source of truth: the cluster always matches what’s in Git

  3. Self-healing: manual changes are overwritten, forcing everyone through Git

  4. Declarative > imperative: you declare desired state, controllers make it happen

  5. Resource requests matter: HPA and Karpenter both need accurate resource declarations

  6. Immutable deploys: new code = new image tag = new rollout. Never mutate running containers

  7. Scale at two levels: HPA scales pods (horizontal), Karpenter scales nodes (infrastructure)

Repos

Learning app source

Helm charts + ArgoCD applications

Built on minikube, deployed with ArgoCD, scaled with HPA. The concepts transfer directly to production EKS/GKE clusters the tools are the same, just the infrastructure gets bigger.

Check out our medium page: Clerion Medium

Kubernetes + GitOps from Scratch: A Hands-On Guide with ArgoCD, HPA, and Karpenter

11 min read

11 min read

Start Your Cloud Journey Today

Contact us now and take the first step toward innovation and scalability with Clerion’s expert cloud solutions.

Start Your Cloud Journey Today

Contact us now and take the first step toward innovation and scalability with Clerion’s expert cloud solutions.

Start Your Cloud Journey Today

Contact us now and take the first step toward innovation and scalability with Clerion’s expert cloud solutions.

Start Your Cloud Journey Today

Contact us now and take the first step toward innovation and scalability with Clerion’s expert cloud solutions.