Certifications · CKAD · Practice Tasks

CKAD Practice Tasks

Fifteen performance-based practice tasks, one bank, all five official CKAD domains, sized to the same weights the real exam uses — four tasks in the biggest domain, two in the smallest, three each in the rest. Every task reads like an exam item: a short scenario, a numbered list of what to build, and a done when line naming the exact kubectl check that proves it — not "did you write correct-looking YAML," but "does the cluster actually do the thing." Work each one cold, on a real cluster, against a clock, before you look at the worked solution underneath. This bank complements the CKAD blueprint's domain-by-domain theory and the CKAD study plan's weekly schedule — read those first if a domain still feels shaky; come here once you're ready to prove it with your hands.

☺ Explain it like I'm 10

Picture a mechanic's workshop with a board of fifteen repair tickets pinned up, and a supervisor who only cares whether the car actually starts afterward — not whether you can explain how an engine works. Some tickets are worth more than others, because that's genuinely how much of that kind of repair the shop does all day; you don't spend the same time on the rare stuff as the common stuff. You grab one ticket, set a timer, fix the car, and only after the timer stops or the car runs do you flip the ticket over to see how the senior mechanic would have done it. Peek at the answer before you've really tried, and you've learned to read repair manuals — not to fix cars.

🦫🐢Your hosts for this bank: Benny the Beaver & Timmy the Turtle — Benny wrote every ticket and won't sign one off until it actually runs on a cluster; Timmy holds the stopwatch and the four tasks in his own domain, config and security.

How to drill this bank

☺ Like you're 10: Try it yourself first, with a timer running — only lift the answer flap once you're stuck or done, or your brain learns "I can read solutions" instead of "I can do this."

CKAD has no question bank to cram, and neither does this page. Reading a worked solution feels like progress and mostly isn't — the exam never asks you to recognize correct YAML, it asks you to produce it, in a terminal, against a live cluster, before a two-hour clock runs out. Four habits make this bank behave like the real thing instead of a reading exercise.

One — start every task cold. Empty terminal, no leftover manifest from the last attempt, no tab open on the solution. That's how the exam starts you; starting warm just inflates the score you record for yourself.

Two — set an actual timer for five to seven minutes per task. That's roughly the real per-task budget on a two-hour paper. When it rings, stop — write one line about where you got stuck, and open the solution. Learning to abandon a task and move on is itself a scored skill on the day.

Three — run the context first, every time. The real exam prints a kubectl config use-context … line with every task, and solving a task perfectly against the wrong cluster or namespace scores zero — see Exam Day for the full habit. Get in the reflex here: kubectl config set-context --current --namespace=<ns> before you touch anything else.

Four — verify, don't trust. A fix you believe you made but never confirmed with kubectl get scores nothing, because grading only sees the cluster's end state, never your intent. Every task below names the exact command that proves it — run that command yourself, don't take the worked solution's word for it.

◆ Key idea

Run this bank on a disposable cluster you're willing to tear down and rebuild — kind or minikube both work, and this bank is deliberately written to run on exactly that kind of throwaway cluster rather than a specific cloud provider's managed one.

Weighted like the exam

☺ Like you're 10: The test doesn't grade every topic equally, so this bank doesn't either — the biggest domain gets four tasks, the smallest gets two.

The CKAD blueprint page covers the full official weights from CNCF curriculum v1.35; here's the same shape, translated into how many of the fifteen tasks below live in each domain.

🔑Application Environment, Configuration and Security
25%
🧩Application Design and Build
20%
🚢Application Deployment
20%
🐦Services and Networking
20%
🔭Application Observability and Maintenance
15%
Domain (curriculum order)WeightTasks in this bank
🧩 Application Design and Build20%3 — B1–B3
🚢 Application Deployment20%3 — D1–D3
🔭 Application Observability and Maintenance15%2 — O1–O2
🔑 Application Environment, Configuration and Security25%4 — C1–C4
🐦 Services and Networking20%3 — N1–N3
Total100%15
⚠ Verify officially before you book

This is an independent, unofficial study resource, not affiliated with the CNCF or the Linux Foundation. Domain weights above are transcribed from curriculum v1.35, but the real exam's exact task count, duration, and pass mark are logistics details that move on their own schedule and are deliberately not repeated here as fixed numbers beyond what's already confirmed on the CKAD blueprint page. Confirm current details on the official Linux Foundation CKAD page and the CNCF certification page before you pay for anything.

🧩 20% — Application Design and Build

☺ Like you're 10: These three are about picking the right kind of container, the right kind of controller, and the right kind of storage for the job.

Three tasks: a multi-container Pod using the native sidecar pattern, choosing the right workload resource for something that runs on a schedule, and mixing persistent with ephemeral storage in one Pod.

B1 · Add a native sidecar to a worker Pod

The invoice-worker Pod in namespace orders writes its logs to a file inside the container instead of stdout, so nothing outside the Pod can currently read them. It also starts too fast relative to the queue it depends on and needs to wait.

Your task:

  1. Create namespace orders if it doesn't exist.
  2. Add a regular init container wait-for-queue that blocks until rabbitmq.orders.svc port 5672 is reachable.
  3. Add a second init-container entry, log-shipper, with restartPolicy: Always so it runs as a native sidecar for the Pod's whole lifetime, tailing a shared log file.
  4. Mount the same emptyDir volume into both log-shipper and the main worker container so they share the log file.

Done when: kubectl -n orders get pod invoice-worker shows READY 2/2 and STATUS Running, and kubectl -n orders logs invoice-worker -c log-shipper --tail=5 prints lines the worker container wrote.

Show the worked solution
apiVersion: v1
kind: Pod
metadata:
  name: invoice-worker
  namespace: orders
spec:
  initContainers:
    - name: wait-for-queue                # ordinary init container: runs once, then exits
      image: busybox:1.36
      command: ["sh", "-c", "until nc -z rabbitmq.orders.svc 5672; do sleep 2; done"]
    - name: log-shipper                   # native sidecar: restartPolicy makes it long-running
      image: busybox:1.36
      restartPolicy: Always
      command: ["sh", "-c", "touch /var/log/worker/invoice.log; tail -F /var/log/worker/invoice.log"]
      volumeMounts:
        - { name: worklogs, mountPath: /var/log/worker }
  containers:
    - name: worker
      image: registry.example.com/invoice-worker:1.4.0
      volumeMounts:
        - { name: worklogs, mountPath: /var/log/worker }
  volumes:
    - name: worklogs
      emptyDir: {}
kubectl create namespace orders --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f invoice-worker.yaml
kubectl -n orders get pod invoice-worker -w
kubectl -n orders logs invoice-worker -c log-shipper --tail=5

Why: restartPolicy: Always on an initContainers entry is what turns it into a native sidecar rather than an ordinary init container — Kubernetes starts it before the main container, and if it later crashes, kubelet restarts only the sidecar, never the whole Pod. wait-for-queue stays a plain init container because it genuinely should run once and finish; log-shipper needs to outlive it. Enabled by default since Kubernetes 1.29, this replaces the old pattern of a second ordinary container and hoping the startup order worked out.

B2 · Convert a manual nightly job into a CronJob

Someone on the orders team currently SSHes into a box every night and runs a reconciliation script by hand. It must never overlap with itself if a run ever takes longer than expected, and you need a way to trigger a test run on demand without waiting for 3am.

Your task:

  1. Create a CronJob nightly-reconcile in namespace orders, schedule 0 3 * * *.
  2. Set concurrencyPolicy: Forbid so overlapping runs never happen, and keep only the last 3 successful and 1 failed Job.
  3. Trigger one run right now, outside the schedule, to prove the template works.

Done when: kubectl -n orders get cronjob nightly-reconcile shows the correct schedule and policy, and a manually triggered Job from it reaches Complete.

Show the worked solution
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-reconcile
  namespace: orders
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid       # never run two reconciles at once
  startingDeadlineSeconds: 120
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: reconcile
              image: registry.example.com/orders-reconcile:1.0.0
              command: ["./reconcile.sh"]
kubectl apply -f nightly-reconcile.yaml
kubectl -n orders get cronjob nightly-reconcile
kubectl -n orders create job --from=cronjob/nightly-reconcile manual-test-1
kubectl -n orders wait --for=condition=complete job/manual-test-1 --timeout=60s
kubectl -n orders get job manual-test-1

Why: Forbid is the answer whenever a task says a job must never run two copies at once — the default, Allow, happily stacks overlapping runs if one takes longer than the schedule interval, and Replace kills the running one instead of skipping the new one, which is a different guarantee. kubectl create job --from=cronjob/… is the fast, no-YAML way to fire the exact same jobTemplate on demand.

B3 · Combine persistent and ephemeral volumes in one Pod

A report-generation Pod in namespace orders needs its finished reports to survive a restart, but also needs scratch space for temporary files that should not survive one — mixing the two into the same volume today has already caused old scratch files to leak into delivered reports.

Your task:

  1. Create a 1Gi ReadWriteOnce PVC named reports-data.
  2. Create Pod report-generator mounting that PVC at /var/reports, and a separate 256Mi emptyDir at /tmp/scratch.
  3. Write a file into each path, delete the Pod, then recreate it from the same manifest and confirm which file survived.

Done when: after recreation, kubectl -n orders exec report-generator -- cat /var/reports/report.txt still returns the original text, and /tmp/scratch is empty again.

Show the worked solution
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: reports-data
  namespace: orders
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: standard        # kind's local-path-provisioner default SC
  resources:
    requests: { storage: 1Gi }
---
apiVersion: v1
kind: Pod
metadata:
  name: report-generator
  namespace: orders
spec:
  containers:
    - name: generator
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      volumeMounts:
        - { name: reports, mountPath: /var/reports }
        - { name: scratch, mountPath: /tmp/scratch }
  volumes:
    - name: reports
      persistentVolumeClaim: { claimName: reports-data }
    - name: scratch
      emptyDir: { sizeLimit: 256Mi }
kubectl apply -f reports-data-pvc.yaml -f report-generator.yaml
kubectl -n orders exec report-generator -- sh -c 'echo "Q3 totals" > /var/reports/report.txt; echo temp > /tmp/scratch/work.tmp'
kubectl -n orders delete pod report-generator
kubectl apply -f report-generator.yaml            # same claimName
kubectl -n orders exec report-generator -- cat /var/reports/report.txt   # "Q3 totals" survived
kubectl -n orders exec report-generator -- ls /tmp/scratch                # empty

Why: a PVC's bytes outlive the Pod that mounted it — delete and recreate the Pod against the same claimName and the data is exactly where you left it. emptyDir is the deliberate opposite: scoped to that one Pod object's lifetime, gone the moment it's deleted, which is exactly why it's the right choice for scratch space and the wrong one for anything you need tomorrow.

🚢 20% — Application Deployment

☺ Like you're 10: These three are about the moment you ship a new version — swapping it in all at once, a little at a time, or letting a tool like Helm do the swapping for you.

Three tasks: a blue/green cutover built from primitives, a rolling update and rollback, and deploying with both of the CKAD-scoped packaging tools.

D1 · Cut traffic over with a blue/green Service flip

The storefront team is about to ship v2 of their web app and wants zero gradual mixing — either everyone hits v1 or everyone hits v2, with an instant, reversible switch and no rolling update in between.

Your task:

  1. Create Deployments web-blue (labels app: web, version: blue, image tag 1.0.0) and web-green (app: web, version: green, tag 2.0.0), 3 replicas each, in namespace storefront.
  2. Create Service web initially selecting {app: web, version: blue}.
  3. Confirm web-green is healthy independently, then flip the Service's selector to version: green to cut all traffic over at once.

Done when: kubectl -n storefront get endpoints web -o jsonpath='{.subsets[0].addresses[*].targetRef.name}' lists only web-green-… Pod names.

Show the worked solution
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-blue
  namespace: storefront
spec:
  replicas: 3
  selector: { matchLabels: { app: web, version: blue } }
  template:
    metadata: { labels: { app: web, version: blue } }
    spec:
      containers:
        - name: web
          image: registry.example.com/storefront-web:1.0.0
          ports: [{ containerPort: 8080 }]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-green
  namespace: storefront
spec:
  replicas: 3
  selector: { matchLabels: { app: web, version: green } }
  template:
    metadata: { labels: { app: web, version: green } }
    spec:
      containers:
        - name: web
          image: registry.example.com/storefront-web:2.0.0
          ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: storefront
spec:
  selector: { app: web, version: blue }     # flip this line to cut over
  ports:
    - { port: 80, targetPort: 8080 }
kubectl apply -f web-blue.yaml -f web-green.yaml -f web-svc.yaml
kubectl -n storefront get pods -l version=green

# prove green is healthy on its own before it takes real traffic
kubectl -n storefront port-forward deploy/web-green 8080:8080 &
curl -s localhost:8080/healthz; kill %1

# the actual cutover
kubectl -n storefront patch service web -p '{"spec":{"selector":{"app":"web","version":"green"}}}'
kubectl -n storefront get endpoints web -o jsonpath='{.subsets[0].addresses[*].targetRef.name}'

Why: because web-blue and web-green are two entirely separate Deployments, both stay fully running and independently reachable the whole time — the Service's selector is the only thing that decides which one is "live," so the cutover is a single API write, not a gradual rollout, and rolling back is the same one-line patch in reverse.

D2 · Roll out safely, then roll back a broken image

The billing-api Deployment in namespace billing must never drop below its current replica count during an update — even briefly — and the team needs to see what a bad rollout looks like and recover from it without redeploying by hand.

Your task:

  1. Set billing-api (4 replicas) to a rolling update strategy that never has fewer than 4 healthy Pods and surges by at most 1 extra Pod.
  2. Ship a broken image tag and observe the rollout stall.
  3. Roll back to the last working revision using the rollout history, not by re-applying an old file.

Done when: kubectl -n billing rollout status deploy/billing-api reports success after the rollback, and kubectl -n billing rollout history deploy/billing-api shows the failed attempt as its own revision.

Show the worked solution
apiVersion: apps/v1
kind: Deployment
metadata:
  name: billing-api
  namespace: billing
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }   # never below 4 healthy Pods
  selector: { matchLabels: { app: billing-api } }
  template:
    metadata: { labels: { app: billing-api } }
    spec:
      containers:
        - name: api
          image: registry.example.com/billing-api:3.1.0
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
kubectl apply -f billing-api.yaml
kubectl -n billing rollout status deploy/billing-api

kubectl -n billing set image deploy/billing-api api=registry.example.com/billing-api:3.2.0-broken
kubectl -n billing rollout status deploy/billing-api --timeout=30s || true   # times out, never finishes

kubectl -n billing rollout undo deploy/billing-api
kubectl -n billing rollout status deploy/billing-api
kubectl -n billing rollout history deploy/billing-api

Why: maxUnavailable: 0 plus maxSurge: 1 is the "never serve fewer than the current count" shape — one extra Pod is created and must pass its readinessProbe before an old one is retired. That's also why a broken image hangs the rollout rather than taking the app down: the new ReplicaSet's Pods never turn Ready, so the old one is never scaled down. rollout undo is a new rollout back to the previous ReplicaSet's template, not time travel — which is exactly why it appears as a fresh entry in rollout history, not a deleted one.

D3 · Deploy the same shape with Helm, then with Kustomize

Two unrelated requests land on the same day: the platform team wants a standalone Redis cache deployed from an existing chart with persistence turned off for a dev environment, and the session-store app — currently one flat manifest — needs a dev overlay that trims it to a single replica and pins a dev image tag, without touching the base file.

Your task — part A (Helm):

  1. Add the bitnami chart repo and install bitnami/redis as release cache into namespace caching.
  2. Override values so it runs standalone, with auth and persistence both disabled.

Your task — part B (Kustomize):

  1. Create a base/ with the plain session-store Deployment and a kustomization.yaml.
  2. Create overlays/dev/ that sets namespace caching-dev, replicas to 1, and the image tag to dev — using Kustomize transformers, not an edited copy of the Deployment.

Done when: helm status cache -n caching reports STATUS: deployed, and kubectl -n caching-dev get deploy session-store -o jsonpath='{.spec.replicas}{" "}{.spec.template.spec.containers[0].image}' prints 1 registry.example.com/session-store:dev.

Show the worked solution
# part A — Helm
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install cache bitnami/redis -n caching --create-namespace \
  --set architecture=standalone \
  --set auth.enabled=false \
  --set master.persistence.enabled=false
helm status cache -n caching
kubectl -n caching get pods
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
---
# overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: caching-dev
resources:
  - ../../base
replicas:
  - name: session-store
    count: 1
images:
  - name: registry.example.com/session-store
    newTag: dev
# part B — Kustomize
kubectl kustomize overlays/dev              # render only — always check before applying
kubectl apply -k overlays/dev
kubectl -n caching-dev get deploy session-store \
  -o jsonpath='{.spec.replicas}{" "}{.spec.template.spec.containers[0].image}'

Why: Helm and Kustomize solve the same problem — "deploy the same thing with per-environment differences" — from opposite directions. Helm templates a chart with a values file the chart's author designed for you; Kustomize starts from plain, already-valid YAML and layers patches on top with no templating language at all. The exam expects fluency in both, because you rarely get to choose which one a given repo already uses. More on either in the Helm and Kustomize tool guides.

🦫 Benny's workshop · 20 min

Run B1 through D3 back to back as one block on a fresh kind cluster, six minutes per task on a real kitchen timer. When the timer rings mid-task, stop, write one line about where you stalled, and move on — don't finish "just this last bit." Only once all six are attempted (or abandoned) do you open a single answer key and re-run whatever you flagged, from scratch, on a clean namespace. The second pass is where the reflex actually gets built.

🔭 15% — Application Observability and Maintenance

☺ Like you're 10: Two tasks — teach the cluster to know when your app is actually ready, and learn to read the clues it leaves behind when something's broken.

The smallest domain by weight but the one that shows up inside almost every other domain's tasks too, since a broken probe or a misread error message can sink a task that has nothing else wrong with it.

O1 · Fix a slow-starting app stuck in a crash loop

The report-api Deployment in namespace reporting takes about 40 seconds to finish warming up on a cold start, but its livenessProbe was configured for a fast app and starts checking after only 5 seconds — kubelet keeps killing it mid-boot before it ever becomes ready, and it's stuck restarting forever.

Your task:

  1. Add a startupProbe that gives the container up to roughly 60 seconds to finish booting before liveness checks begin at all.
  2. Correct the readinessProbe, which is pointed at a path the app doesn't serve.
  3. Restart the rollout and confirm both Pods come up clean.

Done when: kubectl -n reporting get pods -l app=report-api shows RESTARTS 0 and READY 1/1 for both Pods within about a minute of the restart.

Show the worked solution
apiVersion: apps/v1
kind: Deployment
metadata:
  name: report-api
  namespace: reporting
spec:
  replicas: 2
  selector: { matchLabels: { app: report-api } }
  template:
    metadata: { labels: { app: report-api } }
    spec:
      containers:
        - name: api
          image: registry.example.com/report-api:2.0.0
          ports: [{ containerPort: 8080 }]
          startupProbe:
            httpGet: { path: /healthz, port: 8080 }
            failureThreshold: 30
            periodSeconds: 2          # up to ~60s to finish starting
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }   # was pointed at a nonexistent /health
            periodSeconds: 5
kubectl apply -f report-api.yaml
kubectl -n reporting rollout restart deploy/report-api
kubectl -n reporting get pods -l app=report-api -w

Why: livenessProbe and startupProbe answer different questions, and the exam rewards knowing which one is broken — a liveness failure kills a container that's already running and stuck; a startup failure just means it hasn't finished booting yet. Before startupProbe existed, the only fix was inflating the liveness probe's own initialDelaySeconds, which either undersells a fast build or, as here, kills a slow one before it gets a fair chance. Once startupProbe succeeds, only then do livenessProbe and readinessProbe take over on their own schedules.

O2 · Chase down a deprecated API and a silent config failure

Two unrelated problems land in namespace platform the same morning. First, a PodDisruptionBudget manifest that used to apply cleanly now fails outright. Second, the checkout Deployment's Pods are stuck and producing no logs at all.

Your task:

  1. Diagnose why report-api-pdb.yaml (below) fails to apply, then create a correctly-versioned PodDisruptionBudget checkout-pdb with minAvailable: 2 selecting app: checkout.
  2. Find out why the checkout Pods never start — kubectl logs shows nothing — using describe and cluster events, then fix the root cause.
# report-api-pdb.yaml — apply this first and read the error
apiVersion: policy/v1beta1     # removed from the Kubernetes API in v1.25
kind: PodDisruptionBudget
metadata:
  name: report-api-pdb
  namespace: platform
spec:
  minAvailable: 1
  selector: { matchLabels: { app: report-api } }

Done when: kubectl -n platform get pdb checkout-pdb shows a populated ALLOWED DISRUPTIONS column, and kubectl -n platform get pods -l app=checkout shows every Pod Running with no recent restarts.

Show the worked solution
kubectl apply -f report-api-pdb.yaml
# Error from server (NotFound): error when creating "report-api-pdb.yaml":
# no matches for kind "PodDisruptionBudget" in version "policy/v1beta1"
kubectl api-resources | grep -i poddisruptionbudget    # confirms the current group/version
apiVersion: policy/v1              # policy/v1beta1 was removed outright in Kubernetes 1.25
kind: PodDisruptionBudget
metadata:
  name: checkout-pdb
  namespace: platform
spec:
  minAvailable: 2
  selector: { matchLabels: { app: checkout } }
kubectl apply -f checkout-pdb.yaml
kubectl -n platform get pdb checkout-pdb

kubectl -n platform get pods -l app=checkout
kubectl -n platform describe pod <checkout-pod> | tail -15
# State: Waiting, Reason: CreateContainerConfigError
# Events: ...key "API_KEY" not found in Secret "checkout-secrets"
kubectl -n platform get events --sort-by=.lastTimestamp | tail -10

kubectl -n platform get secret checkout-secrets -o jsonpath='{.data}'
kubectl -n platform patch secret checkout-secrets --type=merge -p '{"stringData":{"API_KEY":"replace-me"}}'
kubectl -n platform get pods -l app=checkout -w

Why: policy/v1beta1 for PodDisruptionBudget was removed outright in Kubernetes 1.25, not just deprecated — this exact kind of "used to work, now the apply itself fails" trap is a deliberate, common CKAD item, and kubectl api-resources is the fastest way to confirm the current group/version without leaving the terminal. CreateContainerConfigError specifically means the container never started at all — it's an admission-time failure over a missing ConfigMap or Secret key, which is exactly why kubectl logs shows nothing and describe's Events section is the only place the real reason appears.

🔑 25% — Application Environment, Configuration and Security

☺ Like you're 10: The biggest pile of tickets — feeding config and secrets into an app, keeping it inside its resource budget, and locking down what it's allowed to do.

Four tasks for the largest, broadest domain: ConfigMaps and Secrets consumed two different ways, requests/limits under a namespace quota, least-privilege RBAC for a ServiceAccount, and SecurityContext/Capabilities hardening.

C1 · Feed a Deployment from a ConfigMap, a Secret, and a mounted TLS pair

The notifier app in namespace notifications needs its non-sensitive SMTP settings as environment variables, one sensitive credential also as an environment variable but not lumped in with everything else, and a TLS keypair that its library insists on reading as files on disk, not as env vars.

Your task:

  1. Create ConfigMap notify-config with SMTP_HOST=smtp.internal and SMTP_PORT=587.
  2. Create Secret notify-creds with SMTP_USER and SMTP_PASS.
  3. Create a kubernetes.io/tls Secret notify-tls from a cert/key pair.
  4. In Deployment notifier: pull the whole ConfigMap in with envFrom, pull only SMTP_PASS in individually via secretKeyRef, and mount notify-tls as a read-only volume at /etc/tls.

Done when: kubectl -n notifications exec deploy/notifier -- env includes correct values for SMTP_HOST, SMTP_PORT and SMTP_PASS, and kubectl -n notifications exec deploy/notifier -- ls /etc/tls lists tls.crt and tls.key.

Show the worked solution
kubectl create namespace notifications --dry-run=client -o yaml | kubectl apply -f -
kubectl create configmap notify-config -n notifications \
  --from-literal=SMTP_HOST=smtp.internal --from-literal=SMTP_PORT=587
kubectl create secret generic notify-creds -n notifications \
  --from-literal=SMTP_USER=notifier-bot --from-literal=SMTP_PASS='r3placeMe!'
kubectl create secret tls notify-tls -n notifications --cert=tls.crt --key=tls.key
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notifier
  namespace: notifications
spec:
  replicas: 1
  selector: { matchLabels: { app: notifier } }
  template:
    metadata: { labels: { app: notifier } }
    spec:
      containers:
        - name: notifier
          image: registry.example.com/notifier:1.0.0
          envFrom:
            - configMapRef: { name: notify-config }         # SMTP_HOST, SMTP_PORT
          env:
            - name: SMTP_PASS
              valueFrom: { secretKeyRef: { name: notify-creds, key: SMTP_PASS } }
          volumeMounts:
            - { name: tls, mountPath: /etc/tls, readOnly: true }
      volumes:
        - name: tls
          secret: { secretName: notify-tls }
kubectl apply -f notifier-deploy.yaml
kubectl -n notifications exec deploy/notifier -- env | grep -E 'SMTP_(HOST|PORT|PASS)'
kubectl -n notifications exec deploy/notifier -- ls /etc/tls

Why: envFrom pulls every key of a ConfigMap in with no renaming — the fast path when the app already expects those exact variable names. A single secretKeyRef is what you reach for the moment you need one key under a different name or want to keep it out of a blanket envFrom. Mounting a Secret as a volume, rather than an env var, is the right call for anything an on-disk library expects to read as a file — like a TLS keypair.

C2 · Cap a namespace with requests, limits, and a quota

The batch-jobs namespace currently lets anyone create Pods with no resource fields at all, and one team's runaway job already starved a neighbor's — before it happens again, every container needs a sane default and the namespace needs a hard ceiling.

Your task:

  1. Create a LimitRange that defaults every container to 500m/256Mi limits and 100m/128Mi requests when a Pod spec doesn't set its own.
  2. Create a ResourceQuota capping the namespace at requests.cpu: 2, requests.memory: 4Gi, limits.cpu: 4, limits.memory: 8Gi, and 10 Pods total.
  3. Prove both work: a Pod with no resources field picks up the LimitRange defaults, and a Pod requesting more cpu than the remaining quota allows is rejected outright.

Done when: kubectl -n batch-jobs get pod plain-job -o jsonpath='{.spec.containers[0].resources}' shows the LimitRange's injected defaults, and applying an over-quota Pod returns a Forbidden error naming compute-quota.

Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata: { name: compute-quota, namespace: batch-jobs }
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 4Gi
    limits.cpu: "4"
    limits.memory: 8Gi
    pods: "10"
---
apiVersion: v1
kind: LimitRange
metadata: { name: default-limits, namespace: batch-jobs }
spec:
  limits:
    - type: Container
      default: { cpu: 500m, memory: 256Mi }
      defaultRequest: { cpu: 100m, memory: 128Mi }
kubectl apply -f compute-quota.yaml -f default-limits.yaml

kubectl -n batch-jobs run plain-job --image=busybox --restart=Never -- sleep 3600
kubectl -n batch-jobs get pod plain-job -o jsonpath='{.spec.containers[0].resources}{"\n"}'

kubectl -n batch-jobs apply -f oversized-pod.yaml   # requests.cpu: 3, exceeds the quota's headroom
# Error from server (Forbidden): exceeded quota: compute-quota, requested: requests.cpu=3, ...
kubectl -n batch-jobs describe resourcequota compute-quota

Why: a LimitRange only fires when a container's manifest is silent on resources — it never overrides an explicit value, it just fills the gap so nothing sneaks in with truly unbounded scheduling weight. ResourceQuota is the separate, harder wall behind it: even a Pod with perfectly valid resources gets flatly rejected by the API server the instant the namespace's running total would cross a hard figure — you learn this from the error message, not the scheduler, because the object is never admitted at all.

C3 · Grant a ServiceAccount least-privilege RBAC

A diagnostics Pod in namespace ops-tools needs to list Pods and read their logs — nothing more, and certainly not delete anything. Meanwhile every other Pod in that namespace is currently carrying the default ServiceAccount's token even though nothing else needs one at all.

Your task:

  1. Create ServiceAccount log-reader.
  2. Create Role pod-log-reader allowing get/list on pods and get on pods/log — nothing else.
  3. Bind the Role to the ServiceAccount with a RoleBinding, and run Pod diagnostics using that ServiceAccount.
  4. Disable automatic token mounting on the namespace's default ServiceAccount, since nothing else should be carrying a credential it never asked for.

Done when: kubectl -n ops-tools auth can-i list pods --as=system:serviceaccount:ops-tools:log-reader returns yes, and the same check with delete instead of list returns no.

Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata: { name: log-reader, namespace: ops-tools }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: pod-log-reader, namespace: ops-tools }
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: log-reader-binding, namespace: ops-tools }
subjects:
  - kind: ServiceAccount
    name: log-reader
    namespace: ops-tools
roleRef: { kind: Role, name: pod-log-reader, apiGroup: rbac.authorization.k8s.io }
---
apiVersion: v1
kind: Pod
metadata: { name: diagnostics, namespace: ops-tools }
spec:
  serviceAccountName: log-reader
  containers:
    - name: kubectl
      image: bitnami/kubectl:1.30
      command: ["sleep", "3600"]
kubectl apply -f log-reader-sa.yaml -f pod-log-reader-role.yaml -f log-reader-binding.yaml -f diagnostics-pod.yaml
kubectl -n ops-tools auth can-i list pods --as=system:serviceaccount:ops-tools:log-reader
kubectl -n ops-tools auth can-i delete pods --as=system:serviceaccount:ops-tools:log-reader
kubectl -n ops-tools patch serviceaccount default -p '{"automountServiceAccountToken": false}'

Why: a Role plus a RoleBinding is namespace-scoped least privilege — pods/log is deliberately modeled as a separate resource from pods, so "can list Pods" and "can read their logs" are two rules granted independently, never bundled by default. kubectl auth can-i --as proves a grant actually works without needing a shell inside the Pod first. Turning off automountServiceAccountToken on the default ServiceAccount is the other half of least privilege — see RBAC & Admission Control for the full authn/authz/admission picture.

C4 · Harden a proxy's SecurityContext and Capabilities

The edge-proxy Deployment in namespace edge currently runs as root because it binds to port 80, which is below 1024 — security review wants it non-root, with every Linux capability dropped except the one it actually needs, and a read-only root filesystem.

Your task:

  1. Set runAsNonRoot: true and a fixed non-root runAsUser, with allowPrivilegeEscalation: false.
  2. Drop ALL capabilities, then add back only NET_BIND_SERVICE so it can still bind port 80.
  3. Set readOnlyRootFilesystem: true, and mount a writable emptyDir over the one path the image needs to write to.

Done when: kubectl -n edge get pods -l app=edge-proxy shows every Pod Running with 0 restarts, kubectl -n edge exec deploy/edge-proxy -- id shows a non-root uid, and the proxy still answers on port 80.

Show the worked solution
apiVersion: apps/v1
kind: Deployment
metadata: { name: edge-proxy, namespace: edge }
spec:
  replicas: 2
  selector: { matchLabels: { app: edge-proxy } }
  template:
    metadata: { labels: { app: edge-proxy } }
    spec:
      containers:
        - name: proxy
          image: registry.example.com/edge-proxy:1.1.0
          ports: [{ containerPort: 80 }]
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
              add: ["NET_BIND_SERVICE"]
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: {}
kubectl apply -f edge-proxy.yaml
kubectl -n edge get pods -l app=edge-proxy
kubectl -n edge exec deploy/edge-proxy -- id
kubectl -n edge get pod -l app=edge-proxy -o jsonpath='{.items[0].spec.containers[0].securityContext}'

Why: dropping ALL and adding back exactly one capability is the shape the exam consistently wants over leaving the default set in place — NET_BIND_SERVICE is precisely what lets a non-root process bind a privileged port without the much blunter alternative of running as root. readOnlyRootFilesystem: true is nearly free security and breaks surprisingly few images, but it does break any that write to /tmp or a cache directory — which is exactly why an emptyDir is mounted over the one path this image needs writable.

🐦 20% — Services and Networking

☺ Like you're 10: Three tasks about the plumbing — making sure requests actually reach a Pod, routing different paths to different apps, and locking down who's allowed to talk to whom.

Three tasks: the classic "nothing can reach this app" repair, path-based Ingress routing, and a NetworkPolicy default-deny with one allow rule punched through it.

N1 · Fix a Service nothing can reach

Namespace search has a running search-api Deployment and a Service in front of it, but every request to the Service times out. The Deployment and Service were written by different people on the same day.

Your task:

  1. Check the Service's endpoints first — an empty list means the selector itself is wrong.
  2. Fix the Service's selector so it matches the Pod template's labels.
  3. Fix the Service's targetPort so it matches the port the container actually listens on.

Done when: kubectl -n search get endpoints search-api lists both Pod IPs on the correct port, and a request from inside the cluster to search-api returns a response.

Show the worked solution
# the broken Service as found — container listens on 8000, Pod labeled app: search-api
apiVersion: v1
kind: Service
metadata: { name: search-api, namespace: search }
spec:
  selector: { app: search }           # BROKEN — doesn't match the Pod's app: search-api label
  ports:
    - { port: 80, targetPort: 8080 }  # BROKEN — container listens on 8000, not 8080
kubectl -n search get endpoints search-api            # empty ADDRESSES before the fix

kubectl -n search patch svc search-api --type=merge -p \
  '{"spec":{"selector":{"app":"search-api"},"ports":[{"port":80,"targetPort":8000}]}}'

kubectl -n search get endpoints search-api            # now lists both Pod IPs:8000
kubectl -n search run smoke --rm -it --restart=Never --image=busybox -- wget -qO- http://search-api

Why: kubectl get endpoints is the single fastest triage step for "nothing can reach this app" — an empty ADDRESSES column means the Service's selector isn't matching any Pod's labels, full stop, before you even look at targetPort. A targetPort mismatch is the next most common cause, and it fails differently: the Service accepts the connection and then gets nothing back, distinct from a flat connection refused. Both live in this course's Troubleshooting page.

N2 · Route two paths to two Services with one Ingress

The portal namespace runs a web UI and a separate API gateway as two Services, and they need to sit behind one hostname: portal.example.com/api/… should reach the gateway, everything else should reach the UI.

Your task:

  1. Create Services web-ui (port 80 → container 3000) and api-gateway (port 80 → container 8080).
  2. Create Ingress portal using ingressClassName: nginx, host portal.example.com, with a /api path routed to api-gateway and / routed to web-ui.

Done when: kubectl -n portal get ingress portal lists both rules with the correct backends, and a request for /api/health with the right Host header reaches api-gateway while /dashboard reaches web-ui.

Show the worked solution
apiVersion: v1
kind: Service
metadata: { name: web-ui, namespace: portal }
spec:
  selector: { app: web-ui }
  ports: [{ port: 80, targetPort: 3000 }]
---
apiVersion: v1
kind: Service
metadata: { name: api-gateway, namespace: portal }
spec:
  selector: { app: api-gateway }
  ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: portal
  namespace: portal
spec:
  ingressClassName: nginx
  rules:
    - host: portal.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service: { name: api-gateway, port: { number: 80 } }
          - path: /
            pathType: Prefix
            backend:
              service: { name: web-ui, port: { number: 80 } }
kubectl apply -f portal-services.yaml -f portal-ingress.yaml
kubectl -n portal get ingress portal

kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80 &
curl -s -H "Host: portal.example.com" http://localhost:8080/api/health
curl -s -H "Host: portal.example.com" http://localhost:8080/dashboard

Why: pathType: Prefix matches on whole path segments, and the controller picks the longest matching prefix regardless of the order rules are written — but write the more specific /api rule first anyway, since that's how a human reasons about it and how the exam grader reads your YAML. If the controller's own install or annotations are the shaky part, that's this course's ingress-nginx tool guide.

N3 · Default-deny a namespace, then open exactly one path

The ledger Deployment in namespace payments currently accepts traffic from anything in the cluster. Security wants nothing able to reach it except Pods labeled app: frontend, on the exact port it serves.

Your task:

  1. Create a default-deny-ingress NetworkPolicy selecting every Pod in payments.
  2. Create a second NetworkPolicy that allows ingress to app: ledger Pods, only from Pods labeled app: frontend, only on port 8080.
  3. Prove it from both sides: a request from a frontend-labeled Pod succeeds, a request from an unlabeled Pod does not.

Done when: a Pod labeled app: frontend can reach ledger:8080, and a Pod without that label times out reaching the same address.

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: payments
spec:
  podSelector: {}          # every Pod in the namespace
  policyTypes: [Ingress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-from-frontend
  namespace: payments
spec:
  podSelector: { matchLabels: { app: ledger } }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: { matchLabels: { app: frontend } }
      ports:
        - { protocol: TCP, port: 8080 }
kubectl apply -f ledger.yaml -f default-deny-ingress.yaml -f allow-from-frontend.yaml

kubectl -n payments run allowed --labels=app=frontend --rm -it --restart=Never \
  --image=busybox -- wget -qT 3 -O- http://ledger:8080
kubectl -n payments run blocked --rm -it --restart=Never \
  --image=busybox -- wget -qT 3 -O- http://ledger:8080     # times out

Why: an empty podSelector: {} selects every Pod in the namespace, and a NetworkPolicy with policyTypes: [Ingress] and no ingress rules denies all inbound traffic to whatever it selects — that pair is the entire default-deny idiom. The allow policy then punches exactly one hole back open, scoped to both a source label and a port. This is also the one CKAD task category where a technically perfect manifest can still fail the done-when check: kind's default kindnet CNI (and a stock minikube) accepts NetworkPolicy objects without ever enforcing them — verify on a cluster running Calico or Cilium, the same caveat flagged on the CKAD blueprint page.

Score yourself, then move to a mock exam

☺ Like you're 10: Once you've done every ticket cold, redo only the ones you got wrong tomorrow — then try a full timed paper.

Read the pattern across all fifteen, not any single result. A whole domain dragging is a knowledge gap — reread that domain's section on the CKAD blueprint before you retry it. Consistently finishing correct but over the five-to-seven-minute box is a speed problem, fixed by drilling the imperative shortcuts in the kubectl fluency baseline rather than by re-reading YAML. A task you got wrong once is worth doing again tomorrow, cold, more than doing three you already know well a second time — keep a running list in Field Notes and strike items off only once they run clean without help.

Once the whole bank runs clean, move on to CKAD Mock Exam · Set 1 and Set 2 for full two-hour timed sittings that interleave all five domains the way the real paper does, and revisit the CKAD study plan if a whole week's worth of material needs a second pass rather than one task. For a different explanation of any domain that still isn't clicking, the sibling Platform Engineering course's CKAD page covers the same exam from a platform-team angle, and if CKAD is one stop on your way to the full ladder, the Golden Astronaut course covers the other Kubestronaut and Golden Kubestronaut certifications.

🎬 At the Pod Squad
🦫

Benny: Fifteen tickets, weighted like the real thing. Four in my domain, four in Timmy's, the rest split across build, deploy, and networking.

👺

Gizmo: Or just skip straight to the mock exam. Why waste time on the little tickets when you can find out your score in one go? 🤑

🐢

Timmy: Because a mock exam tells you that something's wrong, not which ten-second decision was wrong. These tasks isolate exactly one skill each so you know precisely what to redo.

🦊

Foxy: Wait — why does the config-and-security domain get four whole tickets and observability only two?

🐢

Timmy: Because it's a quarter of the real exam's score, and observability's the smallest domain on the paper. The bank isn't fair by ticket count — it's fair by what it's actually worth.

🐰

Remy: And time yourself on every single one, not just the mocks. Six minutes, real timer, no pausing to look something up mid-task unless the exam itself would let you.

🦫

Benny: Redo whatever you flagged tomorrow, cold, on a clean namespace. That second pass is the one that actually sticks.

🐢 Timmy's checkpoint

1. Why does this bank give the Environment/Configuration/Security domain four tasks while Observability & Maintenance gets only two? 2. In task B1, what single field turns an ordinary init container into a native sidecar, and what's the practical benefit? 3. In C2, what's the difference between what a LimitRange does and what a ResourceQuota does when a Pod's resource request is too large? 4. In N1, what's the single fastest command to triage "nothing can reach this app," and what does an empty result tell you? 5. Why can a technically correct NetworkPolicy still fail its done-when check on a stock kind cluster? 6. In O1, why doesn't raising a livenessProbe's initialDelaySeconds solve a slow-starting app as well as adding a startupProbe? 7. In O2, why does kubectl logs show nothing for a Pod stuck in CreateContainerConfigError?

Check your answers
  1. Because the bank is weighted to match the real exam's domain weights — Environment/Configuration/Security is 25% of the score, the largest domain, while Observability & Maintenance is the smallest at 15%.
  2. restartPolicy: Always on an initContainers entry. It starts before the main container like any init container, but keeps running for the Pod's whole lifetime and is restarted on its own if it crashes, without restarting the whole Pod.
  3. A LimitRange only fills in defaults when a container's manifest doesn't set resources at all — it never overrides an explicit value. A ResourceQuota is a hard namespace-wide ceiling: the API server rejects any Pod, even one with perfectly valid resources, the moment it would push the namespace's running total past a hard figure.
  4. kubectl get endpoints <service>. An empty ADDRESSES list means the Service's selector isn't matching any Pod's labels at all — check that before ever suspecting targetPort or a NetworkPolicy.
  5. Because NetworkPolicy enforcement depends on the CNI, not the Kubernetes API — the object is accepted and stored either way. kind's default kindnet CNI (and a stock minikube) never actually enforces it, so the policy exists but does nothing until the cluster runs Calico or Cilium.
  6. Because initialDelaySeconds on a liveness probe is a single fixed number applied to every future restart, not just the first boot — inflate it enough for a 40-second cold start and you've also made every later liveness check that much slower to notice a real hang. A startupProbe gives startup its own budget and hands off to normal liveness/readiness timing only once it succeeds.
  7. Because CreateContainerConfigError happens before the container process is ever started — the kubelet can't build a valid container spec (here, a referenced Secret key doesn't exist), so there's no process and therefore no logs. The reason only shows up in kubectl describe's Events section or in kubectl get events.