Skip to content

Container Security

Running containers as root is the Docker equivalent of leaving your front door wide open with a neon "VACANCY" sign — and roughly 80% of public Docker images do exactly this because every tutorial ever written ignores security for simplicity Container security isn't optional in production. It's the difference between a bored attacker finding a foothold and them moving on to an easier target

Running as Non-Root — The Single Most Important Thing

# Bad — every tutorial on Earth
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

# Good — minimal privilege
FROM node:20-slim

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

WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm install --production

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

If your container needs port 80 or 443 (privileged ports under 1024), you either: * Run as root (don't) * Use a reverse proxy like Nginx that handles port binding * Add the CAP_NET_BIND_SERVICE capability instead

# Better — specific capability instead of full root
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/node

# Or use a reverse proxy on port 80 that forwards to your app on 3000

Read-Only Root Filesystem — Lock Down / After Deploy

Your app writes logs and temp files to / — that's a security nightmare if an attacker gains RCE and can modify binaries or plant persistent backdoors

services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp           # Writeable temp directory in memory
      - /var/run       # Writeable runtime directory
    volumes:
      - app_data:/app/data   # Persistent writeable data

With read_only: true, the container filesystem is immutable. Any process trying to write to / (except mounted volumes and tmpfs) gets a permissions error. Attackers can't modify your binaries

Image Scanning — Don't Run Known-Vulnerable Shit

Trivy is free , fast , and actually useful. Run it on every image before pushing to production

# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Scan an image
trivy image node:20-slim

# Scan with severity filter
trivy image --severity CRITICAL,HIGH myapp:latest

# Scan Dockerfile for misconfigurations
trivy config --severity CRITICAL,HIGH .

# Scan your Compose file
trivy config docker-compose.yml

Docker Scout (built into Docker Desktop):

# Enable Scout
docker scout quickview node:20-slim

# Compare to a baseline
docker scout compare myapp:latest --to node:20-slim

GitHub Actions integration:

- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: 'myapp:latest'
    format: 'sarif'
    output: 'trivy-results.sarif'

Secret Management in Docker — You're Doing It Wrong

# NEVER do this
ENV DATABASE_URL=postgres://admin:supersecret@db:5432/prod
# This is baked into the image. Anyone who pulls the image sees it.

Build-time secrets (Docker BuildKit):

# docker-compose.yml or Dockerfile
# Syntax: docker build --secret id=mysecret,src=./secret.txt .

RUN --mount=type=secret,id=mysecret \
    export MY_SECRET=$(cat /run/secrets/mysecret) && \
    echo "Build step that needs secret"

Runtime secrets (Docker Compose):

services:
  app:
    secrets:
      - db_password
    environment:
      - DATABASE_URL=postgres://user:${DB_PASSWORD}@db:5432/prod

secrets:
  db_password:
    file: ./secrets/db_password.txt    # Never commit this

Avoiding Privilege Escalation

services:
  app:
    image: myapp
    # Prevent container from gaining more privileges
    security_opt:
      - no-new-privileges:true

    # Drop all capabilities , add back only what's needed
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE    # Only if app needs privileged ports

    # Prevent privilege escalation vectors
    privileged: false        # Never set to true in production

Resource Limits — Prevent DoS Against Your Own Infrastructure

A memory leak in one container shouldn't take down your entire host

services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
        reservations:
          memory: 256M
          cpus: '0.25'
# Docker run equivalents
docker run -d --memory="512m" --cpus="0.5" myapp

Why this matters: * No memory limit = container can OOM your entire host * No CPU limit = one CPU-intensive process starves all other containers * OOM kills happen silently — you get a stopped container with exit code 137 * Resource limits in production are non-negotiable — every container needs upper bounds

Docker Bench Security — Automated Security Audit

# Run the CIS Docker Benchmark
docker run --rm \
  --net host \
  --pid host \
  --userns host \
  --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc \
  -v /usr/bin/containerd:/usr/bin/containerd \
  -v /usr/bin/runc:/usr/bin/runc \
  -v /usr/lib/systemd:/usr/lib/systemd \
  -v /var/lib:/var/lib \
  -v /var/run/docker.sock:/var/run/docker.sock \
  docker/docker-bench-security

Prerequisites

Dockerfile section , Docker Compose section


next → devops_05_ci_intro.md