Docker Security Best Practices 2025 — Hardening Containers for Production

Sanjeev SharmaSanjeev Sharma
7 min read

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 app

Verify:

docker run --rm myapp whoami    # Should print: appuser
docker run --rm myapp id        # Should show non-zero UID

Use 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.0

GitHub 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.0

Docker 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 container

BuildKit 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 ci
docker 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.0

In Docker Compose:

services:
  api:
    read_only: true
    tmpfs:
      - /tmp:size=64m
      - /var/run

Drop 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.0

In Docker Compose:

services:
  api:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # only if binding ports below 1024

Enable 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.0

Prevent Privilege Escalation

# Prevent the process from gaining additional privileges
docker run -d \
  --security-opt no-new-privileges:true \
  myapp:1.0

In Docker Compose:

services:
  api:
    security_opt:
      - no-new-privileges:true

This 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.0

For 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.com

Limit 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.0

Protect 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-proxy

Common Mistakes

  • Running containers with --privileged flag — grants full host kernel access, equivalent to root on the host
  • Storing secrets in ENV Dockerfile instructions — visible in docker history to anyone with image access
  • Mounting /var/run/docker.sock into containers — gives full Docker daemon control to the container
  • Not scanning base images — FROM ubuntu:22.04 may 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:true blocks 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading