CKS — Cluster Setup & Hardening Lab

12 minute read

Lab Overview

This hands-on lab brings together all Cluster Setup topics into a cohesive exercise. You’ll implement the security controls that defend a Kubernetes cluster at the network and platform level — the same controls tested in Domain 1 of the CKS exam.

Estimated time: 45–60 minutes

Prerequisites

Requirement Details
Kubernetes cluster kubeadm, minikube (--cni=calico), or kind with Calico
CNI with NetworkPolicy Calico, Cilium, or Weave Net (Flannel will NOT work)
kubectl Configured and connected to the cluster
openssl Installed on your workstation
curl/wget Available for download verification

Verify Lab Readiness

# Verify cluster is running
kubectl cluster-info

# Verify CNI supports NetworkPolicy
kubectl get pods -n kube-system | grep -E "calico|cilium|weave"

# Verify kubectl is working
kubectl get nodes

What You’ll Practice

  • Create default deny NetworkPolicy for a namespace
  • Create allow-dns-egress policy
  • Block cloud metadata access with NetworkPolicy
  • Create TLS Secret and Ingress with TLS
  • Verify kubectl binary integrity with sha512sum
  • Verify Dashboard access restrictions

Exercise 1: Default Deny NetworkPolicy

Objective: Create a namespace called secure-app and apply a default deny policy for both ingress and egress.

Step 1.1: Create the Namespace

kubectl create namespace secure-app
kubectl label namespace secure-app purpose=lab

Step 1.2: Deploy Test Workloads

# Deploy a web server
kubectl run web --image=nginx --namespace=secure-app --port=80 \
  --labels="app=web,tier=frontend"

# Deploy a backend
kubectl run api --image=nginx --namespace=secure-app --port=8080 \
  --labels="app=api,tier=backend"

# Wait for Pods to be ready
kubectl wait --for=condition=ready pod/web -n secure-app --timeout=60s
kubectl wait --for=condition=ready pod/api -n secure-app --timeout=60s

# Verify Pods are running
kubectl get pods -n secure-app -o wide

Expected output:

NAME   READY   STATUS    RESTARTS   AGE   IP            NODE
api    1/1     Running   0          15s   10.244.1.12   node01
web    1/1     Running   0          20s   10.244.1.11   node01

Step 1.3: Verify Unrestricted Communication (Before Policy)

# Get the web Pod IP
WEB_IP=$(kubectl get pod web -n secure-app -o jsonpath='{.status.podIP}')

# Test: api can reach web (should succeed — no policies yet)
kubectl exec -n secure-app api -- wget --timeout=3 -q -O- http://${WEB_IP}

You should see the NGINX welcome page HTML.

Step 1.4: Apply Default Deny Ingress and Egress

Create a file default-deny-all.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: secure-app
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-egress
  namespace: secure-app
spec:
  podSelector: {}
  policyTypes:
    - Egress
kubectl apply -f default-deny-all.yaml

Step 1.5: Verify Policies Are Enforced

# Test: api can NO LONGER reach web (ingress denied)
kubectl exec -n secure-app api -- wget --timeout=3 -q -O- http://${WEB_IP}
# Expected: wget: download timed out (command exits with error)

# Test: web can NO LONGER reach external sites (egress denied)
kubectl exec -n secure-app web -- wget --timeout=3 -q -O- http://example.com
# Expected: wget: bad address 'example.com' (DNS blocked)

Expected Result

NAME                      POD-SELECTOR   AGE
default-deny-egress       <none>         10s
default-deny-ingress      <none>         10s

Both ingress and egress are denied for all Pods in secure-app.


Exercise 2: Allow DNS Egress

Objective: Re-enable DNS resolution while keeping all other egress blocked.

Step 2.1: Apply DNS Egress Policy

Create a file allow-dns.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: secure-app
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
kubectl apply -f allow-dns.yaml

Step 2.2: Verify DNS Works

# DNS should now resolve
kubectl exec -n secure-app web -- nslookup kubernetes.default

Expected output:

Server:    10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name:      kubernetes.default
Address 1: 10.96.0.1 kubernetes.default.svc.cluster.local

Step 2.3: Verify Other Egress Still Blocked

# HTTP to external should still fail (DNS resolves, but connection blocked)
kubectl exec -n secure-app web -- wget --timeout=3 -q -O- http://example.com
# Expected: wget: download timed out

Expected Result

DNS resolution works. All other egress remains blocked. The Pods can resolve names but cannot connect to those IPs.


Exercise 3: Block Cloud Metadata Access

Objective: Create a NetworkPolicy that explicitly blocks the cloud metadata endpoint while allowing other egress.

Step 3.1: Apply Metadata Blocking Policy

Create a file block-metadata.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-cloud-metadata
  namespace: secure-app
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    # DNS
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # All egress EXCEPT metadata
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32
kubectl apply -f block-metadata.yaml

Step 3.2: Verify Metadata Is Blocked

# Attempt to reach the metadata endpoint (should timeout)
kubectl exec -n secure-app web -- wget --timeout=3 -q -O- http://169.254.169.254/latest/meta-data/
# Expected: wget: download timed out

Step 3.3: Verify Other Egress Works

# Regular external access should now work (because block-metadata allows 0.0.0.0/0 except metadata)
kubectl exec -n secure-app web -- wget --timeout=3 -q -O- http://example.com

You should see the HTML content from example.com. The block-cloud-metadata policy adds an egress rule allowing all IPs except the metadata endpoint.

Step 3.4: Verify All Policies

kubectl get networkpolicy -n secure-app

Expected output:

NAME                     POD-SELECTOR   AGE
allow-dns-egress         <none>         5m
block-cloud-metadata     <none>         30s
default-deny-egress      <none>         8m
default-deny-ingress     <none>         8m

Exercise 4: Create TLS Secret and Ingress

Objective: Generate a TLS certificate, create a Kubernetes Secret, and configure an Ingress with TLS termination.

Step 4.1: Generate a Self-Signed Certificate

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout tls.key \
  -out tls.crt \
  -subj "/CN=secure-app.example.com/O=lab"

Step 4.2: Create the TLS Secret

kubectl create secret tls secure-app-tls \
  --cert=tls.crt \
  --key=tls.key \
  --namespace=secure-app

Step 4.3: Verify the Secret

kubectl get secret secure-app-tls -n secure-app

Expected output:

NAME              TYPE                DATA   AGE
secure-app-tls    kubernetes.io/tls   2      5s
# Check the Secret details
kubectl describe secret secure-app-tls -n secure-app

Expected output:

Name:         secure-app-tls
Namespace:    secure-app
Type:         kubernetes.io/tls

Data
====
tls.crt:  1164 bytes
tls.key:  1704 bytes

Step 4.4: Create a Service for the Web Pod

kubectl expose pod web --port=80 --name=web-service --namespace=secure-app

Step 4.5: Create the Ingress with TLS

Create a file ingress-tls.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: secure-app-ingress
  namespace: secure-app
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  tls:
    - hosts:
        - secure-app.example.com
      secretName: secure-app-tls
  rules:
    - host: secure-app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-service
                port:
                  number: 80
kubectl apply -f ingress-tls.yaml

Step 4.6: Verify the Ingress

kubectl get ingress -n secure-app

Expected output:

NAME                  CLASS   HOSTS                      ADDRESS   PORTS     AGE
secure-app-ingress    nginx   secure-app.example.com               80, 443   5s
kubectl describe ingress secure-app-ingress -n secure-app

Expected output includes:

TLS:
  secure-app-tls terminates secure-app.example.com
Rules:
  Host                      Path  Backends
  ----                      ----  --------
  secure-app.example.com    /     web-service:80 (10.244.1.11:80)

Expected Result

The Ingress is configured with TLS termination using the self-signed certificate. HTTPS traffic to secure-app.example.com will be decrypted at the Ingress controller and forwarded to the backend as HTTP.


Exercise 5: Verify kubectl Binary Integrity

Objective: Verify that the kubectl binary on your workstation hasn’t been tampered with.

Step 5.1: Get the Installed Version

KUBECTL_VERSION=$(kubectl version --client -o json 2>/dev/null | grep gitVersion | awk -F'"' '{print $4}')
echo "kubectl version: $KUBECTL_VERSION"

Step 5.2: Download the Official Checksum

# Download the sha256 checksum for the installed version
curl -sLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
echo "Official sha256: $(cat kubectl.sha256)"

Step 5.3: Compute the Hash of the Installed Binary

# Find kubectl location
KUBECTL_PATH=$(which kubectl)
echo "kubectl path: $KUBECTL_PATH"

# Compute its sha256
sha256sum "$KUBECTL_PATH"

Step 5.4: Compare Hashes

INSTALLED_HASH=$(sha256sum "$KUBECTL_PATH" | awk '{print $1}')
OFFICIAL_HASH=$(cat kubectl.sha256)

echo "Installed: $INSTALLED_HASH"
echo "Official:  $OFFICIAL_HASH"

if [[ "$INSTALLED_HASH" == "$OFFICIAL_HASH" ]]; then
  echo "PASS: kubectl binary is authentic"
else
  echo "FAIL: kubectl binary hash mismatch — possible tampering!"
fi

Step 5.5: Verify Using sha512sum

# Compute sha512 of the installed binary
sha512sum "$KUBECTL_PATH"

# The exam may give you a sha512 hash and ask you to compare
# The process is identical to sha256 verification

Expected Result

The hashes match, confirming the binary is authentic. If they don’t match, the binary has been modified.

Note: On macOS, use shasum -a 256 instead of sha256sum. On the CKS exam (Linux), sha256sum is available.


Exercise 6: Verify Dashboard Access Restrictions

Objective: If the Kubernetes Dashboard is installed, verify it has proper access controls. If not installed, create the RBAC resources that would restrict it.

Step 6.1: Check if Dashboard Is Installed

kubectl get namespace kubernetes-dashboard 2>/dev/null
kubectl get pods -n kubernetes-dashboard 2>/dev/null

Step 6.2: If Dashboard Is Installed — Audit It

# Check the Service type (should be ClusterIP)
kubectl get svc -n kubernetes-dashboard
# If NodePort or LoadBalancer → SECURITY RISK

# Check RBAC bindings
kubectl get clusterrolebinding | grep dashboard

# Check if cluster-admin is bound
kubectl get clusterrolebinding -o json | \
  jq '.items[] | select(.roleRef.name=="cluster-admin") | 
  select(.subjects[]?.namespace=="kubernetes-dashboard") | .metadata.name'

Step 6.3: If Dashboard Is NOT Installed — Create Restricted RBAC

Create a file dashboard-rbac.yaml:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: dashboard-readonly
  namespace: secure-app
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: dashboard-viewer-lab
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "namespaces", "events"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: dashboard-viewer-lab-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: dashboard-viewer-lab
subjects:
  - kind: ServiceAccount
    name: dashboard-readonly
    namespace: secure-app
kubectl apply -f dashboard-rbac.yaml

Step 6.4: Verify RBAC Restrictions

# Should be able to list Pods (read-only)
kubectl auth can-i list pods \
  --as=system:serviceaccount:secure-app:dashboard-readonly
# Expected: yes

# Should NOT be able to delete Pods
kubectl auth can-i delete pods \
  --as=system:serviceaccount:secure-app:dashboard-readonly
# Expected: no

# Should NOT be able to get Secrets
kubectl auth can-i get secrets \
  --as=system:serviceaccount:secure-app:dashboard-readonly
# Expected: no

# Should NOT be able to exec into Pods
kubectl auth can-i create pods/exec \
  --as=system:serviceaccount:secure-app:dashboard-readonly
# Expected: no

# Should NOT be able to create Deployments
kubectl auth can-i create deployments \
  --as=system:serviceaccount:secure-app:dashboard-readonly
# Expected: no

Expected Result

The ServiceAccount has read-only access. It can list and watch resources but cannot create, modify, delete, or access sensitive resources like Secrets.


Lab Summary

Exercise Control Status
1 Default deny ingress & egress Verified — all traffic blocked
2 Allow DNS egress Verified — DNS works, other egress blocked
3 Block metadata endpoint Verified — 169.254.169.254 blocked
4 Ingress TLS Verified — TLS Secret and Ingress configured
5 Binary verification Verified — sha256 hash matches official checksum
6 Dashboard RBAC Verified — read-only access, no secrets/exec

Lab Cleanup

# Delete all lab resources
kubectl delete namespace secure-app

# Delete cluster-scoped resources
kubectl delete clusterrole dashboard-viewer-lab 2>/dev/null
kubectl delete clusterrolebinding dashboard-viewer-lab-binding 2>/dev/null

# Clean up local files
rm -f tls.key tls.crt kubectl.sha256

# Verify cleanup
kubectl get namespace secure-app 2>/dev/null
# Expected: Error from server (NotFound)

Practice Questions


Q1: You enter a CKS exam question that says: “Create a NetworkPolicy in the payments namespace that denies all ingress and egress, then allow only DNS egress.” Write the complete solution.

Show Answer
# Step 1: Default deny all
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
# Step 2: Allow DNS egress
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
# Apply
kubectl apply -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
EOF

# Verify
kubectl get networkpolicy -n payments

Exam speed tip: Use heredoc with kubectl apply -f - to create both policies in one command. This saves time compared to creating separate files.


Q2: During a lab exercise, you applied a default-deny-egress policy and an allow-dns policy, but DNS still doesn’t work. What should you check?

Show Answer

Troubleshooting checklist:

  1. Verify the CNI supports NetworkPolicy:
    kubectl get pods -n kube-system | grep -E "calico|cilium|weave"
    

    If using Flannel or a non-policy CNI, policies have no effect.

  2. Check the policy is in the correct namespace:
    kubectl get networkpolicy -n <your-namespace>
    
  3. Verify the allow-dns policy YAML:
    • Port is 53 (not 5353)
    • Protocols include both UDP and TCP
    • namespaceSelector: {} is present (not a specific label selector)
  4. Check CoreDNS is running:
    kubectl get pods -n kube-system -l k8s-app=kube-dns
    
  5. Check CoreDNS Service:
    kubectl get svc -n kube-system kube-dns
    
  6. Test DNS directly by IP (bypass name resolution):
    kubectl exec -n <namespace> <pod> -- wget --timeout=3 -q -O- http://10.96.0.10:53
    

    If this also fails, the egress policy isn’t allowing port 53.

  7. Check for conflicting policies that might override:
    kubectl get networkpolicy -n <namespace> -o yaml
    

Q3: Write the commands to verify that kubeadm on your CKS exam node is untampered, given that the version is v1.29.2.

Show Answer
# Step 1: Verify the installed version matches expectations
kubeadm version -o short
# Expected: v1.29.2

# Step 2: Compute the hash of the installed binary
sha256sum $(which kubeadm)
# Example: e4f5a6b7c8d9...  /usr/bin/kubeadm

# Step 3: Download the official checksum
curl -sL "https://dl.k8s.io/release/v1.29.2/bin/linux/amd64/kubeadm.sha256" -o kubeadm-official.sha256

# Step 4: Compare
echo "$(cat kubeadm-official.sha256)  $(which kubeadm)" | sha256sum --check
# Expected: /usr/bin/kubeadm: OK

If the check fails: The binary has been modified. Replace it:

curl -LO "https://dl.k8s.io/release/v1.29.2/bin/linux/amd64/kubeadm"
echo "$(cat kubeadm-official.sha256)  kubeadm" | sha256sum --check
chmod +x kubeadm
sudo mv kubeadm /usr/bin/kubeadm

Q4: Create a TLS Secret named api-tls in namespace production for the domain api.company.com, then write an Ingress that uses it. Use one-liners where possible.

Show Answer
# Create namespace
kubectl create namespace production

# Generate cert + key + create Secret in one pipeline
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /tmp/tls.key -out /tmp/tls.crt \
  -subj "/CN=api.company.com" && \
kubectl create secret tls api-tls \
  --cert=/tmp/tls.crt --key=/tmp/tls.key \
  -n production

# Create Ingress
kubectl apply -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: production
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  tls:
    - hosts:
        - api.company.com
      secretName: api-tls
  rules:
    - host: api.company.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 8080
EOF

# Verify
kubectl get secret api-tls -n production
kubectl get ingress api-ingress -n production
kubectl describe ingress api-ingress -n production | grep -A2 "TLS"

Q5: What is the complete list of Security controls you implemented in this lab, and which CKS exam objectives do they map to?

Show Answer
Lab Exercise Security Control CKS Exam Objective
Exercise 1 Default deny ingress & egress Use NetworkPolicy to restrict cluster-level access
Exercise 2 DNS egress allowance Use NetworkPolicy to restrict cluster-level access
Exercise 3 Metadata endpoint blocking Protect node metadata and endpoints
Exercise 4 TLS Secret + Ingress with TLS Properly set up Ingress objects with security control
Exercise 5 Binary hash verification Verify platform binaries before deploying
Exercise 6 Dashboard RBAC restriction Minimize use of, and access to, GUI elements

All six exercises map to Domain 1 — Cluster Setup (10%).

Defense-in-depth layers implemented:

  1. Network layer — Default deny prevents lateral movement (Exercise 1)
  2. Service discovery — DNS is explicitly allowed so workloads function (Exercise 2)
  3. Cloud integration — Metadata endpoint blocked to prevent credential theft (Exercise 3)
  4. Transport encryption — TLS on Ingress prevents traffic interception (Exercise 4)
  5. Supply chain — Binary verification prevents tampered components (Exercise 5)
  6. Access control — GUI restricted to prevent unauthorized cluster management (Exercise 6)

Together, these controls implement a zero-trust network model with verified platform integrity and least-privilege access.


← Binary Verification Cluster Hardening →

Updated: