HOW-TO GUIDE | CLOUD SECURITY
Updated 11 min read

Container Security: Hardening Docker and Kubernetes Workloads in Practice

65%
Of containers deployed in production run as root: a configuration that gives container escape exploits immediate host root access
Privileged
Container security mode that disables all Linux namespace isolation: equivalent to disabling the entire container security model
Trivy
Most widely deployed open-source container image scanner: integrated with GitHub Actions, GitLab CI, and Kubernetes admission controllers
< 5MB
Size of a minimal distroless container base image vs 150MB+ for a full OS image: smaller image = fewer packages = fewer vulnerabilities

SponsoredRetool

Retool's new app builder is where AI-generated code ships safely

Building apps with AI is easy. Getting them to production safely is another story.

Start building for free today

Container security failures follow a predictable pattern: a developer uses a convenient base image running as root, adds the packages they need, and pushes to production. An attacker compromises the application, exploits a container escape vulnerability in a privileged mode flag someone added to fix a permissions issue, and now has host access.

Containers provide security isolation by default: Linux namespaces, cgroups, and capability restrictions. Most container security problems come from explicitly disabling that isolation for convenience.

Docker Image Hardening

1. Run as a non-root user

The most impactful single change in container security:

# Bad: runs as root by default
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

# Good: runs as non-root user
FROM node:20-alpine

# Create a non-root user and group
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup

WORKDIR /app

# Copy with correct ownership
COPY --chown=appuser:appgroup . .
RUN npm ci --production

# Switch to non-root before running
USER appuser

CMD ["node", "server.js"]

2. Use minimal base images

# Instead of full OS images (ubuntu, debian, centos)
# Use minimal alternatives:

# Alpine Linux (~5MB, musl libc)
FROM node:20-alpine

# Distroless: no shell, no package manager, minimal attack surface
FROM gcr.io/distroless/nodejs20-debian12

# Chainguard images: hardened, frequently updated
FROM cgr.dev/chainguard/node:latest

Distroless images do not contain a shell: an attacker who compromises the container cannot run arbitrary commands because no shell binary exists.

3. Multi-stage builds: avoid build tools in production images

# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production: only copy the compiled output
FROM node:20-alpine AS production
RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app
WORKDIR /app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
USER app
CMD ["node", "dist/server.js"]

The production image contains only compiled code and runtime dependencies: not the build toolchain, not source code, not test dependencies.

4. Scan images for vulnerabilities

# Trivy: open source, integrates with CI/CD
trivy image myapp:latest

# Set a severity threshold that fails the build
trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:latest

# Scan a Dockerfile for misconfigurations (non-root, no COPY --chown, etc.)
trivy config ./Dockerfile

# Grype (Anchore): alternative scanner
grype myapp:latest

# Snyk: commercial option with free tier
snyk container test myapp:latest

Docker Runtime Hardening

Never use --privileged mode:

# Bad: disables all container isolation
docker run --privileged myapp:latest

# Good: drop capabilities, add only what is needed
docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid \
  myapp:latest

What these flags do:

  • --cap-drop ALL: Removes all Linux capabilities (file ownership changes, raw sockets, kernel modules, etc.)
  • --cap-add NET_BIND_SERVICE: Adds back only the capability to bind to ports below 1024 (only if needed)
  • --security-opt no-new-privileges:true: Prevents privilege escalation via setuid binaries
  • --read-only: Mounts the container filesystem as read-only: attackers cannot write payloads to disk
  • --tmpfs /tmp: Provides a writable /tmp in RAM only, without exec permission

Linux capability reference for common application needs:

# Net binding (port < 1024): NET_BIND_SERVICE
# File operations: nothing extra (regular file ownership operations work)
# Network raw sockets (ping): NET_RAW
# Setting system clock: SYS_TIME
# Everything else: drop it

# List capabilities a running container is using
capsh --print  # Run inside the container

User namespace remapping (daemon-level protection):

// /etc/docker/daemon.json
{
  "userns-remap": "default",
  "no-new-privileges": true,
  "live-restore": true,
  "icc": false  // Disable inter-container communication by default
}

userns-remap maps the container's root (UID 0) to an unprivileged host UID: even if a container escape occurs, the attacker lands as an unprivileged user on the host.

Free daily briefing

Briefings like this, every morning before 9am.

Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.

Kubernetes Security Contexts and RBAC

Pod security context: apply to all workloads:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp
spec:
  template:
    spec:
      # Pod-level security context
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        seccompProfile:
          type: RuntimeDefault
      
      containers:
      - name: webapp
        image: myapp:1.2.3  # Pinned to digest, not :latest
        
        # Container-level security context
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
            add: []  # Add only what is absolutely needed
        
        # Resource limits: prevents noisy-neighbor DoS
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "256Mi"
            cpu: "500m"
        
        volumeMounts:
        - name: tmp-dir
          mountPath: /tmp
      
      volumes:
      - name: tmp-dir
        emptyDir: {}  # Writable scratch space when readOnlyRootFilesystem is true
      
      automountServiceAccountToken: false  # Disable if not using Kubernetes API

RBAC least privilege: avoid ClusterAdmin:

# Bad: full cluster access
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: myapp-admin
subjects:
- kind: ServiceAccount
  name: myapp
roleRef:
  kind: ClusterRole
  name: cluster-admin  # Never grant this to application service accounts
  apiGroup: rbac.authorization.k8s.io
# Good: namespace-scoped, specific resources only
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: myapp-role
  namespace: production
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["get", "list"]
  resourceNames: ["myapp-config"]  # Only this specific ConfigMap
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get"]
  resourceNames: ["myapp-secret"]

Kubernetes Network Policy: default-deny:

# Default deny all ingress and egress for a namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}  # Applies to all pods in namespace
  policyTypes:
  - Ingress
  - Egress
---
# Allow specific ingress from the ingress controller
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-webapp-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: webapp
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: ingress-nginx
    ports:
    - protocol: TCP
      port: 3000

Kubernetes Admission Control and Image Policy

Use Kyverno or OPA Gatekeeper to enforce policies at admission:

# Kyverno policy: require non-root in all pods
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-runAsNonRoot
    match:
      any:
      - resources:
          kinds: [Pod]
    validate:
      message: "Pods must run as non-root user"
      pattern:
        spec:
          securityContext:
            runAsNonRoot: true
---
# Disallow privileged containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-privileged
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-privileged
    match:
      any:
      - resources:
          kinds: [Pod]
    validate:
      message: "Privileged containers are not allowed"
      pattern:
        spec:
          containers:
          - =(securityContext):
              =(privileged): false

Scan Kubernetes manifests for misconfigurations:

# Trivy: scan all YAML manifests in a directory
trivy config ./k8s/

# Checkov: infrastructure-as-code security scanner
checkov -d ./k8s/ --framework kubernetes

# kube-bench: checks against CIS Kubernetes Benchmark
# Run on the cluster itself
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job.batch/kube-bench

The bottom line

Container security failures are almost always misconfigurations, not novel exploits: running as root, privileged mode, wildcard RBAC, and unscanned images. Fix these in order: add a non-root USER to every Dockerfile, drop ALL capabilities and add back only what is needed, set readOnlyRootFilesystem in security contexts, enforce runAsNonRoot and allowPrivilegeEscalation: false cluster-wide via Kyverno or OPA, and scan images with Trivy in CI. The CIS Docker Benchmark and NSA/CISA Kubernetes Hardening Guide provide the full checklist.

Frequently asked questions

What are the most important Docker security hardening steps?

In priority order: add a non-root USER to your Dockerfile, use a minimal base image (Alpine or distroless), use multi-stage builds to exclude build tools from production images, run with --cap-drop ALL and add back only needed capabilities, set --read-only on the container filesystem, and scan the image with Trivy before pushing to a registry.

How do you harden Kubernetes workloads?

Set securityContext.runAsNonRoot: true, allowPrivilegeEscalation: false, and readOnlyRootFilesystem: true on all container specs. Drop all Linux capabilities. Set automountServiceAccountToken: false if the pod does not use the Kubernetes API. Apply default-deny NetworkPolicies per namespace. Use Kyverno or OPA Gatekeeper to enforce these standards at admission time across the cluster.

How do you scan Docker container images for vulnerabilities?

Scan at three stages: in the CI pipeline before images are pushed (Trivy, Snyk Container, or Grype via GitHub Actions or GitLab CI), in your container registry after push (Amazon ECR scanning, Google Artifact Registry scanning, or Harbor with Trivy), and on running containers in production (Kubernetes admission controllers using Kyverno or OPA Gatekeeper to block deployment of images with critical unpatched CVEs). Block deployment of images with Critical vulnerabilities unless explicitly approved. Use distroless or minimal base images (gcr.io/distroless, Alpine) to reduce the attack surface: fewer packages means fewer CVEs. Rebuild and redeploy images on a regular cadence even without code changes to pick up base image security updates.

What is the difference between Docker container security and Kubernetes security?

Docker container security focuses on the individual container: image vulnerability scanning, non-root users, read-only filesystems, dropped capabilities, and resource limits. Kubernetes security adds orchestration-layer controls: network policies that control traffic between pods, RBAC for the Kubernetes API (who can create/delete pods and secrets), pod security standards (replacing deprecated PodSecurityPolicies) that enforce container hardening at the namespace level, secret management (avoiding plaintext secrets in environment variables), and control plane hardening (API server authentication, etcd encryption at rest). A hardened container image in an insecure Kubernetes cluster can still be compromised via the Kubernetes API. Both layers are required.

How do I prevent container breakout attacks?

Container breakout occurs when an attacker escapes from a container to the host OS. Prevention controls: never run containers as root (runAsNonRoot: true); set allowPrivilegeEscalation: false; drop all Linux capabilities and only re-add specific required ones (e.g., NET_BIND_SERVICE if needed); never mount the Docker socket (/var/run/docker.sock) inside containers — this grants full host control; avoid privileged: true mode which disables all namespace isolation; use seccomp profiles to restrict system calls (Docker's default seccomp profile blocks 44 syscalls; define a custom profile for tighter control). For Kubernetes: enable the RuntimeDefault seccomp profile at the cluster level and use Falco to alert on suspicious system calls from container processes.

How do you integrate container image scanning into a CI/CD pipeline without blocking every deployment?

The goal is catching exploitable vulnerabilities before production without creating a developer experience bottleneck that causes teams to bypass scanning. Start by scanning on every pull request and build, but set the failure threshold at Critical severity only: blocking on High and Medium initially generates too many failures from base image CVEs that have no available fix, which trains teams to ignore or disable scanning. Use the --ignore-unfixed flag in Trivy (trivy image --ignore-unfixed --exit-code 1 --severity CRITICAL) to skip CVEs where no patched version of the vulnerable package is yet available. These unfixed CVEs cannot be addressed by the development team and should be tracked separately as accepted risk with a base image upgrade plan. Establish a grace period for new CVE findings: when a Critical CVE is discovered in a package your images use, give teams a defined window (typically 14 days for Critical, 30 for High) before it blocks deployments. This prevents the situation where a new CVE published overnight immediately blocks all CI pipelines at 9am. For the registry layer, use continuous scanning of images already in production (Amazon ECR or Harbor) to catch newly published CVEs against images already deployed, and generate alerts or Jira tickets rather than blocking existing services. The critical enforcement point is preventing net-new Critical CVEs from entering the registry in the first place.

Sources & references

  1. CIS Docker Benchmark
  2. Kubernetes: Pod Security Standards
  3. NSA/CISA: Kubernetes Hardening Guide

Free resources

25
Free download

Critical CVE Reference Card 2025–2026

25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.

No spam. Unsubscribe anytime.

Free download

Ransomware Incident Response Playbook

Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.

No spam. Unsubscribe anytime.

Free newsletter

Get threat intel before your inbox does.

50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.

Unsubscribe anytime. We never sell your data.

Eric Bang
Author

Founder & Cybersecurity Evangelist, Decryption Digest

Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.

Black Hat Giveaway

Win a $2,495 Black Hat pass.

Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.

Joins Decryption Digest daily briefing. Unsubscribe anytime.

Giveaway: Black Hat USA 2026 Full-Access Pass ($2,495 value)

Details →
Daily Briefing

Subscribe to enter the giveaway

Every subscriber is automatically entered. You also get daily threat intel every morning: zero-days, ransomware, and nation-state campaigns. Free. No spam.

Already subscribed? You're already entered.

Giveaway

Win a $2,495 Black Hat USA 2026 pass.