Why default containers are too permissive
A container is not a virtual machine. It is a process that shares the host kernel, wrapped in namespaces and limited by cgroups. Out of the box, many containers run as root, keep a set of Linux capabilities the application never uses, can write anywhere in their filesystem and can gain more privileges at run time. None of that is needed to serve an API, and all of it helps an attacker who finds a remote code execution bug in your application.
Hardening is the practice of removing those unneeded powers. It is the security half of our Docker containerisation service and carries over directly to Kubernetes consulting.
What good looks like
For a typical stateless service, the baseline we aim for is:
- Runs as a non-root user with a fixed numeric user id.
allowPrivilegeEscalationis false.- All capabilities are dropped, with specific ones added only when justified.
- The runtime default seccomp profile is applied.
- The root filesystem is read-only, with explicit writable volumes for temp data.
- No privileged mode, no host networking, no host path mounts.
- Resource requests and limits are set, so one container cannot starve a node.
Step one: the image
Hardening starts in the Dockerfile, because the image decides the user and file ownership:
FROM debian:bookworm-slim
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home app
WORKDIR /app
COPY --chown=10001:10001 --from=build /out/app /app/app
USER 10001:10001
ENTRYPOINT ["/app/app"]
Use a numeric id so Kubernetes can verify runAsNonRoot without resolving names. Avoid installing a shell, curl or a package manager in the final stage unless a requirement says otherwise: an attacker uses what they find.
Step two: the runtime spec
In Kubernetes the same intent is expressed in the pod spec, so it is enforced even if someone builds a careless image:
apiVersion: v1
kind: Pod
metadata:
name: api
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.example.com/platform/api:1.4.2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
The equivalent for plain Docker uses --user, --cap-drop=ALL, --read-only, --security-opt no-new-privileges and a tmpfs mount for writable paths.
Enforcing it
Settings that rely on every developer remembering them will eventually be forgotten. We enforce the baseline at admission time. Kubernetes Pod Security Admission can apply the restricted profile per namespace, and policy engines such as Kyverno or OPA Gatekeeper add custom rules, such as requiring resource limits or blocking images from unapproved registries. Combine this with network policies and RBAC so that a compromised pod has little to talk to and little authority.
Decisions and trade-offs
Rootless runtime or just non-root containers? Running the container runtime itself rootless adds protection against runtime bugs but is more complex to operate. For most teams, hardening the workloads comes first.
Distroless or a normal base? A distroless image removes shells and package managers, reducing what an attacker can use. The cost is harder debugging, which you handle with ephemeral debug containers. See base image standardisation.
Custom seccomp or the runtime default? The runtime default is the right start. Custom profiles are justified for high-risk workloads and need care, because an overly tight profile fails in surprising ways.
When to allow exceptions. Some tools legitimately need more: a node agent that reads host metrics, or a CNI plugin. Put them in a dedicated namespace with explicit approval rather than weakening the baseline for everyone.
Rolling it out without breaking production
Turning on every setting at once is how hardening earns a bad reputation. We stage it. First, the restricted profile runs in warn mode, so Kubernetes reports violations without blocking anything. Then audit mode records them in the audit log. Only after owners have fixed or excepted their workloads do we switch to enforce, and we do it namespace by namespace. In Pod Security Admission, this is a matter of labelling a namespace with the warn, audit and enforce modes at the restricted level:
kubectl label namespace payments \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
Once the warnings stop, add the enforce label. Because the label change is the whole rollout, going back is as quick as removing it.
What hardening does not cover
A hardened container still runs on a node, and a vulnerable kernel or a permissive node role can undermine it. The settings above also do nothing about a vulnerable dependency inside your application, so scanning stays necessary. If your clusters are on a managed service, check which node-level controls the provider handles and which remain yours. For EKS specifically, that boundary is covered in our EKS consulting work.
A note on sidecars and third-party charts
Hardening your own services is the easy part. Vendor Helm charts often ship with root users, writable filesystems or broad capabilities, and service mesh sidecars and init containers may need extra privileges to set up networking. We review each third-party chart, override the values where the chart allows it, and record an exception where it does not. Skipping this step leaves the most privileged workloads in the cluster outside the policy you just wrote.
Checking your own estate quickly
Before any engagement you can get a rough picture yourself. List pods whose security context lacks runAsNonRoot, count containers that do not drop capabilities, and look for privileged: true or hostPath volumes. Open-source tools such as kube-bench and Trivy’s Kubernetes misconfiguration scanning can automate part of this. The results tell you whether hardening is a small clean-up or a larger programme.
Common failure modes
- Writable-path crashes after enabling a read-only filesystem. Fix by mounting
emptyDirvolumes at the paths the application writes. - Binding to port 80 or 443 as non-root. Listen on a high port and let the Service map it.
- Volume permission errors. Use
fsGroupor fix ownership at build time. - Quiet drift as new services copy old manifests. Admission policy catches it.
- Hardened pods, powerful service accounts. Cloud credentials attached to a pod can undo all the isolation. Scope them tightly.
Delivery and handover
We deliver hardened Dockerfile templates, pod spec baselines (or Helm chart defaults), admission policies and a testing checklist. Findings from image scanning and configuration checks feed the same backlog.
How to tell it is working
A scan of the cluster shows no privileged or root containers outside documented exceptions, new deployments that break the baseline are rejected with a clear message, and a penetration test or tabletop exercise finds a compromised pod with very little to work with. To scope a hardening review, book a consultation.