Docker Security Best Practices: Hardening Containers for Production

David Childs

Learn how to secure Docker containers in production with proven techniques for image hardening, runtime protection, and vulnerability management.

Docker containers power millions of applications, but with great flexibility comes great responsibility. After securing containerized environments processing sensitive data, I've compiled the essential security practices every DevOps team needs to implement.

The Container Security Landscape

Container security isn't just about the container itself—it's about the entire lifecycle:

  • Base image selection and maintenance
  • Build-time security scanning
  • Runtime protection and monitoring
  • Network segmentation
  • Secrets management
  • Compliance and auditing

Secure Base Images

Choose Minimal Base Images

# Instead of this
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3

# Use this
FROM python:3.11-slim-bullseye

# Or even better, use distroless
FROM gcr.io/distroless/python3-debian11

Scan for Vulnerabilities

# Using Trivy for scanning
trivy image --severity HIGH,CRITICAL myapp:latest

# Integrate into CI/CD
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'myapp:latest'
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'

Dockerfile Security Best Practices

Multi-Stage Builds for Smaller Attack Surface

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

# Production stage
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=nonroot:nonroot . .
USER nonroot
EXPOSE 3000
CMD ["server.js"]

Never Run as Root

# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser

# Switch to non-root user
USER appuser

# Or use numeric UID for better security
USER 1001:1001

Secure Secret Handling

# BAD: Never hardcode secrets
ENV API_KEY=sk_live_abc123def456

# GOOD: Use build arguments for build-time secrets
ARG BUILD_TOKEN
RUN --mount=type=secret,id=build_token \
    BUILD_TOKEN=$(cat /run/secrets/build_token) && \
    npm install --registry https://private-registry.com

# BETTER: Use runtime secret injection
# Secrets are provided at runtime via environment or mounted files

Runtime Security

Security Options and Capabilities

# docker-compose.yml with security options
version: '3.8'
services:
  app:
    image: myapp:latest
    security_opt:
      - no-new-privileges:true
      - seccomp:seccomp-profile.json
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

AppArmor and SELinux Profiles

# Load AppArmor profile
sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx

# Run container with AppArmor profile
docker run --security-opt apparmor=docker-nginx nginx

# SELinux context
docker run --security-opt label=level:s0:c100,c200 myapp

Resource Limits

# Prevent resource exhaustion attacks
services:
  app:
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M
        reservations:
          cpus: '0.25'
          memory: 128M
    pids_limit: 100
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

Network Security

Network Segmentation

# Create custom networks for isolation
docker network create --driver bridge frontend
docker network create --driver bridge backend
docker network create --driver bridge data

# Run containers in appropriate networks
docker run -d --name web --network frontend nginx
docker run -d --name api --network frontend --network backend api-server
docker run -d --name db --network data postgres

Network Policies

# Kubernetes NetworkPolicy example
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-netpol
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: web
    ports:
    - protocol: TCP
      port: 8080
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: database
    ports:
    - protocol: TCP
      port: 5432

Secrets Management

Using Docker Secrets

# Create secrets
echo "mypassword" | docker secret create db_password -

# Use in stack deployment
version: '3.8'
services:
  db:
    image: postgres
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true

Integration with External Secret Stores

# HashiCorp Vault integration
- name: Fetch secrets from Vault
  uri:
    url: "{{ vault_addr }}/v1/secret/data/myapp"
    method: GET
    headers:
      X-Vault-Token: "{{ vault_token }}"
  register: vault_secrets

- name: Run container with secrets
  docker_container:
    name: myapp
    image: myapp:latest
    env:
      DATABASE_URL: "{{ vault_secrets.json.data.data.database_url }}"

Image Signing and Verification

Docker Content Trust

# Enable content trust
export DOCKER_CONTENT_TRUST=1

# Sign images
docker trust sign myregistry/myapp:latest

# Verify signatures
docker trust inspect --pretty myregistry/myapp:latest

Cosign for Container Signing

# Generate keys
cosign generate-key-pair

# Sign container image
cosign sign --key cosign.key myregistry/myapp:latest

# Verify signature
cosign verify --key cosign.pub myregistry/myapp:latest

Compliance and Auditing

CIS Docker Benchmark

# Run Docker Bench Security
docker run --rm --net host --pid host --userns host --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v /etc:/etc:ro \
  docker/docker-bench-security

Audit Logging

// /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "labels": "environment,version",
    "env": "ENVIRONMENT,VERSION"
  },
  "experimental": true,
  "metrics-addr": "127.0.0.1:9323",
  "log-level": "info"
}

Runtime Protection with Falco

Falco Rules for Container Security

# falco_rules.yaml
- rule: Terminal shell in container
  desc: Detect shell spawned in container
  condition: >
    container.id != host and
    proc.name in (bash, sh, zsh) and
    spawned_process and
    proc.pname exists and
    not proc.pname in (sh, bash)
  output: >
    Shell spawned in container (user=%user.name container=%container.name 
    shell=%proc.name parent=%proc.pname cmdline=%proc.cmdline)
  priority: WARNING

- rule: Write below root
  desc: Detect write below root
  condition: >
    container.id != host and
    fd.name startswith / and
    (evt.type = write or evt.type = open_write) and
    not fd.name startswith /tmp and
    not fd.name startswith /var
  output: >
    File write below root directory (user=%user.name command=%proc.cmdline file=%fd.name)
  priority: ERROR

Vulnerability Management Pipeline

Automated Scanning in CI/CD

# GitLab CI example
stages:
  - build
  - scan
  - deploy

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

container_scanning:
  stage: scan
  image: registry.gitlab.com/security-products/analyzers/klar:latest
  variables:
    CLAIR_DB_IMAGE: "arminc/clair-db:latest"
    CLAIR_DB_IMAGE_TAG: "latest"
  script:
    - /analyzer run
  artifacts:
    reports:
      container_scanning: gl-container-scanning-report.json
  dependencies: []
  only:
    - branches

Admission Controllers

# OPA (Open Policy Agent) policy
package docker.security

deny[msg] {
  input.request.kind.kind == "Pod"
  input.request.object.spec.containers[_].image
  not starts_with(input.request.object.spec.containers[_].image, "myregistry.com/")
  msg := "Images must be from approved registry"
}

deny[msg] {
  input.request.kind.kind == "Pod"
  input.request.object.spec.containers[_].securityContext.runAsUser == 0
  msg := "Containers cannot run as root"
}

Incident Response

Container Forensics

# Capture running container state
docker commit suspicious-container investigation-image
docker save investigation-image > evidence.tar

# Analyze with tools
docker run --rm -v $(pwd):/data:ro \
  aquasec/trivy image --input /data/evidence.tar

# Extract file system for analysis
docker export suspicious-container | tar -tv > filesystem-listing.txt

Kill Chain Prevention

  1. Initial Access: Use signed images only
  2. Execution: Restrict capabilities and syscalls
  3. Persistence: Read-only root filesystem
  4. Privilege Escalation: No new privileges flag
  5. Defense Evasion: Runtime monitoring with Falco
  6. Lateral Movement: Network segmentation
  7. Exfiltration: Egress filtering

Security Checklist

  • Use minimal base images (distroless/alpine)
  • Never run containers as root
  • Implement multi-stage builds
  • Scan images for vulnerabilities
  • Sign and verify images
  • Drop unnecessary capabilities
  • Use read-only root filesystem
  • Implement resource limits
  • Enable security options (AppArmor/SELinux)
  • Segregate container networks
  • Manage secrets properly
  • Monitor runtime behavior
  • Implement admission controls
  • Regular security audits
  • Incident response plan

Conclusion

Container security is not a one-time configuration but an ongoing practice. Start with the basics—non-root users, minimal images, and vulnerability scanning—then progressively add layers of security as your infrastructure matures.

Remember: the goal is defense in depth. No single security measure is perfect, but combining multiple layers creates a robust security posture that can protect against most threats. Regular audits, continuous monitoring, and staying updated with the latest security practices are key to maintaining secure containerized environments.

Share this article

DC

David Childs

Consulting Systems Engineer with over 10 years of experience building scalable infrastructure and helping organizations optimize their technology stack.

Related Articles