TL;DR
- Never run as root — use
USERdirective with a non-root user. - Use minimal base images — Alpine or distroless (no shell, no package manager).
- Scan images with Trivy/Snyk before deployment.
- BuildKit secrets for credentials — never
ARG/ENVfor sensitive data. - Read-only filesystem + tmpfs for write-needed paths.
Step 1: Non-Root Containers
Running containers as root is Docker's default but also its biggest security anti-pattern. If an attacker exploits your application (RCE via dependency vulnerability, SSRF, etc.), they inherit the container's user permissions. Root inside a container can escape to the host in many scenarios (kernel exploits, misconfigured capabilities, mounted Docker socket). Running as a non-root user with a dedicated UID is the single most impactful security hardening you can apply — and it's required by most container security policies (PCI-DSS, SOC2).
Running as root inside a container means if the container is compromised, the attacker has root access:
# ✅ Create and switch to non-root user
FROM node:20-alpine
# Create system group and user
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
WORKDIR /app
# Copy files with correct ownership
COPY --chown=appuser:appgroup . .
# Switch to non-root before running
USER appuser
CMD ["node", "dist/main.js"]
Verify at Runtime
# Check who's running inside the container
docker exec <container> whoami
# Should output: appuser (NOT root)
# Docker run with read-only filesystem
docker run --read-only --tmpfs /tmp myapp
Step 2: Minimal Base Images
Base image selection determines your security surface area. Every installed package is a potential vulnerability — a full Ubuntu image ships ~500 packages you'll never use but still need to patch. Alpine, distroless, and scratch images contain only what's necessary to run your application. Distroless (from Google) doesn't even have a shell, meaning attackers who get code execution can't spawn bash or install tools. Choosing the right base image is a trade-off between debuggability and attack surface.
| Base Image | Size | Shell | Package Manager | Security Surface |
|---|---|---|---|---|
node:20 |
~1GB | ✅ | ✅ apt | Large |
node:20-slim |
~200MB | ✅ | ✅ apt | Medium |
node:20-alpine |
~130MB | ✅ | ✅ apk | Small |
| Distroless | ~80MB | ❌ | ❌ | Minimal |
Alpine (Recommended for most cases)
FROM node:20-alpine AS runner
# Alpine has musl libc — most npm packages work, but some native modules may not
Distroless (Maximum security)
# No shell, no package manager — attackers can't install tools
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Can't use CMD ["sh", "-c", "..."] — no shell available
CMD ["dist/main.js"]
Step 3: Image Scanning
Image scanning detects known vulnerabilities (CVEs) in your base image and installed packages before they reach production. Without scanning, you're deploying containers with publicly-known exploits that automated tools can find and abuse. Scanners cross-reference your image's package list against vulnerability databases (NVD, OSV) and flag issues by severity. Integrating scanning into CI/CD (blocking deploys on critical/high CVEs) is baseline security hygiene for any containerized application.
Trivy (Most popular, free)
# Scan an image for vulnerabilities
trivy image myapp:latest
# Scan and fail CI if HIGH/CRITICAL found
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
# Scan Dockerfile for misconfigurations
trivy config Dockerfile
# Scan with SBOM (Software Bill of Materials)
trivy image --format spdx-json -o sbom.json myapp:latest
In CI/CD (GitHub Actions)
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: "1"
Docker Scout (Built-in)
# Built into Docker Desktop
docker scout cves myapp:latest
docker scout recommendations myapp:latest
Step 4: Dockerfile Security Best Practices
Your Dockerfile is code that defines your production security posture. Common mistakes — hardcoded secrets, running as root, installing unnecessary packages, using latest tags — create vulnerabilities that scanners won't catch because they're logic errors, not known CVEs. These best practices prevent secrets from leaking into image layers (use multi-stage or BuildKit secrets), reduce attack surface (only install what you need), and ensure reproducible builds (pin versions).
# syntax=docker/dockerfile:1
# 1. Pin exact versions — avoid supply chain attacks
FROM node:20.11.1-alpine@sha256:abc123... AS builder
# 2. Don't install unnecessary packages
RUN apk add --no-cache dumb-init
# 3. Use .dockerignore to exclude sensitive files
# (Handled by .dockerignore file)
# 4. BuildKit secrets for private registries
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --production
# 5. Set NODE_ENV=production
ENV NODE_ENV=production
# 6. Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
# 7. Don't store secrets in ENV or ARG
# ❌ ARG DB_PASSWORD=secret — visible in docker history
# ❌ ENV API_KEY=secret — visible in docker inspect
# ✅ Use runtime secrets or secret mounts
.dockerignore (Security-critical)
.git
.env
.env.*
*.pem
*.key
secrets/
node_modules
.next
coverage
tests
**/*.test.*
Step 5: Runtime Security
Build-time hardening protects the image; runtime security protects the running container. Read-only filesystems prevent attackers from writing malware or modifying application code. Dropped capabilities remove kernel-level privileges your app doesn't need (like changing file ownership or binding to privileged ports). Resource limits prevent DoS via fork bombs or memory exhaustion. These settings compose to create defense-in-depth: even if one layer is breached, others limit the blast radius.
Read-Only Filesystem
# compose.yaml
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp # App can write to /tmp
- /app/.next/cache # Next.js cache needs writes
security_opt:
- no-new-privileges:true # Prevent privilege escalation
Resource Limits
services:
app:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
Network Isolation
services:
app:
networks:
- frontend
- backend
db:
networks:
- backend # Only accessible from backend network
nginx:
networks:
- frontend # Only accessible from frontend
networks:
frontend:
backend:
internal: true # No external access
Step 6: Supply Chain Security
Supply chain attacks target the dependencies and base images you pull from external registries. A compromised base image or a typo-squatted package can inject malware into your production environment. Pinning image digests (SHA256) ensures you always get the exact same image regardless of tag mutations. Signing images with Cosign/Notary creates a cryptographic chain of trust. These practices prevent scenarios like the codecov breach or event-stream attack from compromising your containers.
Pin Image Digests
# ❌ Mutable tag — could be replaced with malicious image
FROM node:20-alpine
# ✅ Pin to specific digest — immutable reference
FROM node:20-alpine@sha256:a1b2c3d4e5f6...
Verify Image Signatures
# Verify image with cosign (sigstore)
cosign verify --key cosign.pub myregistry/myapp:latest
Minimal Installed Packages
# ❌ Don't do this
RUN apt-get update && apt-get install -y curl wget vim nano git
# ✅ Install only what's needed, clean up after
RUN apk add --no-cache dumb-init && \
rm -rf /var/cache/apk/*
Step 7: Security Checklist
This checklist distills container security into actionable items you can verify in CI/CD or during code review. Each item addresses a specific attack vector: root execution enables privilege escalation, latest tags cause supply chain drift, exposed ports expand the network attack surface, and missing health checks prevent orchestrators from detecting compromised containers. Use this as a gate before any container reaches production.
| Check | Why | How |
|---|---|---|
| Non-root user | Limit blast radius | USER appuser |
| Minimal base image | Fewer vulnerabilities | Alpine or distroless |
| No secrets in layers | Prevent leaks | BuildKit --mount=type=secret |
| Pin image versions | Reproducibility | Use @sha256:... digests |
| Scan before deploy | Catch CVEs | Trivy in CI/CD |
| Read-only filesystem | Prevent tampering | read_only: true + tmpfs |
| Resource limits | Prevent DoS | deploy.resources.limits |
| Network isolation | Limit lateral movement | Internal networks |
| Health checks | Detect failures | HEALTHCHECK directive |
| No unnecessary capabilities | Principle of least privilege | cap_drop: ALL |
Interview Questions
-
Why shouldn't containers run as root?
- If the container is compromised, the attacker has root. With non-root users, they're constrained to that user's permissions, limiting damage.
-
What's the difference between Alpine and Distroless?
- Alpine has a shell and package manager (apk) — useful for debugging. Distroless has nothing — maximum security but can't shell in to debug.
-
How do you handle secrets in Docker?
- Build time: BuildKit
--mount=type=secret. Runtime: Docker secrets (Swarm/Compose) or external secret managers. Never in ENV/ARG.
- Build time: BuildKit
-
What's a Docker image digest and why pin it?
- A SHA-256 hash of the image's manifest. Tags are mutable (can be overwritten), but digests are immutable — ensures you always get the exact same image.