Skip to content

Certified Kubernetes Application Developer (CKAD)

The CKAD is a two-hour, hands-on lab exam focused entirely on running applications in Kubernetes - not managing the cluster. You'll be creating and modifying workloads, wiring up config and secrets, writing probes, exposing services, and debugging broken apps. Speed is the biggest constraint: you need to produce correct YAML fast and know the right kubectl flags without looking them up.


Exam Facts

Format Browser-based terminal, multiple live clusters
Duration 2 hours
Passing score 66%
Price See current pricing - includes one free retake
Open book kubernetes.io/docs, kubernetes.io/blog and their subdomains. The exam's Important Instructions page is the authoritative list - read it the week before you sit
Questions ~15-20 tasks across multiple namespaces
Curriculum version Track github.com/cncf/curriculum for the version tied to your exam date. Exam facts on this page verified 2026-09-09

Study the Concepts First

Exam tactics below assume the concepts are solid. If any domain feels shaky, read the matching deep dive first:

Exam domain Weight Deep-dive companions
Application Design and Build 20% Pods & Deployments · Jobs & CronJobs · Init Containers
Application Environment, Configuration and Security 25% ConfigMaps & Secrets · Security Context · RBAC · Requests & Limits
Application Deployment 20% Pods & Deployments · Scaling & HPA · Helm · Kustomize
Application Observability and Maintenance 15% Health Probes · Troubleshooting
Services & Networking 20% Services · Ingress · Network Policies

Weights are from the CNCF CKAD curriculum (github.com/cncf/curriculum) and sum to 100%. Note that Application Environment, Configuration and Security is the single heaviest domain at 25% - budget study time accordingly.


First 3 Minutes: Terminal Setup

# kubectl autocomplete
source <(kubectl completion bash)
echo "source <(kubectl completion bash)" >> ~/.bashrc

# Essential alias
alias k=kubectl
complete -F __start_kubectl k

# Editor (pick one and stick with it)
export KUBE_EDITOR=vim    # or nano

Vim settings (~/.vimrc):

set expandtab
set tabstop=2
set shiftwidth=2


The Core Skill: Generate YAML, Don't Write It

Writing manifests from scratch is slow and error-prone. Use --dry-run=client -o yaml to generate a scaffold, then edit.

# Pod
kubectl run mypod --image=nginx --dry-run=client -o yaml > pod.yaml

# Pod with specific port
kubectl run mypod --image=nginx --port=8080 --dry-run=client -o yaml > pod.yaml

# Pod with env var
kubectl run mypod --image=nginx --env="DB_HOST=localhost" --dry-run=client -o yaml > pod.yaml

# Pod with labels
kubectl run mypod --image=nginx --labels="app=frontend,tier=web" --dry-run=client -o yaml > pod.yaml

# Pod with command override
kubectl run mypod --image=busybox --dry-run=client -o yaml -- /bin/sh -c "sleep 3600" > pod.yaml

# Deployment
kubectl create deployment myapp --image=nginx --replicas=3 --dry-run=client -o yaml > deploy.yaml

# Job
kubectl create job myjob --image=busybox --dry-run=client -o yaml -- /bin/sh -c "echo hello" > job.yaml

# CronJob
kubectl create cronjob mycron --image=busybox --schedule="*/5 * * * *" --dry-run=client -o yaml -- date > cron.yaml

# ConfigMap
kubectl create configmap app-config \
  --from-literal=DB_HOST=localhost \
  --from-literal=DB_PORT=5432 \
  --dry-run=client -o yaml > cm.yaml

# ConfigMap from file
kubectl create configmap app-config --from-file=config.properties --dry-run=client -o yaml

# Secret
kubectl create secret generic app-secret \
  --from-literal=password=mysecret \
  --dry-run=client -o yaml > secret.yaml

# ServiceAccount
kubectl create serviceaccount my-sa --dry-run=client -o yaml

# Service
kubectl create service clusterip mysvc --tcp=80:8080 --dry-run=client -o yaml
kubectl expose deployment myapp --port=80 --target-port=8080 --dry-run=client -o yaml

Domain 1: Application Design and Build (20%)

Building Container Images

The curriculum says "define, build and modify container images", so know a Dockerfile well enough to write and fix one. A minimal multi-stage build keeps the runtime image small, which is both good practice and a common exam-adjacent ask:

# Build stage - toolchain lives here and is thrown away
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# Runtime stage - only the binary ships
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
docker build -t myregistry.io/myapp:1.0 .
docker run --rm -p 8080:8080 myregistry.io/myapp:1.0
docker push myregistry.io/myapp:1.0

# podman is a drop-in replacement for the same three commands
podman build -t myregistry.io/myapp:1.0 .
podman run --rm -p 8080:8080 myregistry.io/myapp:1.0
podman push myregistry.io/myapp:1.0

Key instruction differences worth knowing cold: ENTRYPOINT maps to a container's command: in a pod spec, CMD maps to args:. Setting command/args in the pod spec overrides both.

Multi-Container Pod Patterns

These appear regularly. Know the YAML for each.

Sidecar - enhances the main container (log shipper, proxy):

apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  volumes:
  - name: shared-logs
    emptyDir: {}
  containers:
  - name: app
    image: nginx
    volumeMounts:
    - name: shared-logs
      mountPath: /var/log/nginx
  - name: log-shipper
    image: busybox
    command: ["/bin/sh", "-c", "tail -f /logs/access.log"]
    volumeMounts:
    - name: shared-logs
      mountPath: /logs

Init Container - runs to completion before main containers start:

spec:
  initContainers:
  - name: init-db
    image: busybox
    command: ['sh', '-c', 'until nslookup mydb; do echo waiting; sleep 2; done']
  containers:
  - name: app
    image: myapp:1.0

Use init containers for: waiting for a dependency, seeding a volume, running migrations.

Ephemeral container (debug a running pod):

kubectl debug -it <pod> --image=busybox --target=<container>

Jobs and CronJobs

# Job - runs once
apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  completions: 3       # run this many times total
  parallelism: 2       # run this many in parallel
  backoffLimit: 4      # retry up to 4 times before marking failed
  template:
    spec:
      restartPolicy: Never   # Never or OnFailure - not Always
      containers:
      - name: pi
        image: perl
        command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"]
# CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cleanup
spec:
  schedule: "0 2 * * *"   # 2am daily
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: cleanup
            image: busybox
            command: ["/bin/sh", "-c", "echo cleanup done"]

Cron schedule cheat sheet:

* * * * *
│ │ │ │ └── day of week (0-7, 0 and 7 = Sunday)
│ │ │ └──── month (1-12)
│ │ └────── day of month (1-31)
│ └──────── hour (0-23)
└────────── minute (0-59)


Domain 2: Application Environment, Configuration and Security (25%)

This is the heaviest domain on the exam. It covers ConfigMaps, Secrets, requests/limits/quotas, ServiceAccounts, authentication and authorization, application security (SecurityContext, capabilities), and discovering resources that extend Kubernetes (CRDs and operators).

ConfigMaps - Four Ways to Use Them

1. Environment variable (single key):

env:
- name: DB_HOST
  valueFrom:
    configMapKeyRef:
      name: app-config
      key: DB_HOST

2. All keys as env vars:

envFrom:
- configMapRef:
    name: app-config

3. Mounted as a file:

volumes:
- name: config-vol
  configMap:
    name: app-config
containers:
- volumeMounts:
  - name: config-vol
    mountPath: /etc/config
    # each key becomes a file: /etc/config/DB_HOST, etc.

4. Mounted as a specific file:

volumes:
- name: config-vol
  configMap:
    name: app-config
    items:
    - key: config.properties
      path: app.properties   # mounted as /etc/config/app.properties

Secrets - Same Patterns, Different Object

# Single key env var
env:
- name: PASSWORD
  valueFrom:
    secretKeyRef:
      name: app-secret
      key: password

# All keys as env vars
envFrom:
- secretRef:
    name: app-secret

# Volume mount (files)
volumes:
- name: secret-vol
  secret:
    secretName: app-secret

Gotcha: secret values in YAML must be base64 encoded. kubectl create secret handles this automatically. If you're writing the YAML by hand:

echo -n "mypassword" | base64   # outputs: bXlwYXNzd29yZA==

Resource Requests and Limits

containers:
- name: app
  image: nginx
  resources:
    requests:
      memory: "64Mi"
      cpu: "250m"     # 250 millicores = 0.25 CPU
    limits:
      memory: "128Mi"
      cpu: "500m"
  • Requests: what the scheduler uses to find a node; guaranteed to the container
  • Limits: hard ceiling; CPU is throttled at limit, memory causes OOM kill
  • 100m = 0.1 CPU, 1000m = 1 CPU, 1 = 1 CPU

SecurityContext

# Pod-level (applies to all containers)
spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000

# Container-level (overrides pod-level)
  containers:
  - name: app
    securityContext:
      runAsNonRoot: true
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
        add: ["NET_BIND_SERVICE"]

ServiceAccounts

# Create a ServiceAccount
kubectl create serviceaccount my-sa -n default

# Bind a Role to it
kubectl create rolebinding my-sa-binding \
  --role=pod-reader \
  --serviceaccount=default:my-sa \
  -n default

Use it in a pod:

spec:
  serviceAccountName: my-sa
  automountServiceAccountToken: false

Projected Volumes

Merge multiple config sources into a single volume:

volumes:
- name: projected
  projected:
    sources:
    - configMap:
        name: app-config
    - secret:
        secretName: app-secret
    - serviceAccountToken:
        path: token
        expirationSeconds: 3600

Downward API

Expose pod metadata and resource values to the container:

env:
- name: POD_NAME
  valueFrom:
    fieldRef:
      fieldPath: metadata.name
- name: POD_IP
  valueFrom:
    fieldRef:
      fieldPath: status.podIP
- name: CPU_LIMIT
  valueFrom:
    resourceFieldRef:
      containerName: app
      resource: limits.cpu

The same two sources work as a volume (downwardAPI: with items: [{path: ..., fieldRef: ...}]) when the app wants files rather than env vars.

ResourceQuota and LimitRange

Namespace-level constraints are explicitly in this domain, and a task may hand you a namespace whose quota is rejecting your pod:

kubectl create quota compute-quota --hard=pods=10,requests.cpu=2,limits.memory=4Gi -n dev
kubectl describe quota -n dev          # see used vs hard - this is how you diagnose a rejected pod
kubectl describe limitrange -n dev     # defaults injected into pods that omit resources

If a namespace has a ResourceQuota that sets requests.cpu or limits.memory, every container in that namespace must declare the matching requests/limits or the pod is rejected outright.

Discovering Extension Resources (CRDs and Operators)

The exam can hand you a cluster with a custom resource installed and ask you to create an instance of it without giving you the schema. Discover it:

kubectl get crd
kubectl api-resources                      # short names, api group, namespaced or not
kubectl api-resources --api-group=cert-manager.io
kubectl explain certificate --recursive    # full field tree, no docs needed
kubectl get certificate -A

kubectl explain reads the schema straight off the API server, so it works for custom resources exactly as it does for built-ins. This is faster than searching the operator's website.


Domain 3: Application Deployment (20%)

Deployment Strategies

RollingUpdate (default):

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # max pods over desired count during update
      maxUnavailable: 0    # max pods that can be unavailable

Recreate (takes downtime, useful for incompatible migrations):

spec:
  strategy:
    type: Recreate

Rollout commands:

kubectl rollout status deployment/myapp
kubectl rollout history deployment/myapp
kubectl rollout undo deployment/myapp
kubectl rollout undo deployment/myapp --to-revision=2
kubectl set image deployment/myapp nginx=nginx:1.25

Canary deployment (manual approach - two deployments, adjust replicas):

# stable: 9 replicas with label version=stable
# canary: 1 replica with label version=canary
# Both have label app=myapp so same Service selects them
# Traffic split ≈ 90/10 by replica ratio

Horizontal Pod Autoscaler

kubectl autoscale deployment myapp --cpu-percent=50 --min=2 --max=10
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50

Note: HPA requires metrics-server to be running in the cluster.

Helm

"Use the Helm package manager to deploy existing packages" is an explicit CKAD objective. You are not asked to author charts - you are asked to install, configure and roll back someone else's.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm search repo nginx
helm show values bitnami/nginx | less       # find the key you need to override

helm install web bitnami/nginx -n web --create-namespace \
  --set replicaCount=3 \
  -f values.yaml

helm list -A                                 # all releases, all namespaces
helm upgrade web bitnami/nginx --set replicaCount=5 -n web
helm history web -n web
helm rollback web 1 -n web                   # back to revision 1
helm uninstall web -n web

helm template web bitnami/nginx              # render locally without installing

--set beats -f values.yaml when both set the same key, and helm show values is much faster than hunting for the option name in a browser.

Kustomize

Kustomize is built into kubectl, so there is nothing to install on the exam.

base/
  kustomization.yaml     # resources: [deployment.yaml, service.yaml]
  deployment.yaml
  service.yaml
overlays/prod/
  kustomization.yaml
  replica-patch.yaml
# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base
namePrefix: prod-
commonLabels:
  env: prod
images:
- name: myapp
  newTag: "2.0"
patches:
- path: replica-patch.yaml
  target:
    kind: Deployment
    name: myapp
kubectl kustomize overlays/prod        # render to stdout - always check before applying
kubectl apply -k overlays/prod
kubectl delete -k overlays/prod

Domain 4: Application Observability and Maintenance (15%)

Probes - Get These Right

Probes are a major exam topic. There are three types, and you need to know when to use each.

Liveness probe - restarts the container if it fails:

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 15   # wait before first check
  periodSeconds: 10          # check every 10s
  failureThreshold: 3        # restart after 3 consecutive failures

Readiness probe - removes pod from service endpoints if it fails (doesn't restart):

readinessProbe:
  tcpSocket:
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5

Startup probe - for slow-starting apps; disables liveness/readiness until it passes:

startupProbe:
  exec:
    command: ["/bin/sh", "-c", "cat /tmp/healthy"]
  failureThreshold: 30   # 30 * 10s = 5 minutes to start
  periodSeconds: 10

Probe types: - httpGet - HTTP GET, success if 200-399 - tcpSocket - TCP connection succeeds - exec - command exits with 0

Common mistake: setting initialDelaySeconds too low for a slow app. The liveness probe fires before the app is up, triggers a restart, and you get a CrashLoopBackOff even though the app itself is fine.

Debugging

# Pod not starting
kubectl describe pod <name>   # look at Events section
kubectl logs <name>
kubectl logs <name> --previous   # logs from crashed container

# Running pod - inspect without exec
kubectl describe pod <name>
kubectl top pod <name>   # resource usage (needs metrics-server)

# Get a shell
kubectl exec -it <pod> -- /bin/sh
kubectl exec -it <pod> -c <container> -- /bin/sh   # specific container

# Debug without exec available
kubectl debug -it <pod> --image=busybox --target=<container>

# Copy logs out
kubectl cp <pod>:/var/log/app.log ./app.log

API Deprecations

"Understand API deprecations" is an objective in this domain, and a task may hand you a manifest that no longer applies. The tell is an error naming a version that "no longer served".

kubectl api-versions                        # every group/version this server serves
kubectl explain ingress                     # prints the version kubectl will use
kubectl apply -f old.yaml --dry-run=server  # surfaces deprecation warnings without writing
kubectl convert -f old.yaml --output-version networking.k8s.io/v1   # if the plugin is present

Versions to know by heart, because stale manifests still float around:

Object Current API version
Deployment, DaemonSet, StatefulSet, ReplicaSet apps/v1
Job, CronJob batch/v1
Ingress, NetworkPolicy, IngressClass networking.k8s.io/v1
HorizontalPodAutoscaler autoscaling/v2
Role, RoleBinding, ClusterRole, ClusterRoleBinding rbac.authorization.k8s.io/v1
PodDisruptionBudget policy/v1

Deprecation policy in one line: a GA (v1) API is supported for at least 12 months or 3 releases after deprecation, whichever is longer; beta APIs get 9 months or 3 releases.


Domain 5: Services and Networking (20%)

Service Types

# ClusterIP (default) - internal only
apiVersion: v1
kind: Service
metadata:
  name: my-svc
spec:
  selector:
    app: myapp
  ports:
  - port: 80           # port the service listens on
    targetPort: 8080   # port on the pod
  type: ClusterIP

# NodePort - exposed on every node
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080    # optional; 30000-32767 if not set

Headless Service - no cluster IP; returns pod IPs directly via DNS:

spec:
  clusterIP: None
  selector:
    app: myapp

DNS format for headless pods: <pod-name>.<service>.<namespace>.svc.cluster.local

Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
spec:
  ingressClassName: nginx
  rules:
  - host: myapp.example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: api-svc
            port:
              number: 8080
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend-svc
            port:
              number: 80

NetworkPolicy

# Deny all ingress to a set of pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
  - Ingress

# Allow only from specific pod
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - port: 8080

Speed Techniques That Matter

Edit live resources:

kubectl edit deployment myapp   # opens in $KUBE_EDITOR

Patch a single field without opening an editor:

kubectl patch deployment myapp -p '{"spec":{"replicas":5}}'
kubectl set image deployment/myapp nginx=nginx:1.25
kubectl set resources deployment myapp -c=nginx --limits=cpu=200m,memory=512Mi

Check all resources in a namespace quickly:

kubectl get all -n <namespace>

Get YAML of existing resource (to understand what's there):

kubectl get pod <name> -o yaml
kubectl get deployment <name> -o yaml

kubectl explain - use instead of searching docs for field names:

kubectl explain pod.spec.containers.livenessProbe
kubectl explain deployment.spec.strategy
kubectl explain cronjob.spec.jobTemplate.spec


What CKAD Exam Questions Actually Look Like

Understanding the format helps you read questions efficiently:

  • "Create a Pod named web in namespace app using image nginx:1.25 that uses ConfigMap web-config to set env var APP_MODE from key mode."
  • "A deployment frontend is failing. Find and fix the issue." (Usually: wrong image, bad probe, wrong resource limits)
  • "Update the deployment api to use a rolling update strategy with maxSurge=2 and maxUnavailable=1."
  • "Create a Job data-export that runs 5 completions with max 2 parallel."
  • "The pod app should not be able to use more than 200m CPU. Set appropriate limits."

Pattern for every question: read carefully, identify what resource/namespace, generate or edit YAML, apply, verify.


Common CKAD Mistakes

  1. Wrong namespace - most questions specify one. Always use -n <ns> or --namespace.
  2. restartPolicy: Always on a Job - Jobs need Never or OnFailure. This is the most common Job mistake.
  3. Forgetting selector in a Service - the selector must match the pod labels.
  4. Missing containerPort - doesn't break functionality (it's informational only) but some exam graders check for it.
  5. ConfigMap/Secret key case mismatch - DB_HOST vs db_host - exact match required.
  6. Liveness probe too aggressive - failureThreshold: 1 with initialDelaySeconds: 0 will restart a healthy pod before it finishes starting.
  7. Not verifying - spend 15 seconds confirming the resource exists and has the right spec.

Practice Approach

  1. Use Killer.sh - the free simulator included with your exam purchase. It's harder than the real exam and gives you a realistic time constraint.
  2. Practice generating YAMLs from scratch without the docs - the exam has the docs available, but looking things up takes time. Know the core manifest patterns cold.
  3. Time constraint drills - give yourself 2 minutes per task. If you can't do it in 2 minutes, practice that scenario until you can.
  4. Deliberately practice the annoying parts: multi-container pods, projection volumes, complex probe configurations, CronJob schedules.

No affiliate relationship: the tools and courses recommended on this page are linked plainly and earn this site nothing.


The Learn pages that map to each CKAD domain:

Also: Learning Paths - the track that leads here · Certification Preparation · kubectl Cheat Sheet