Containers provide isolation, but they’re not magic security boundaries. A misconfigured container can expose your entire host. Let’s fix that.

Don’t Run as Root

The single biggest mistake: running containers as root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Bad: runs as root by default
FROM node:20
COPY . /app
CMD ["node", "server.js"]

# Good: create and use non-root user
FROM node:20
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "server.js"]

Why it matters: if an attacker escapes the container while running as root, they’re root on the host. As a non-root user, they’re limited.

1
2
3
# Verify your container isn't running as root
docker run --rm your-image whoami
# Should NOT print "root"

Use Minimal Base Images

Every package is attack surface. Smaller images = fewer vulnerabilities.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Bad: 1GB+ image with gcc, perl, etc.
FROM ubuntu:22.04

# Better: ~130MB
FROM node:20-slim

# Best: ~5MB, no shell
FROM node:20-alpine
# Or for compiled languages:
FROM gcr.io/distroless/static-debian12

Distroless images contain only your app and runtime—no shell, no package manager, nothing for attackers to use.

1
2
3
4
5
6
7
8
9
# Multi-stage build with distroless
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
ENTRYPOINT ["/server"]

Scan Images for Vulnerabilities

Build scanning into your CI/CD:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Trivy - fast and comprehensive
trivy image your-image:latest

# Output
your-image:latest (alpine 3.19)
Total: 3 (HIGH: 2, CRITICAL: 1)

┌──────────────┬────────────────┬──────────┐
│   Library    │ Vulnerability  │ Severity │
├──────────────┼────────────────┼──────────┤
│ openssl      │ CVE-2024-XXXX  │ CRITICAL │
│ curl         │ CVE-2024-YYYY  │ HIGH     │
└──────────────┴────────────────┴──────────┘

Automate in CI:

1
2
3
4
5
6
7
# GitHub Actions
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: '${{ env.IMAGE }}'
    exit-code: '1'  # Fail build on HIGH/CRITICAL
    severity: 'CRITICAL,HIGH'

Pin Image Versions

Never use latest in production:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Bad: who knows what you'll get tomorrow
FROM node:latest

# Bad: still too vague
FROM node:20

# Good: specific version
FROM node:20.11.1-alpine3.19

# Best: pin by digest (immutable)
FROM node@sha256:abc123...

Digests guarantee you get the exact same image every time, even if tags are moved.

Drop Capabilities

Linux capabilities give processes specific privileges. Containers get many by default—drop what you don’t need:

1
2
3
4
5
6
7
8
# See default capabilities
docker run --rm alpine cat /proc/1/status | grep Cap

# Run with minimal capabilities
docker run --rm \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  your-image

Common capabilities to drop:

  • SYS_ADMIN - broad privileges, almost never needed
  • NET_RAW - raw sockets, needed only for ping/traceroute
  • SYS_PTRACE - debugging, not for production
1
2
3
4
5
6
7
8
# docker-compose.yml
services:
  app:
    image: your-image
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Read-Only Filesystem

Prevent attackers from writing malware:

1
docker run --rm --read-only your-image

Your app needs temp files? Mount specific writable directories:

1
2
3
4
5
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid \
  --tmpfs /app/cache:rw,noexec,nosuid \
  your-image
1
2
3
4
5
6
7
# docker-compose.yml
services:
  app:
    image: your-image
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid

No Privileged Mode

Never use --privileged in production:

1
2
3
4
5
# NEVER do this in production
docker run --privileged your-image

# This gives container full access to host devices,
# can load kernel modules, and more

If something “only works with –privileged,” find the specific capability it needs and grant only that.

Seccomp and AppArmor

Seccomp limits which syscalls a container can make:

1
2
3
4
5
# Use Docker's default seccomp profile (good baseline)
docker run --security-opt seccomp=default your-image

# Or a custom restrictive profile
docker run --security-opt seccomp=/path/to/profile.json your-image

AppArmor/SELinux add mandatory access controls:

1
2
# Apply AppArmor profile
docker run --security-opt apparmor=docker-default your-image

Resource Limits

Prevent container from consuming all host resources (DoS protection):

1
2
3
4
5
6
docker run \
  --memory=512m \
  --memory-swap=512m \
  --cpus=0.5 \
  --pids-limit=100 \
  your-image
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# docker-compose.yml
services:
  app:
    image: your-image
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          memory: 256M

Network Segmentation

Don’t let every container talk to every other container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# docker-compose.yml
services:
  frontend:
    networks:
      - frontend-net
  
  backend:
    networks:
      - frontend-net
      - backend-net
  
  database:
    networks:
      - backend-net  # Only backend can reach DB

networks:
  frontend-net:
  backend-net:
    internal: true  # No external access

Secrets Management

Never bake secrets into images:

1
2
3
# NEVER do this
ENV DATABASE_PASSWORD=supersecret
COPY .env /app/.env

Use Docker secrets or external secret managers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Docker secrets (Swarm)
echo "supersecret" | docker secret create db_password -

# docker-compose with secrets
services:
  app:
    secrets:
      - db_password
secrets:
  db_password:
    external: true

For Kubernetes, use sealed secrets or external secret operators:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    env:
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: db-secrets
          key: password

Runtime Security Monitoring

Detect anomalies in running containers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Falco - runtime security monitoring
helm install falco falcosecurity/falco

# Example Falco rule
- rule: Shell Spawned in Container
  condition: >
    spawned_process and
    container and
    shell_procs
  output: "Shell spawned in container (user=%user.name container=%container.name)"
  priority: WARNING

Security Checklist

Before deploying to production:

  • Non-root user in Dockerfile
  • Minimal base image (alpine/distroless)
  • Image scanned, no CRITICAL/HIGH CVEs
  • Image version pinned (or digest)
  • Capabilities dropped (at minimum --cap-drop=ALL)
  • Read-only filesystem where possible
  • No --privileged flag
  • Resource limits set
  • Network segmented appropriately
  • No secrets in image or environment
  • Seccomp profile enabled

Quick Wins

Start with these three changes today:

1
2
3
4
5
6
7
# 1. Add USER directive
USER nonroot

# 2. Use slim/alpine base
FROM node:20-alpine

# 3. Don't run as privileged (check your docker-compose)

Then gradually add capabilities dropping, read-only filesystems, and scanning to your CI.


Container security isn’t about perfect isolation—it’s about making attacks harder and blast radius smaller. Layer your defenses.