XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksDockerSecurity & Production Hardening

Security & Production Hardening

securityproductionbest-practiceshandbook

TL;DR

  • Never run as root — use USER directive 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/ENV for 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Docker Compose v2 — Modern Multi-Container Apps

On this page

  • TL;DR
  • Step 1: Non-Root Containers
  • Verify at Runtime
  • Step 2: Minimal Base Images
  • Alpine (Recommended for most cases)
  • Distroless (Maximum security)
  • Step 3: Image Scanning
  • Trivy (Most popular, free)
  • In CI/CD (GitHub Actions)
  • Docker Scout (Built-in)
  • Step 4: Dockerfile Security Best Practices
  • .dockerignore (Security-critical)
  • Step 5: Runtime Security
  • Read-Only Filesystem
  • Resource Limits
  • Network Isolation
  • Step 6: Supply Chain Security
  • Pin Image Digests
  • Verify Image Signatures
  • Minimal Installed Packages
  • Step 7: Security Checklist
  • Interview Questions