In the previous tutorial, you learned about security logging and monitoring. Now let us secure where your code runs. Docker containers are everywhere, but the default configuration is not secure enough for production. In this article, you will learn how to harden Docker containers, scan images for vulnerabilities, and manage secrets safely.

Why Container Security Matters

Containers provide isolation, but they are not virtual machines. By default:

  • Containers run as root — if an attacker breaks out, they have root on the host
  • Docker images contain hundreds of packages, many with known vulnerabilities
  • Secrets are often baked into images or passed as environment variables (visible in process lists)
  • Network ports are exposed by default — more attack surface

A compromised container can lead to:

  • Data theft from other containers on the same host
  • Lateral movement across your infrastructure
  • Cryptocurrency mining on your servers
  • Access to secrets and API keys

Rule 1: Never Run as Root

The most important Docker security rule. By default, containers run as root (UID 0). If an attacker gains code execution inside the container, they have root privileges.

Create a Non-Root User

Dockerfile:

FROM node:20-alpine

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set working directory
WORKDIR /app

# Copy files and install dependencies
COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Change ownership to non-root user
RUN chown -R appuser:appgroup /app

# Switch to non-root user
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

Go Dockerfile:

FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# Runtime: minimal image with non-root user
FROM scratch

COPY --from=builder /app/server /server

# Use numeric UID (scratch has no user database)
USER 65534

EXPOSE 8080
ENTRYPOINT ["/server"]

Python Dockerfile:

FROM python:3.12-slim

RUN groupadd -r appgroup && useradd -r -g appgroup appuser

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN chown -R appuser:appgroup /app

USER appuser

EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

Rule 2: Use Minimal Base Images

Smaller images have fewer packages, which means fewer vulnerabilities.

Base ImageSizePackagesUse Case
ubuntu:22.04~77 MB~100Development only
python:3.12~1 GB~400Development only
python:3.12-slim~150 MB~100Better for production
python:3.12-alpine~50 MB~30Good for production
node:20-alpine~130 MB~30Good for production
golang:1.23-alpine~250 MB~30Build stage only
gcr.io/distroless/static~2 MB0Best for Go/Rust
scratch0 MB0Best for static binaries

Multi-Stage Builds

Build in a full image, run in a minimal image:

# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# Stage 2: Run (minimal image)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot
ENTRYPOINT ["/server"]

The final image is ~5 MB instead of ~250 MB.

Rule 3: Scan Images for Vulnerabilities

Every image you use has potential vulnerabilities. Scan before deploying.

Trivy

# Scan a local image
trivy image myapp:latest

# Scan and fail on HIGH or CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

# Scan a Dockerfile (without building)
trivy config Dockerfile

Docker Scout (Built into Docker)

# Scan an image
docker scout cves myapp:latest

# Quick overview
docker scout quickview myapp:latest

GitHub Actions with Image Scanning

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Scan image
        uses: aquasecurity/trivy-action@v0.20.0
        with:
          image-ref: myapp:${{ github.sha }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'

Rule 4: Manage Secrets Properly

Never put secrets in Dockerfiles, images, or environment variables (visible via docker inspect).

BAD: Secrets in Dockerfile

# NEVER DO THIS — secrets are stored in image layers
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk_live_abc123

Even if you delete the ENV in a later layer, the secret is still in the previous layer.

BAD: Secrets in docker-compose.yml

# NEVER DO THIS — secrets in plain text
services:
  app:
    environment:
      - DATABASE_URL=postgres://user:password@db:5432/myapp

GOOD: Docker Secrets (Swarm)

# Create a secret
echo "postgres://user:password@db:5432/myapp" | docker secret create db_url -

# Use in docker-compose (Swarm mode)
services:
  app:
    image: myapp:latest
    secrets:
      - db_url

secrets:
  db_url:
    external: true

In the container, the secret is available as a file at /run/secrets/db_url.

GOOD: Mount secrets as files

# docker-compose.yml
services:
  app:
    image: myapp:latest
    volumes:
      - ./secrets/db_url.txt:/run/secrets/db_url:ro

Read the secret in code:

func loadSecret(name string) (string, error) {
    data, err := os.ReadFile("/run/secrets/" + name)
    if err != nil {
        return "", err
    }
    return strings.TrimSpace(string(data)), nil
}

func main() {
    dbURL, err := loadSecret("db_url")
    if err != nil {
        log.Fatal("Cannot load secret:", err)
    }
    // Use dbURL
}
def load_secret(name):
    with open(f"/run/secrets/{name}") as f:
        return f.read().strip()

db_url = load_secret("db_url")

Rule 5: Limit Container Capabilities

By default, Docker containers have many Linux capabilities they do not need.

Drop All Capabilities

# docker-compose.yml
services:
  app:
    image: myapp:latest
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only if binding to port < 1024

Read-Only Filesystem

services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp  # Allow writing to /tmp only

Resource Limits

Prevent a compromised container from consuming all host resources:

services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

Rule 6: Minimize Network Exposure

Only expose ports that are needed. Do not expose database ports to the host.

services:
  app:
    image: myapp:latest
    ports:
      - "8080:8080"  # Only expose the app port

  db:
    image: postgres:16-alpine
    # NO ports section — only accessible within the Docker network
    expose:
      - "5432"  # Only accessible to other containers

Rule 7: Keep Images Updated

Outdated base images contain known vulnerabilities. Update regularly.

# Check for updates
docker pull python:3.12-slim

# Rebuild with latest base
docker build --no-cache -t myapp:latest .

Automate with Dependabot or Renovate:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

Secure Dockerfile Checklist

# 1. Use specific version tags (never :latest in production)
FROM python:3.12.4-slim

# 2. Install only what you need
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# 3. Copy dependency files first (better caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 4. Copy application code
COPY . .

# 5. Create and use non-root user
RUN useradd -r -s /bin/false appuser
USER appuser

# 6. Use HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s \
    CMD curl -f http://localhost:8000/health || exit 1

# 7. Set read-only where possible
# (use --read-only flag in docker run)

# 8. Document the exposed port
EXPOSE 8000

# 9. Use exec form for CMD (proper signal handling)
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]

Prevention Checklist

DefensePriorityNotes
Run containers as non-root (USER directive)HighMost important Docker security rule
Use minimal base images (alpine, distroless, scratch)HighFewer packages = fewer vulnerabilities
Scan images with Trivy or Docker ScoutHighAutomate in CI/CD
Never put secrets in Dockerfiles or ENVHighUse Docker secrets or mounted files
Drop all capabilities, add only what is neededHighcap_drop: ALL in compose
Use multi-stage buildsHighSmaller final image
Pin base image versions (never use :latest)MediumReproducible builds
Enable read-only filesystemMediumPrevents file modification
Set resource limits (CPU, memory)MediumPrevent resource exhaustion
Do not expose unnecessary portsMediumMinimize attack surface
Update base images regularlyMediumAutomate with Dependabot
Use no-new-privileges security optionLowPrevents privilege escalation

What is Next?

In the next tutorial, you will get a Complete Security Checklist — a comprehensive guide covering every topic in this series, organized by priority, that you can use as a reference for every project.