Image Security
Container security is the layered defence around what is otherwise "the app runs as root with whatever it can reach". The basics: pinned images, distroless base, drop capabilities, read-only filesystem, non-root user, no privileged mode, scanned for CVEs in CI. None of these is exotic; all of them stop common attacks.
A hardened Dockerfile + run-time controls
EXAMPLE
# ===== Hardened Dockerfile =====
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# Stage 2: runtime — distroless, non-root
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
# Non-root user is the default in distroless 'nonroot' variant (UID 65532)
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/package.json ./
EXPOSE 3000
ENV NODE_ENV=production
# distroless has no shell; CMD must be exec form
CMD ["dist/server.js"]
# ===== Patterns to internalise =====
# - PIN tags to specific digests for the most paranoid setups:
# FROM node:20-alpine@sha256:...
# - Distroless / Alpine / Wolfi -> minimal attack surface
# - Multi-stage so build tools are NOT in the runtime image
# - Non-root user (USER 65532 in distroless nonroot)
# - Drop --omit=dev for production npm install
# - Avoid 'latest' tag — surprise upgrades are surprise breakages
# ===== Run-time controls =====
# docker run \
# --user 65532:65532 \
# --read-only --tmpfs /tmp --tmpfs /var/run \
# --cap-drop ALL --cap-add NET_BIND_SERVICE \
# --security-opt no-new-privileges \
# --pids-limit 100 \
# --memory 512m --memory-swap 512m \
# --cpus 1 \
# --network shop-net \
# shop/api:1.0
# Why each flag:
# --user not root
# --read-only filesystem is RO; writes go to mounted tmpfs only
# --cap-drop ALL removes ALL Linux capabilities; add only what you need
# --security-opt no-new-privileges setuid binaries lose effect
# --pids-limit stops fork bombs
# --memory hard cap; OOM-kill on overshoot
# --network attach to a named bridge, not the default
# ===== Compose equivalent =====
# services:
# api:
# image: shop/api:1.0
# read_only: true
# tmpfs: [/tmp, /var/run]
# user: '65532:65532'
# cap_drop: [ALL]
# cap_add: [NET_BIND_SERVICE]
# security_opt:
# - no-new-privileges:true
# pids_limit: 100
# deploy:
# resources:
# limits: { cpus: '1.0', memory: 512M }
# ===== Image scanning in CI =====
# trivy image shop/api:1.0
# grype shop/api:1.0
# docker scout cves shop/api:1.0
# Block PRs that introduce CRITICAL CVEs.
# ===== Secrets — NEVER bake into images =====
# Bad: COPY .env /app/.env
# Good: pass via env, files via 'secrets:' (compose) or k8s Secret + projected volume
# Audit existing images:
# docker history --no-trunc shop/api:1.0 | grep -i token
# ===== Don't run the daemon socket inside a container =====
# Mounting /var/run/docker.sock inside a container = root on the host.
# If you need Docker-in-Docker for builds, use Buildx with a remote builder.
# ===== SBOM + signing =====
# Generate a Software Bill of Materials per release:
# docker sbom shop/api:1.0
# OR: syft shop/api:1.0 -o spdx-json > sbom.json
# Sign images so deployers can verify provenance:
# cosign sign --key cosign.key ghcr.io/owner/shop/api:1.0
# Deploy policy: cosign verify ghcr.io/owner/shop/api:1.0
# ===== Reduce blast radius further =====
# - Run untrusted code with gVisor or Firecracker (kata-containers)
# - Use rootless Docker (dockerd as a non-root user)
# - Apply seccomp profiles (default profile drops ~70 syscalls)
# - AppArmor / SELinux confinement on the host
# ===== Pitfalls =====
# - Distroless without exec form CMD -> container fails to start
# - Read-only filesystem without tmpfs for /tmp -> npm / sqlite breaks
# - --privileged for 'it didn't work' -> grants the kernel
# - latest tag in production -> surprise upgrades
# - apt-get install in the runtime image (bloated + more CVEs)
Why it matters
Run containers non-root + read-only + cap-drop=ALL + no-new-privileges by default; add specific capabilities only when needed. The combination shrinks the attack surface from "anything Linux can do" to "exactly what this container needs" — and it costs four CLI flags or four compose lines, not a security architect.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# Scan images: docker scout cves my-api:1.0 # Run as non-root user; drop capabilities; use read-only fs where possible.Try it Yourself »
Discussion
Loading…