Container Security: Hardening Docker and Kubernetes Workloads in Practice

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.
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.
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
Free resources
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.
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.
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.

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.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
