Your First Deployment — Security Defaults from Day One
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:
| Object | What it is |
|---|---|
| Pod | The smallest deployable unit — one or more containers that share a network and storage |
| Deployment | Declares the desired state: "I want 3 replicas of this Pod, always" |
| Service | A 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:
- Root user — if nginx is compromised, the attacker has root inside the container
- No resource limits — this pod can consume all CPU and memory on the node (next lab)
latesttag —latestis 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.
| Problem | Fix |
|---|---|
| Runs as root | runAsNonRoot: true, runAsUser: 101 |
| No resource limits | resources.limits and resources.requests |
latest image tag | Pinned tag: nginxinc/nginx-unprivileged:1.25-alpine |
| Single pod, no HA | Deployment 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 Pod | Secure Deployment | |
|---|---|---|
| User | root (UID 0) | UID 101 (nginx-unprivileged) |
| Image | nginx:latest (root, port 80) | nginxinc/nginx-unprivileged:1.25-alpine (non-root, port 8080) |
| Resource limits | None | CPU 200m / Memory 128Mi |
| Replicas | 1 | 3 |
| Filesystem | Read-write | Read-only |
| HA on node failure | No (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
- Previous lesson
- Trivy: CVE Scanning & SBOM
- Next lesson
- Resource Limits & Denial-of-Service Prevention