Lesson  in  DevSecOps in Practice: Container & Kubernetes Security

Your First Deployment — Security Defaults from Day One

Most tutorials deploy nginx as root with no resource limits and call it done. This lab does it the right way from the start: non-root container, read-only filesystem, resource limits, and a proper Service for exposure. You will also learn the core kubectl commands that every Kubernetes operator uses daily.

The Naive Deployment — What Most Tutorials Teach

What Is Kubernetes, Actually?

Kubernetes is a container orchestrator — it runs your containers across a cluster of machines, restarts them when they crash, and routes traffic to healthy instances. Think of it as a data centre operating system where the unit of deployment is a container, not a binary.

The three objects you will use constantly:

ObjectWhat it is
PodThe smallest deployable unit — one or more containers that share a network and storage
DeploymentDeclares the desired state: "I want 3 replicas of this Pod, always"
ServiceA stable network endpoint that load-balances across all matching Pods

Core kubectl Commands

Before deploying anything, familiarise yourself with the commands you will use throughout this course:

# See all nodes in the cluster
kubectl get nodes

# List pods in the default namespace
kubectl get pods

# List pods in ALL namespaces
kubectl get pods -A

# Describe a resource in detail (great for debugging)
kubectl describe pod <pod-name>

# Watch resources update in real time
kubectl get pods -w

# View logs from a container
kubectl logs <pod-name>

# Open a shell inside a running container
kubectl exec -it <pod-name> -- sh

Create a Namespace

Namespaces are Kubernetes's way of creating isolated tenants within a cluster. Every production workload should live in its own namespace — not default.

kubectl create namespace demo

Deploy the Naive Way

Here is how most tutorials deploy nginx. Run it, then we will audit its problems:

cat > naive-pod.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: naive-nginx
  namespace: demo
spec:
  containers:
  - name: nginx
    image: nginx:latest
EOF
kubectl apply -f naive-pod.yaml
kubectl wait --for=condition=Ready pod/naive-nginx -n demo --timeout=60s

Audit the Naive Pod

# Who is this container running as?
kubectl exec -n demo naive-nginx -- id

# What resource limits does it have?
kubectl get pod naive-nginx -n demo -o jsonpath='{.spec.containers[0].resources}' | python3 -m json.tool

# What image tag are we using?
kubectl get pod naive-nginx -n demo -o jsonpath='{.spec.containers[0].image}'

Expected results — all bad:

uid=0(root) gid=0(root) groups=0(root)
{}
nginx:latest

Three problems:

  1. Root user — if nginx is compromised, the attacker has root inside the container
  2. No resource limits — this pod can consume all CPU and memory on the node (next lab)
  3. latest taglatest is a moving target; today's build ≠ tomorrow's build, making deployments non-reproducible and vulnerable to supply-chain attacks

The Secure Deployment — Security as the Default

What Changes and Why

The secure deployment fixes all three problems from the previous unit — and adds a fourth improvement: a Deployment instead of a bare Pod. Bare pods are not restarted if the node fails; a Deployment maintains the desired replica count automatically.

ProblemFix
Runs as rootrunAsNonRoot: true, runAsUser: 101
No resource limitsresources.limits and resources.requests
latest image tagPinned tag: nginxinc/nginx-unprivileged:1.25-alpine
Single pod, no HADeployment with 3 replicas

Why nginx-unprivileged and not public.ecr.aws/nginx/nginx:1.25-alpine?

public.ecr.aws/nginx/nginx:1.25-alpine binds port 80 on startup. Ports below 1024 are privileged — the kernel blocks non-root processes from binding them. It also writes to /etc/nginx/conf.d/ at startup, which a read-only filesystem blocks.

nginxinc/nginx-unprivileged is the official nginx image built for non-root operation: it listens on port 8080 and writes only to paths that work with a read-only root filesystem. Same nginx, same performance — just designed to run safely.

Deploy Securely

cat > secure-deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secure-app
  namespace: demo
  labels:
    app: secure-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: secure-app
  template:
    metadata:
      labels:
        app: secure-app
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: nginx
        image: nginxinc/nginx-unprivileged:1.25-alpine
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: [ALL]
        resources:
          requests:
            cpu: "100m"
            memory: "64Mi"
          limits:
            cpu: "200m"
            memory: "128Mi"
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: cache
          mountPath: /var/cache/nginx
        - name: run
          mountPath: /var/run
      volumes:
      - name: tmp
        emptyDir: {}
      - name: cache
        emptyDir: {}
      - name: run
        emptyDir: {}
EOF
kubectl apply -f secure-deployment.yaml
kubectl rollout status deployment/secure-app -n demo --timeout=90s

Verify the Deployment

# Check all 3 replicas are ready
kubectl get deployment secure-app -n demo

# Confirm non-root
kubectl exec -n demo \
  $(kubectl get pods -n demo -l app=secure-app -o jsonpath='{.items[0].metadata.name}') \
  -- id

# Confirm read-only filesystem
kubectl exec -n demo \
  $(kubectl get pods -n demo -l app=secure-app -o jsonpath='{.items[0].metadata.name}') \
  -- sh -c "echo test > /etc/test.txt" 2>&1

Expected:

NAME         READY   UP-TO-DATE   AVAILABLE
secure-app   3/3     3            3

uid=101 gid=101 groups=101

sh: can't create /etc/test.txt: Read-only file system

Expose with a Service

A Service gives the Deployment a stable IP and DNS name inside the cluster:

cat > secure-service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: secure-app-svc
  namespace: demo
spec:
  selector:
    app: secure-app
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP
EOF
kubectl apply -f secure-service.yaml

# Get the ClusterIP
kubectl get svc secure-app-svc -n demo

# Test the connection from inside the cluster
# Service listens on port 80 and forwards to the pod's port 8080
kubectl run curl-test --image=curlimages/curl:8.5.0 -it --rm --restart=Never \
  -- curl -sf http://secure-app-svc.demo.svc.cluster.local

Expected: HTML from nginx.

The selector: app: secure-app ties the Service to all pods with that label — as pods are added or replaced, they automatically join or leave the load-balancing pool.

What You Achieved

Naive PodSecure Deployment
Userroot (UID 0)UID 101 (nginx-unprivileged)
Imagenginx:latest (root, port 80)nginxinc/nginx-unprivileged:1.25-alpine (non-root, port 8080)
Resource limitsNoneCPU 200m / Memory 128Mi
Replicas13
FilesystemRead-writeRead-only
HA on node failureNo (pod lost)Yes (Deployment reschedules)

Cleanup

kubectl delete pod naive-nginx -n demo
kubectl delete deployment secure-app -n demo
kubectl delete service secure-app-svc -n demo