Learning Guides
Menu

Container Security

9 min readDocker for Developers

Container Security

Containers provide isolation, but security requires deliberate effort. This chapter covers security best practices from image building to runtime, vulnerability scanning, and defense-in-depth strategies.

Security Principles

Container security follows the principle of least privilege and defense in depth:

PLAINTEXT
┌─────────────────────────────────────────────────────────┐
│                    Defense in Depth                      │
│  ┌─────────────────────────────────────────────────┐    │
│  │  Layer 1: Secure Base Images                    │    │
│  │  ┌───────────────────────────────────────────┐  │    │
│  │  │  Layer 2: Minimal Dependencies            │  │    │
│  │  │  ┌─────────────────────────────────────┐  │  │    │
│  │  │  │  Layer 3: Non-Root User            │  │  │    │
│  │  │  │  ┌───────────────────────────────┐  │  │  │    │
│  │  │  │  │  Layer 4: Read-Only FS       │  │  │  │    │
│  │  │  │  │  ┌─────────────────────────┐  │  │  │  │    │
│  │  │  │  │  │  Layer 5: Resource     │  │  │  │  │    │
│  │  │  │  │  │  Limits               │  │  │  │  │    │
│  │  │  │  │  └─────────────────────────┘  │  │  │  │    │
│  │  │  │  └───────────────────────────────┘  │  │  │    │
│  │  │  └─────────────────────────────────────┘  │  │    │
│  │  └───────────────────────────────────────────┘  │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

Note

Security is not a single feature but a series of layers. Each layer provides protection even if another fails.

Secure Base Images

Choose Trusted Sources

DOCKERFILE
# GOOD: Official images
FROM node:20-alpine
FROM python:3.12-slim
FROM nginx:1.25-alpine
 
# AVOID: Unknown sources
# FROM random-user/node-custom
# FROM unverified-org/python

Use Minimal Images

DOCKERFILE
# Large attack surface (~1GB)
FROM node:20
 
# Reduced attack surface (~150MB)
FROM node:20-slim
 
# Minimal attack surface (~180MB)
FROM node:20-alpine
 
# Smallest attack surface (~20MB) - no shell
FROM gcr.io/distroless/nodejs20

Image Security Comparison

Image TypeSizeShellPackage ManagerAttack Surface
Full~1GBYesYesHigh
Slim~150MBYesYesMedium
Alpine~50-150MBYesYesLow
Distroless~20-50MBNoNoMinimal
Scratch0MBNoNoAbsolute minimum

Pin Image Versions

DOCKERFILE
# BAD: Can change without notice
FROM node:latest
FROM python:3
 
# GOOD: Specific version
FROM node:20.11.0-alpine3.19
FROM python:3.12.1-slim-bookworm
 
# BEST: Use digest for immutability
FROM node:20-alpine@sha256:abc123...

Warning

Never use latest in production. Tag changes can introduce security vulnerabilities or breaking changes without warning.

Running as Non-Root

Create and Use Non-Root User

DOCKERFILE
FROM node:20-alpine
 
WORKDIR /app
 
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
 
# Copy with correct ownership
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci
 
COPY --chown=appuser:appgroup . .
 
# Switch to non-root user
USER appuser
 
CMD ["node", "server.js"]

Python Non-Root Pattern

DOCKERFILE
FROM python:3.12-slim
 
# Create user before installing dependencies
RUN useradd --create-home --shell /bin/bash appuser
 
WORKDIR /home/appuser/app
 
# Install dependencies as root, then switch
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
 
# Copy app and set ownership
COPY --chown=appuser:appuser . .
 
USER appuser
 
CMD ["python", "app.py"]

Verify Non-Root

BASH
# Check container runs as non-root
docker run --rm myimage whoami
# Should NOT output: root
 
docker run --rm myimage id
# Should show non-root uid/gid

Read-Only Containers

Read-Only Filesystem

BASH
# Run with read-only filesystem
docker run --read-only myapp
 
# Allow specific writable directories
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/cache \
  -v logs:/app/logs \
  myapp

Dockerfile for Read-Only

DOCKERFILE
FROM node:20-alpine
 
WORKDIR /app
COPY --chown=node:node . .
 
# Create writable directories
RUN mkdir -p /app/tmp /app/logs && \
    chown -R node:node /app/tmp /app/logs
 
USER node
 
# App knows to use these directories
ENV TEMP_DIR=/app/tmp
ENV LOG_DIR=/app/logs
 
CMD ["node", "server.js"]
YAML
# docker-compose.yml
services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp
      - /app/cache
    volumes:
      - logs:/app/logs

Limiting Capabilities

Drop All Capabilities

BASH
# Drop all capabilities
docker run --cap-drop=ALL myapp
 
# Add back only what's needed
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

Common Capabilities

YAML
# docker-compose.yml
services:
  app:
    image: myapp
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE # Bind to ports < 1024

Linux Capabilities

CapabilityPurposeUsually Needed?
NET_BIND_SERVICEBind ports < 1024Sometimes
CHOWNChange file ownershipRarely
DAC_OVERRIDEBypass file permissionsNo
SETUIDChange process UIDNo
SYS_ADMINVarious admin operationsNo

Warning

Never add SYS_ADMIN capability—it essentially gives root-like powers. If your app needs it, reconsider the architecture.

Security Options

Seccomp Profiles

Seccomp filters system calls the container can make:

BASH
# Use default seccomp profile (recommended)
docker run --security-opt seccomp=default myapp
 
# Custom seccomp profile
docker run --security-opt seccomp=/path/to/profile.json myapp

AppArmor Profiles

BASH
# Use default AppArmor profile
docker run --security-opt apparmor=docker-default myapp
 
# Custom profile
docker run --security-opt apparmor=my-custom-profile myapp

No New Privileges

BASH
# Prevent privilege escalation
docker run --security-opt no-new-privileges myapp
YAML
# docker-compose.yml
services:
  app:
    image: myapp
    security_opt:
      - no-new-privileges:true

Secrets Management

Don't Embed Secrets in Images

DOCKERFILE
# NEVER DO THIS
ENV API_KEY=supersecret123
RUN echo "password" > /app/config
 
# Secrets are visible in image history
docker history myimage

Use Environment Variables at Runtime

BASH
# Pass secrets at runtime
docker run -e API_KEY=secret myapp
 
# Or from file
docker run --env-file .env myapp

Docker Secrets (Swarm/Compose)

YAML
# docker-compose.yml
services:
  app:
    image: myapp
    secrets:
      - db_password
      - api_key
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
 
secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    external: true

BuildKit Secrets

DOCKERFILE
# syntax=docker/dockerfile:1
FROM node:20-alpine
 
WORKDIR /app
 
# Secret available only during this RUN
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
 
COPY . .
CMD ["npm", "start"]
BASH
# Build with secret
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Note

BuildKit secrets are never stored in image layers—they're only available during the build step that requests them.

Vulnerability Scanning

Docker Scout

BASH
# Scan local image
docker scout cves myapp:latest
 
# Scan with recommendations
docker scout recommendations myapp:latest
 
# Quick vulnerability summary
docker scout quickview myapp:latest
 
# Compare images
docker scout compare myapp:1.0 --to myapp:2.0

Trivy

BASH
# Install Trivy
brew install aquasecurity/trivy/trivy
 
# Scan image
trivy image myapp:latest
 
# Scan with severity filter
trivy image --severity HIGH,CRITICAL myapp:latest
 
# Output as JSON
trivy image -f json -o results.json myapp:latest
 
# Scan and fail on vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest

Snyk

BASH
# Authenticate
snyk auth
 
# Scan image
snyk container test myapp:latest
 
# Monitor continuously
snyk container monitor myapp:latest

CI Integration

YAML
# .github/workflows/security.yml
name: Security Scan
 
on: [push, pull_request]
 
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - 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"
 
      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: "trivy-results.sarif"

Runtime Security

Resource Limits

BASH
# Limit memory
docker run --memory=512m --memory-swap=512m myapp
 
# Limit CPU
docker run --cpus=1.5 myapp
 
# Limit processes
docker run --pids-limit=100 myapp
YAML
# docker-compose.yml
services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
          pids: 100
        reservations:
          cpus: "0.5"
          memory: 256M

Network Security

BASH
# Disable inter-container communication
docker network create --opt com.docker.network.bridge.enable_icc=false isolated
 
# Internal network (no external access)
docker network create --internal backend
 
# Bind to localhost only
docker run -p 127.0.0.1:8080:80 myapp

Logging and Auditing

BASH
# Enable Docker events logging
docker events
 
# View container logs
docker logs --details myapp
 
# Send to centralized logging
docker run --log-driver=syslog --log-opt syslog-address=tcp://logserver:514 myapp

Security Hardening Checklist

Image Security

  • Use official/verified base images
  • Pin specific version tags
  • Use minimal base images (alpine/distroless)
  • Run as non-root user
  • Scan for vulnerabilities regularly
  • Sign images with content trust

Build Security

  • Use multi-stage builds
  • Don't include secrets in images
  • Use .dockerignore
  • Minimize layers and installed packages
  • Remove build tools in final image

Runtime Security

  • Run containers read-only
  • Drop all capabilities, add only needed
  • Enable no-new-privileges
  • Set resource limits
  • Use network segmentation
  • Mount filesystems read-only where possible

Operational Security

  • Keep Docker updated
  • Use secrets management
  • Implement logging and monitoring
  • Regular vulnerability scanning
  • Incident response plan

Complete Secure Dockerfile

DOCKERFILE
# syntax=docker/dockerfile:1
 
# Build stage
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Copy only package files first
COPY package*.json ./
 
# Install dependencies
RUN npm ci --only=production
 
# Copy source
COPY . .
 
# Build
RUN npm run build
 
# Production stage
FROM gcr.io/distroless/nodejs20-debian12
 
# Labels for tracking
LABEL org.opencontainers.image.source="https://github.com/company/app"
LABEL org.opencontainers.image.version="1.0.0"
 
WORKDIR /app
 
# Copy from builder with minimal files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
 
# Use non-root user (distroless has 'nonroot')
USER nonroot
 
# Expose port
EXPOSE 3000
 
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
  CMD ["/nodejs/bin/node", "healthcheck.js"]
 
# Start application
CMD ["dist/server.js"]

Quick Reference

Security Flags

FlagPurpose
--read-onlyRead-only root filesystem
--cap-drop=ALLDrop all capabilities
--security-opt no-new-privilegesPrevent privilege escalation
--memoryMemory limit
--pids-limitProcess limit
-p 127.0.0.1:8080:80Localhost-only binding

Scanning Commands

ToolCommand
Docker Scoutdocker scout cves IMAGE
Trivytrivy image IMAGE
Snyksnyk container test IMAGE
Grypegrype IMAGE

In the next chapter, we'll explore production deployment strategies for containerized applications.