Containers changed the threat model for application security. A container isn’t a VM—the kernel is shared with the host, and container escapes, while not trivial, are a real attack class. Securing containers requires thinking about security at build time, image time, and runtime, with each layer complementing the others.
Layer 1: Secure Dockerfile Practices
Security starts in the Dockerfile. Every decision you make here affects the attack surface of every container you run.
Don’t Run as Root
The most common container security mistake: running as root inside the container. A process running as root inside a container that escapes the container context is root on the host. Always specify a non-root user:
FROM eclipse-temurin:21-jre-alpine
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy application
COPY --chown=appuser:appgroup target/app.jar /app/app.jar
# Switch to non-root user
USER appuser
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Use Minimal Base Images
Every package in your base image is potential attack surface. Use minimal base images:
# Unnecessary bloat
FROM ubuntu:22.04
# Better - JRE only
FROM eclipse-temurin:21-jre-alpine
# Best for statically compiled apps (Go, Rust)
FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]
Alpine Linux is a common choice—it’s small (~5MB), uses musl libc, and has minimal pre-installed packages. Distroless images (from Google) go further: no shell, no package manager, only the runtime needed for your language.
Multi-Stage Builds
Build tools should never be in your production image:
# Build stage
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# Production stage - only JRE, no Maven
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/target/app.jar /app/app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
The production image contains only what’s needed to run: the JRE and your application JAR. No Maven, no source code, no build tools.
Pin Base Image Tags
Never use mutable tags:
# Bad - who knows what this is today
FROM ubuntu:latest
# Bad - still mutable
FROM nginx:1.25
# Good - immutable by digest (get the real digest with: docker manifest inspect nginx:1.25)
FROM nginx:1.25@sha256:<insert-actual-sha256-digest-here>
Pinning by digest ensures you’re running exactly the image you tested, not whatever 1.25 points to today.
Don’t Store Secrets in Images
Never bake secrets into images. No .env files, no hardcoded credentials:
# Never do this
ENV API_KEY=my-secret-api-key
COPY .env /app/.env
Secrets belong in Kubernetes Secrets (properly managed with SOPS or External Secrets), injected at runtime.
Layer 2: Kubernetes Security Context
Even with a secure image, the Kubernetes securityContext determines what privileges the container actually has at runtime.
Pod Security Context
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: my-app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
Breaking down what each field does:
runAsNonRoot: true: Kubernetes admission controller refuses to run the container if it would run as root. Belt-and-suspenders over the Dockerfile USER directive.
allowPrivilegeEscalation: false: Prevents the setuid bit on executables from being used to gain higher privileges. Critical for preventing privilege escalation inside the container.
readOnlyRootFilesystem: true: The container’s root filesystem is mounted read-only. Malware can’t write new executables to the filesystem. Applications that need to write use explicitly mounted volumes:
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
capabilities: drop: [ALL]: Linux capabilities are fine-grained privileges. Dropping ALL removes every privilege the container would have by default. Only add back what’s explicitly needed. Most applications need no capabilities at all.
seccompProfile: RuntimeDefault: Restricts the system calls available to the container using the container runtime’s default seccomp profile. This significantly reduces the syscall attack surface.
Layer 3: Pod Security Standards
Kubernetes 1.25+ ships with Pod Security Standards (PSS), replacing the deprecated PodSecurityPolicy. Three levels:
Privileged: No restrictions. Don’t use in production.
Baseline: Prevents the most obvious escalations. Allows running as non-root.
Restricted: Heavily restricted. Requires running as non-root, dropping capabilities, read-only root filesystem.
Enforce PSS per namespace:
apiVersion: v1
kind: Namespace
metadata:
name: my-app
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Applications must meet the “restricted” profile to deploy in this namespace. This is enforced by the Kubernetes API server before the pod is scheduled.
Layer 4: Image Signing and Verification
Container supply chain attacks (like the SolarWinds incident but for container images) are a real threat. An attacker who compromises your CI system can push malicious images to your registry. Image signing creates a cryptographic chain of trust:
# Sign an image with cosign
cosign sign --key cosign.key myregistry.example.com/myapp:1.4.2
# Verify before deploying
cosign verify --key cosign.pub myregistry.example.com/myapp:1.4.2
Kyverno can enforce that only signed images run in your cluster:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: enforce
rules:
- name: check-image-signature
match:
resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "myregistry.example.com/myapp:*"
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
<your cosign public key>
-----END PUBLIC KEY-----
If someone tries to deploy an unsigned or tampered image from your registry, Kyverno blocks it at admission.
Layer 5: Runtime Security with Falco
Even with all the above, containers can be compromised. Runtime security detects malicious behavior in running containers:
Falco is a CNCF project that monitors system calls and raises alerts on suspicious behavior:
# Custom Falco rule
- rule: Shell Spawned in Container
desc: Detect shell spawned in a running container
condition: spawned_process and container and shell_procs and proc.pname = "java"
output: >
Shell spawned in java container
(user=%user.name container=%container.name
image=%container.image.repository command=%proc.cmdline)
priority: WARNING
Common detections: unexpected outbound connections, new binary execution, privileged container operations, file read on sensitive paths (/etc/shadow, /etc/kubernetes/admin.conf).
The Security Checklist
For every containerized application:
- Non-root user in Dockerfile
- Minimal base image (Alpine or distroless)
- Multi-stage build (build tools not in production image)
- Base image pinned by digest
- No secrets in image
allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities: drop: [ALL]runAsNonRoot: trueseccompProfile: RuntimeDefault- Namespace enforcing
restrictedPSS - Image signed and signature verification enforced
- Image scanned for CVEs (Trivy or similar)
- Falco or equivalent runtime security deployed
None of these individually is sufficient. Together, they create defense in depth that requires attackers to chain multiple exploits to achieve meaningful impact. That’s the goal.