Docker Security: Essential Best Practices for Container Hardening
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
- Running containers as root
- Using vulnerable base images
- Exposing sensitive data in images
- Excessive container privileges
- Unpatched dependencies
- Insecure network configurations
- 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.


