Docker Security Best Practices 2025 — Hardening Containers for Production
Advertisement
Introduction
Why This Matters
Container security incidents are not hypothetical. Real-world attacks exploit common misconfigurations: containers running as root, secrets stored in image layers, ports unnecessarily exposed, and base images with months of unpatched CVEs. When a container is compromised, poor security posture turns a contained incident into a full host takeover.
The Docker security model provides multiple layers of defense — image hardening, runtime isolation, network policies, and secrets management. Teams that implement these layers reduce their blast radius dramatically: even if an attacker achieves code execution inside a container, they find a non-root user, a read-only filesystem, dropped capabilities, and no access to adjacent containers.
This guide covers the complete 2025 Docker security checklist in order of impact.
Run Containers as Non-Root
The most impactful single change: never run application processes as root inside a container.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Create non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Change ownership of app files
RUN chown -R appuser:appgroup /app
# Switch to non-root user for all subsequent instructions
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]For Debian/Ubuntu base images:
RUN groupadd -r app --gid=999 && \
useradd -r -g app --uid=999 --home-dir=/app --shell=/sbin/nologin app
USER appVerify:
docker run --rm myapp whoami # Should print: appuser
docker run --rm myapp id # Should show non-zero UIDUse Minimal Base Images
Every package in the base image is a potential attack surface. Minimize it.
# Development: Alpine (5 MB base)
FROM node:20-alpine
# Production: Distroless (no shell, no package manager)
FROM gcr.io/distroless/nodejs20-debian12
# For static binaries: scratch (literally empty)
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]Distroless images cannot be exec-ed into with /bin/sh because there is no shell — a significant barrier to post-exploitation lateral movement.
Scan Images for Vulnerabilities
Integrate scanning into your CI pipeline so vulnerable images never reach production:
# Trivy (open-source, fast, comprehensive)
trivy image myapp:1.0
trivy image --severity CRITICAL,HIGH myapp:1.0
# Fail CI on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:$CI_COMMIT_SHA
# Docker Scout (built into Docker CLI)
docker scout cves myapp:1.0
docker scout recommendations myapp:1.0
# Grype (Anchore)
grype myapp:1.0GitHub Actions integration:
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: '1'Never Store Secrets in Images
Secrets stored in Dockerfile ENV instructions, copied config files, or embedded credentials are visible in docker history and to anyone with pull access to the registry.
# WRONG: password visible in docker history
ENV DB_PASSWORD=mysecretpassword
RUN git clone https://user:token@github.com/org/repo.git
# WRONG: .env file baked into the image
COPY .env .Correct Approaches
Runtime injection:
# Inject at runtime from a secrets manager
docker run -d \
-e DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id prod/db/password \
--query SecretString --output text) \
myapp:1.0Docker secrets (Swarm mode):
echo "mysecretpassword" | docker secret create db_password -
docker service create \
--secret db_password \
--name api myapp:1.0
# Secret available at /run/secrets/db_password inside the containerBuildKit secret mounts (for build-time secrets like npm tokens):
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm cidocker build --secret id=npm_token,src=$HOME/.npmrc .Read-Only Root Filesystem
Prevent containers from writing to their own filesystem, limiting the impact of a compromise:
docker run -d \
--read-only \
--tmpfs /tmp:rw,size=64m \
--tmpfs /var/run:rw \
myapp:1.0In Docker Compose:
services:
api:
read_only: true
tmpfs:
- /tmp:size=64m
- /var/runDrop Linux Capabilities
Containers run with a reduced set of Linux capabilities by default, but you can drop more and add only what is needed:
# Drop all capabilities, then add only what is required
docker run -d \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
-p 80:80 \
nginx:alpine
# No special capabilities needed for most apps
docker run -d \
--cap-drop ALL \
myapp:1.0In Docker Compose:
services:
api:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # only if binding ports below 1024Enable Seccomp Profiles
Seccomp (Secure Computing Mode) restricts which system calls a container can make. Docker applies a default profile that blocks 44 dangerous syscalls.
# Apply the default seccomp profile explicitly
docker run -d \
--security-opt seccomp=/path/to/seccomp-profile.json \
myapp:1.0
# Use Docker's built-in default
docker run -d \
--security-opt seccomp=default \
myapp:1.0
# Disable seccomp (only for debugging)
docker run -d \
--security-opt seccomp=unconfined \
myapp:1.0Prevent Privilege Escalation
# Prevent the process from gaining additional privileges
docker run -d \
--security-opt no-new-privileges:true \
myapp:1.0In Docker Compose:
services:
api:
security_opt:
- no-new-privileges:trueThis flag prevents setuid binaries from granting elevated privileges to the process, blocking common privilege escalation vectors.
Use Content Trust for Image Signing
Docker Content Trust (DCT) enforces signed images — only signed images from verified publishers can be pulled and run:
# Enable content trust
export DOCKER_CONTENT_TRUST=1
# Sign and push (requires Notary)
docker trust sign myapp:1.0
# Verify image signature
docker trust inspect myapp:1.0For CI pipelines, use cosign (Sigstore) for keyless signing:
# Sign image in CI with keyless signing
cosign sign --yes myrepo/myapp:$GITHUB_SHA
# Verify signature
cosign verify myrepo/myapp:$GITHUB_SHA \
--certificate-identity=https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main \
--certificate-oidc-issuer=https://token.actions.githubusercontent.comLimit Container Resources
Resource limits prevent a compromised or buggy container from consuming all host resources (denial-of-service):
docker run -d \
--memory=512m \
--memory-swap=512m \
--cpus=0.5 \
--pids-limit=100 \
--ulimit nofile=1024:1024 \
myapp:1.0Protect the Docker Socket
The Docker socket (/var/run/docker.sock) grants root-equivalent access to the host. Never mount it into untrusted containers.
# DANGEROUS: gives container full host access
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp
# Use Docker-in-Docker (dind) with TLS for CI instead
# Or use rootless Docker and socket proxies like dockersocket-proxyCommon Mistakes
- Running containers with
--privilegedflag — grants full host kernel access, equivalent to root on the host - Storing secrets in
ENVDockerfile instructions — visible indocker historyto anyone with image access - Mounting
/var/run/docker.sockinto containers — gives full Docker daemon control to the container - Not scanning base images —
FROM ubuntu:22.04may have 50+ unpatched CVEs - Ignoring image scanning results instead of fixing or accepting vulnerabilities explicitly
Best Practices
- Apply all security options as a baseline:
--read-only --cap-drop ALL --security-opt no-new-privileges:true --user 1000 - Use Trivy in CI and fail builds on CRITICAL vulnerabilities — never ship a known critical CVE
- Rotate base images monthly to pick up OS security patches
- Use distroless or scratch images for services that do not need an OS shell
- Implement image signing with cosign in production pipelines
- Audit container capabilities quarterly — the least-privilege set is smaller than you think
Key Takeaways
- Running containers as non-root is the single most impactful Docker security change — root in a container can escape with kernel vulnerabilities
- Secrets must never appear in Dockerfile instructions, image layers, or environment variables baked at build time
- Trivy and Docker Scout catch known CVEs in base images and dependencies — integrate them into CI with exit codes
- Read-only root filesystems prevent attackers from writing tools or modifying application code at runtime
- Dropping all Linux capabilities and re-adding only what is needed follows the principle of least privilege
--security-opt no-new-privileges:trueblocks setuid privilege escalation — add it to every container- The Docker socket grants host root access; never mount it into untrusted or third-party containers
- Content trust and cosign image signing ensure only verified, unmodified images run in production
Advertisement