Kubernetes pod security: non-root, read-only rootfs, dropped caps
For developers hardening Kubernetes workloads without breaking startup. You’ll apply a working securityContext that runs as non-root, mounts writable paths explicitly, drops Linux capabilities, and verify the pod still starts and serves traffic.
TL;DR — To harden a Kubernetes workload, set
runAsNonRoot: true, use a numeric non-root UID/GID, setreadOnlyRootFilesystem: true, and drop all capabilities withcapabilities.drop: ["ALL"]. The most common failure is an app trying to write under/tmp,/var/run, or its working directory; fix that by mounting anemptyDironly where writes are actually needed. Reading time: ~5 min
Goal
When you finish, your Deployment will run containers as a non-root user, use a read-only root filesystem, run with all Linux capabilities dropped, and still start successfully because every required writable path is mounted explicitly.
Prerequisites
- Kubernetes cluster access with permission to create/update Deployments, Pods, and Services in a namespace — check with:
kubectl auth can-i create deployment -n your-namespace
kubectl auth can-i patch deployment -n your-namespace
kubectlinstalled and pointed at the right cluster — check with:
kubectl version --client
kubectl config current-context
- A container image that can run as a non-root numeric UID. If the image hardcodes startup work under
/rootor requires privileged ports, expect changes. - Your app’s writable paths at hand: common ones are
/tmp,/var/run,/var/cache, framework upload dirs, and log dirs if the app does not log to stdout. - A namespace and deployment name to modify, used below as
your-namespaceandyour-app.
Steps
Step 1: Inspect the current pod user, ports, and write behavior
Run a pod from the current Deployment and inspect what it is doing before changing security settings.
kubectl -n your-namespace get deploy your-app -o wide
kubectl -n your-namespace get pods -l app=your-app -o name
kubectl -n your-namespace logs deploy/your-app --tail=100
kubectl -n your-namespace exec deploy/your-app -- sh -c 'id; pwd; ss -lnt 2>/dev/null || netstat -lnt 2>/dev/null || true; touch /tmp/test-write && echo ok-tmp || echo fail-tmp'
What you should see when this succeeds: output includes the current UID/GID, listening ports, and whether /tmp is writable.
Step 2: Patch the Deployment with a safe baseline securityContext
Apply a baseline that works for most web/API containers: non-root numeric UID/GID, no privilege escalation, read-only rootfs, and all capabilities dropped.
⚠️ This can cause a rollout failure if the image writes to the root filesystem or binds to a privileged port like
80or443. If this is production, do this in a staging namespace first or setmaxUnavailable: 0on the Deployment strategy before rollout.
kubectl -n your-namespace patch deployment your-app --type='merge' -p '
{
"spec": {
"template": {
"spec": {
"securityContext": {
"runAsNonRoot": true,
"runAsUser": 10001,
"runAsGroup": 10001,
"fsGroup": 10001,
"seccompProfile": {
"type": "RuntimeDefault"
}
},
"containers": [
{
"name": "your-app",
"securityContext": {
"allowPrivilegeEscalation": false,
"readOnlyRootFilesystem": true,
"capabilities": {
"drop": ["ALL"]
}
}
}
]
}
}
}
}'
What you should see when this succeeds: deployment.apps/your-app patched.
Step 3: Add explicit writable mounts for /tmp and /var/run
Most apps fail here because they assume these paths are writable. Mount emptyDir only where needed.
kubectl -n your-namespace patch deployment your-app --type='merge' -p '
{
"spec": {
"template": {
"spec": {
"volumes": [
{"name": "tmp", "emptyDir": {}},
{"name": "run", "emptyDir": {}}
],
"containers": [
{
"name": "your-app",
"volumeMounts": [
{"name": "tmp", "mountPath": "/tmp"},
{"name": "run", "mountPath": "/var/run"}
]
}
]
}
}
}
}'
What you should see when this succeeds: deployment.apps/your-app patched.
Step 4: If the app listens on port 80 or 443, move it to an unprivileged port
With all capabilities dropped, binding to ports below 1024 will fail. Change the container port and Service targetPort to 8080 or 8443.
kubectl -n your-namespace patch deployment your-app --type='json' -p='[
{"op":"replace","path":"/spec/template/spec/containers/0/ports/0/containerPort","value":8080}
]'
kubectl -n your-namespace patch service your-app --type='json' -p='[
{"op":"replace","path":"/spec/ports/0/targetPort","value":8080}
]'
What you should see when this succeeds: both resources report patched.
Step 5: Watch the rollout and inspect exact startup errors if it fails
Do not guess. Wait for the rollout, then read the events and logs.
kubectl -n your-namespace rollout status deployment/your-app --timeout=120s
kubectl -n your-namespace get pods -l app=your-app
kubectl -n your-namespace describe pods -l app=your-app | sed -n '/Events:/,$p'
kubectl -n your-namespace logs deploy/your-app --previous --tail=100 || true
kubectl -n your-namespace logs deploy/your-app --tail=100
What you should see when this succeeds: deployment "your-app" successfully rolled out and new pods in Running state.
Step 6: If your app writes elsewhere, mount only that path
Add one more emptyDir or PVC mount for the exact write path shown in logs. Example for an app writing uploads to /app/data.
kubectl -n your-namespace patch deployment your-app --type='merge' -p '
{
"spec": {
"template": {
"spec": {
"volumes": [
{"name": "app-data", "emptyDir": {}}
],
"containers": [
{
"name": "your-app",
"volumeMounts": [
{"name": "app-data", "mountPath": "/app/data"}
]
}
]
}
}
}
}'
What you should see when this succeeds: the next rollout completes and the previous Read-only file system error disappears.
Verify it works
Run these checks end to end.
kubectl -n your-namespace get pod -l app=your-app -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
POD=$(kubectl -n your-namespace get pod -l app=your-app -o jsonpath='{.items[0].metadata.name}')
kubectl -n your-namespace exec "$POD" -- sh -c 'id; cat /proc/1/status | grep -E "^Uid|^Gid|^CapEff"; touch /tmp/ok && echo tmp-write-ok; touch /root/should-fail || true'
kubectl -n your-namespace get pod "$POD" -o jsonpath='{.spec.securityContext.runAsNonRoot}{"\n"}{.spec.containers[0].securityContext.readOnlyRootFilesystem}{"\n"}{.spec.containers[0].securityContext.allowPrivilegeEscalation}{"\n"}{.spec.containers[0].securityContext.capabilities.drop}{"\n"}'
kubectl -n your-namespace port-forward deploy/your-app 8080:8080
In another terminal:
curl -i http://127.0.0.1:8080/
Expected results:
uid=10001 gid=10001 groups=10001
Uid:\t10001\t10001\t10001\t10001
Gid:\t10001\t10001\t10001\t10001
CapEff:\t0000000000000000
tmp-write-ok
touch: /root/should-fail: Read-only file system
true
true
false
[ALL]
And curl should return your normal app response, for example:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1234
Common pitfalls
The image still starts as root internally
Mistake: setting runAsNonRoot: true without a numeric runAsUser, while the image metadata defaults to root.
Symptom: pod stays in CreateContainerConfigError or CrashLoopBackOff, and events show:
Error: container has runAsNonRoot and image will run as root
Fix: set a numeric UID explicitly, for example runAsUser: 10001 and runAsGroup: 10001.
The app writes to the root filesystem
Mistake: enabling readOnlyRootFilesystem: true without mounting writable paths.
Symptom: logs show one of these:
open /tmp/app.sock: read-only file system
mkdir /var/cache/app: read-only file system
panic: open /app/data/state.db: read-only file system
Fix: mount emptyDir or a PVC at the exact failing path, such as /tmp, /var/run, or /app/data.
The app binds to port 80 or 443
Mistake: dropping all capabilities and keeping a privileged listen port.
Symptom: container logs show:
listen tcp :80: bind: permission denied
Fix: change the app and container to listen on 8080 or 8443, and update the Service targetPort.
The container name in the patch does not match
Mistake: patching containers[0] or name your-app when the actual container name is different.
Symptom: patch applies, but the running pod spec does not contain the expected securityContext on the real container.
Fix: get the exact name first:
kubectl -n your-namespace get deploy your-app -o jsonpath='{.spec.template.spec.containers[*].name}{"\n"}'
Then replace "name": "your-app" with the real container name.
emptyDir causes data loss on restart
Mistake: mounting emptyDir for state that must survive pod rescheduling.
Symptom: uploads, caches, or SQLite files disappear after restart or node drain.
Fix: keep emptyDir for scratch paths only; use a PersistentVolumeClaim for durable app data.
fsGroup slows startup on large volumes
Mistake: setting fsGroup on a pod with a large mounted volume and expecting instant startup.
Symptom: pod sits in ContainerCreating longer than usual while ownership is applied.
Fix: if your storage and image already use the target GID, remove fsGroup; otherwise keep it and accept the startup cost, or pre-provision volume permissions outside the pod.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI