DevSecOps

Docker Security Best Practices for Developers

Practical Docker security from image building to runtime, covering multi-stage builds, user namespaces, and image scanning.

Michael
Staff Security Engineer
4 min read

Docker containers are not sandboxes. A container running as root with the default configuration has a weaker isolation boundary than many developers assume. This guide covers practical Docker security for developers who build and ship containerized applications.

Build Secure Images

Use Minimal Base Images

Start with the smallest base image that works:

# Best: distroless (no shell, no package manager)
FROM gcr.io/distroless/static-debian12

# Good: Alpine-based (small attack surface)
FROM node:20-alpine

# Acceptable: slim variants
FROM python:3.12-slim

# Avoid: full OS images
# FROM ubuntu:22.04

Distroless images contain only your application and its runtime dependencies. No shell, no package manager, no unnecessary utilities. This dramatically reduces the attack surface.

Multi-Stage Builds

Separate build dependencies from runtime:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Runtime stage
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]

The runtime image does not contain npm, build tools, source code, or dev dependencies.

Run as Non-Root

Never run containers as root:

FROM node:20-alpine
RUN addgroup -g 1001 -S appgroup && \
    adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]

Verify your container is not running as root:

docker run --rm your-image whoami
# Should NOT output "root"

Pin Base Image Digests

Tags are mutable. Use SHA256 digests for reproducible builds:

FROM node:20-alpine@sha256:abc123def456...

This ensures you get exactly the same base image every time, even if the 20-alpine tag is updated.

Minimize Layers and Clean Up

Each Dockerfile instruction creates a layer. Combine commands and clean up in the same layer:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      ca-certificates \
      curl && \
    rm -rf /var/lib/apt/lists/*

Do not leave package manager caches, build artifacts, or temporary files in your image.

Image Scanning

Trivy

Trivy is the most popular open-source container scanner:

trivy image your-image:latest

Run Trivy in CI to block builds with critical vulnerabilities:

trivy image --exit-code 1 --severity CRITICAL,HIGH your-image:latest

Docker Scout

Docker's built-in scanning tool:

docker scout cves your-image:latest

Grype

Another excellent open-source scanner:

grype your-image:latest

Run at least one scanner in your CI pipeline. Many teams run two for better coverage.

Runtime Security

Read-Only Root Filesystem

Run containers with a read-only root filesystem:

docker run --read-only --tmpfs /tmp your-image

In Docker Compose:

services:
  app:
    image: your-image
    read_only: true
    tmpfs:
      - /tmp

This prevents malware from writing to the container filesystem.

Drop Capabilities

Containers inherit a set of Linux capabilities. Drop all and add only what you need:

docker run --cap-drop ALL --cap-add NET_BIND_SERVICE your-image

In Docker Compose:

services:
  app:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

No Privileged Mode

Never use --privileged in production. It gives the container full access to the host:

# NEVER do this in production
docker run --privileged your-image

Resource Limits

Prevent resource exhaustion attacks:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

Network Isolation

Do not expose ports you do not need:

services:
  app:
    ports:
      - "8080:8080"
  db:
    # No ports exposed to host
    expose:
      - "5432"

Use Docker networks to isolate services:

networks:
  frontend:
  backend:

services:
  web:
    networks: [frontend, backend]
  api:
    networks: [backend]
  db:
    networks: [backend]

Secrets Management

Never put secrets in environment variables in your Dockerfile or docker-compose.yml:

# NEVER do this
ENV DATABASE_PASSWORD=supersecret

Use Docker secrets or an external secrets manager:

services:
  app:
    secrets:
      - db_password
secrets:
  db_password:
    file: ./secrets/db_password.txt

Better: use a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Doppler.

.dockerignore

Always include a .dockerignore file to prevent sensitive files from entering your image:

.git
.env
.env.*
*.pem
*.key
node_modules
__pycache__
.aws
credentials.json

SBOM Generation

Generate SBOMs for your container images:

# Using Trivy
trivy image --format cyclonedx --output sbom.json your-image:latest

# Using Syft
syft your-image:latest -o cyclonedx-json > sbom.json

How Safeguard.sh Helps

Safeguard.sh integrates with your container build pipeline to ingest SBOMs from every image. It tracks OS-level packages, language dependencies, and base image versions across your entire container fleet. When a CVE hits an Alpine package or a Python library bundled in your images, Safeguard.sh shows you exactly which containers are affected. It provides the fleet-wide visibility that individual image scans in isolated pipelines cannot deliver.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.