Docker Security: Essential Best Practices for Container Hardening

Docker Security: Essential Best Practices for Container Hardening

Whitespots Team ·
docker
containers
devops
infrastructure

Introduction

Docker containers have revolutionized application deployment, but they introduce unique security challenges. Misconfigurations can expose sensitive data, allow privilege escalation, or provide attack vectors into your infrastructure. This article covers essential Docker security practices with practical examples.

Common Docker Security Issues

  1. Running containers as root
  2. Using vulnerable base images
  3. Exposing sensitive data in images
  4. Excessive container privileges
  5. Unpatched dependencies
  6. Insecure network configurations
  7. Missing resource limits

Secure Dockerfile Practices

Vulnerable Dockerfile

dockerfile
# VULNERABLE Dockerfile - Multiple security issues FROM ubuntu:latest # Running as root (default) RUN apt-get update && apt-get install -y python3 # Secrets in image layers ENV DATABASE_PASSWORD=supersecret123 # Copy everything COPY . /app # No user specified - runs as root WORKDIR /app CMD ["python3", "app.py"]

Secure Dockerfile

dockerfile
# SECURE Dockerfile - Best practices applied # Use specific version, not 'latest' FROM python:3.11-slim-bullseye AS builder # Install security updates RUN apt-get update && \ apt-get upgrade -y && \ apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* # Create non-root user RUN groupadd -r appuser && \ useradd -r -g appuser -u 1001 -m -s /sbin/nologin appuser WORKDIR /app # Copy only requirements first (layer caching) COPY requirements.txt . # Install dependencies RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY --chown=appuser:appuser . . # Switch to non-root user USER appuser # Use specific port EXPOSE 8000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD python -c "import requests; requests.get('http://localhost:8000/health')" # Run with least privileges CMD ["python", "-u", "app.py"]

Multi-stage Build for Smaller, Safer Images

dockerfile
# Multi-stage build - Reduces attack surface # Stage 1: Build FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build # Stage 2: Production FROM node:18-alpine # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init # Create non-root user RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 WORKDIR /app # Copy only necessary files from builder COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --chown=nodejs:nodejs package*.json ./ # Switch to non-root user USER nodejs EXPOSE 3000 # Use dumb-init to handle signals properly ENTRYPOINT ["dumb-init", "--"] CMD ["node", "dist/index.js"]

Scanning Images for Vulnerabilities

bash
# Install Trivy curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Scan image for vulnerabilities trivy image myapp:latest # Scan with severity threshold trivy image --severity HIGH,CRITICAL myapp:latest # Fail CI/CD if vulnerabilities found trivy image --exit-code 1 --severity CRITICAL myapp:latest # Scan Dockerfile trivy config Dockerfile

Docker Compose Security

Vulnerable docker-compose.yml

yaml
# VULNERABLE docker-compose.yml version: '3' services: web: image: myapp:latest ports: - "80:80" environment: - DATABASE_PASSWORD=supersecret # Secrets in plaintext privileged: true # Excessive privileges volumes: - /:/host # Mounting host root

Secure docker-compose.yml

yaml
# SECURE docker-compose.yml version: '3.8' services: web: image: myapp:1.2.3 # Specific version ports: - "127.0.0.1:8000:8000" # Bind to localhost only environment: - NODE_ENV=production env_file: - .env # Never commit .env file secrets: - db_password # Security options security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - NET_BIND_SERVICE read_only: true tmpfs: - /tmp volumes: - ./app:/app:ro # Read-only mount # Resource limits deploy: resources: limits: cpus: '0.5' memory: 512M reservations: cpus: '0.25' memory: 256M # Health check healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 3s retries: 3 start_period: 40s # Restart policy restart: unless-stopped # User user: "1001:1001" # Network networks: - backend database: image: postgres:15-alpine environment: - POSTGRES_DB=myapp - POSTGRES_USER=appuser secrets: - db_password volumes: - postgres_data:/var/lib/postgresql/data security_opt: - no-new-privileges:true networks: - backend # Don't expose database publicly expose: - "5432" secrets: db_password: file: ./secrets/db_password.txt volumes: postgres_data: driver: local networks: backend: driver: bridge internal: true # No external access

Docker Runtime Security

Running Containers Securely

bash
# VULNERABLE: Running with excessive privileges docker run -d --privileged myapp:latest # SECURE: Run with minimal privileges docker run -d \ --name myapp \ --user 1001:1001 \ --read-only \ --tmpfs /tmp \ --cap-drop ALL \ --cap-add NET_BIND_SERVICE \ --security-opt no-new-privileges:true \ --security-opt seccomp=seccomp-profile.json \ --memory="256m" \ --cpus="0.5" \ --pids-limit 100 \ --health-cmd="curl -f http://localhost:8000/health || exit 1" \ --health-interval=30s \ --health-retries=3 \ --network backend \ -p 127.0.0.1:8000:8000 \ myapp:1.2.3

Custom Seccomp Profile

json
{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_AARCH64" ], "syscalls": [ { "names": [ "accept4", "bind", "brk", "clone", "close", "connect", "epoll_create1", "epoll_ctl", "epoll_wait", "exit", "exit_group", "fcntl", "fstat", "futex", "getpid", "getsockname", "getsockopt", "listen", "mmap", "mprotect", "munmap", "open", "openat", "read", "recvfrom", "recvmsg", "rt_sigaction", "rt_sigprocmask", "sendmsg", "sendto", "setsockopt", "socket", "write" ], "action": "SCMP_ACT_ALLOW" } ] }

Secrets Management

Using Docker Secrets (Swarm)

bash
# Create secret echo "mysecretpassword" | docker secret create db_password - # Use in service docker service create \ --name myapp \ --secret db_password \ myapp:latest
javascript
// Reading secret in application const fs = require('fs'); function getSecret(secretName) { try { // Docker secrets are mounted at /run/secrets/ return fs.readFileSync(`/run/secrets/${secretName}`, 'utf8').trim(); } catch (error) { console.error(`Failed to read secret ${secretName}:`, error); process.exit(1); } } const dbPassword = getSecret('db_password');

Using Environment Variables Securely

bash
# WRONG: Secrets in command line (visible in ps, history) docker run -e DB_PASSWORD=secret myapp # BETTER: Use env file (don't commit to git) docker run --env-file .env myapp # BEST: Use secrets management docker run --secret db_password myapp

Network Security

bash
# Create isolated network docker network create --driver bridge --internal backend # Create external-facing network docker network create --driver bridge frontend # Run containers in appropriate networks docker run -d --name api --network backend api:latest docker run -d --name web --network frontend --network backend web:latest # Inspect network docker network inspect backend

Docker Daemon Security

daemon.json Configuration

json
{ "icc": false, "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }, "live-restore": true, "userland-proxy": false, "no-new-privileges": true, "seccomp-profile": "/etc/docker/seccomp-profile.json", "userns-remap": "default" }

Image Signing and Verification

bash
# Enable Docker Content Trust export DOCKER_CONTENT_TRUST=1 # Push signed image docker push myregistry.com/myapp:latest # Pull and verify signature docker pull myregistry.com/myapp:latest # Generate keys docker trust key generate mykey # Sign image docker trust sign myregistry.com/myapp:latest

Security Scanning in CI/CD

yaml
# GitHub Actions example name: Docker Security Scan on: push: branches: [ main ] pull_request: branches: [ main ] jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build image run: docker build -t myapp:${{ github.sha }} . - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: image-ref: myapp:${{ github.sha }} format: 'sarif' output: 'trivy-results.sarif' severity: 'CRITICAL,HIGH' exit-code: '1' - name: Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@v2 if: always() with: sarif_file: 'trivy-results.sarif' - name: Docker Scout uses: docker/scout-action@v1 with: command: cves image: myapp:${{ github.sha }} only-severities: critical,high exit-code: true

Docker Security Checklist

  • ✅ Use specific image versions, not latest
  • ✅ Run containers as non-root user
  • ✅ Use minimal base images (Alpine, Distroless)
  • ✅ Scan images for vulnerabilities regularly
  • ✅ Never store secrets in images
  • ✅ Use multi-stage builds
  • ✅ Drop unnecessary capabilities
  • ✅ Use read-only filesystems when possible
  • ✅ Set resource limits (CPU, memory)
  • ✅ Enable security options (no-new-privileges, seccomp)
  • ✅ Use private registries with authentication
  • ✅ Sign and verify images
  • ✅ Implement health checks
  • ✅ Use isolated networks
  • ✅ Enable Docker Content Trust
  • ✅ Regular security updates
  • ✅ Monitor and log container activity

Conclusion

Securing Docker containers requires a defense-in-depth approach covering images, runtime configuration, network isolation, and secrets management. By following these best practices—using minimal base images, running as non-root, scanning for vulnerabilities, and properly configuring security options—you significantly reduce your container attack surface.

Container security is an evolving field requiring continuous monitoring and updates. For comprehensive Docker security assessments and container infrastructure reviews, contact the Whitespots team for expert consultation.