Containers provide isolation, not security. A misconfigured container is as vulnerable as a misconfigured server — sometimes more so, because containers make it easy to ship the same vulnerability everywhere.

These basics prevent the most common container security issues.

Don’t Run as Root

The most common mistake:

 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 useradd -r -u 1001 appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["node", "server.js"]

Many base images include non-root users:

1
2
FROM node:20
USER node

Use Minimal Base Images

Smaller images = smaller attack surface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ❌ Full image: ~1GB, includes compilers, shells, etc.
FROM python:3.11

# ✅ Slim image: ~150MB, essential packages only
FROM python:3.11-slim

# ✅ Alpine: ~50MB, minimal Linux
FROM python:3.11-alpine

# ✅ Distroless: ~20MB, no shell, no package manager
FROM gcr.io/distroless/python3

Distroless images are ideal for production — attackers can’t get a shell because there isn’t one.

Multi-Stage Builds

Don’t ship build tools:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

The final image contains only runtime dependencies.

Scan for Vulnerabilities

Scan images before deploying:

1
2
3
4
5
6
7
8
# Trivy (open source, excellent)
trivy image myapp:latest

# Docker Scout
docker scout cves myapp:latest

# Snyk
snyk container test myapp:latest

Integrate into CI:

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

Fail builds on critical vulnerabilities.

Pin Dependencies

Avoid floating tags:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ❌ Bad: "latest" changes without notice
FROM node:latest

# ❌ Bad: Major version can have breaking changes
FROM node:20

# ✅ Good: Pin to specific version
FROM node:20.11.0-slim

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

Don’t Embed Secrets

Secrets in images are extractable:

1
2
3
4
5
6
7
8
# ❌ Bad: Secret in image layer (even if deleted later)
COPY .env /app/.env
RUN npm run build
RUN rm /app/.env  # Still in previous layer!

# ❌ Bad: Secret in build arg (visible in image history)
ARG API_KEY
ENV API_KEY=$API_KEY

Instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# ✅ Good: Mount secrets at runtime
CMD ["node", "server.js"]
# Run with: docker run -e API_KEY=xxx myapp

# ✅ Good: Use Docker secrets (Swarm/Compose)
# docker-compose.yml
secrets:
  api_key:
    file: ./api_key.txt
services:
  app:
    secrets:
      - api_key

Read-Only Filesystem

Prevent runtime modifications:

1
2
3
4
5
6
7
8
# docker-compose.yml
services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp
      - /var/run
1
2
# Or with docker run
docker run --read-only --tmpfs /tmp myapp

Drop Capabilities

Containers get Linux capabilities by default. Drop unnecessary ones:

1
2
3
4
5
6
7
# docker-compose.yml
services:
  app:
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only if binding to ports < 1024
1
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

Resource Limits

Prevent resource exhaustion:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# docker-compose.yml
services:
  app:
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

No Privileged Mode

Never use privileged unless absolutely necessary:

1
2
3
4
5
# ❌ Very bad: Full host access
docker run --privileged myapp

# ✅ Good: Specific capabilities if needed
docker run --cap-add=SYS_PTRACE myapp

Privileged containers can escape to the host.

Network Isolation

Don’t expose unnecessary ports:

1
2
3
4
5
6
7
8
9
# docker-compose.yml
services:
  app:
    ports:
      - "8080:8080"  # Only expose what's needed
  
  db:
    # No ports exposed to host
    # Only accessible from app via internal network

Use internal networks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
networks:
  frontend:
  backend:
    internal: true  # No external access

services:
  web:
    networks:
      - frontend
      - backend
  
  db:
    networks:
      - backend  # Only accessible from backend network

Security Scanning in Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM node:20-slim

# Update packages to get security fixes
RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*

# Create non-root user
RUN useradd -r -u 1001 -g root appuser

WORKDIR /app

# Copy package files first (better caching)
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy app with correct ownership
COPY --chown=appuser:root . .

# Switch to non-root user
USER appuser

# Use exec form (signals handled correctly)
CMD ["node", "server.js"]

Runtime Security

Monitor containers in production:

1
2
3
4
5
6
7
8
# Falco: Runtime threat detection
helm install falco falcosecurity/falco

# Example Falco rule: Alert on shell in container
- rule: Shell spawned in container
  condition: container and proc.name = bash
  output: "Shell spawned (container=%container.name)"
  priority: WARNING

Security Checklist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- [ ] Non-root user in Dockerfile
- [ ] Minimal base image (slim/alpine/distroless)
- [ ] Multi-stage build (no build tools in production)
- [ ] Pinned base image version
- [ ] No secrets in image
- [ ] Vulnerability scanning in CI
- [ ] Read-only filesystem where possible
- [ ] Dropped unnecessary capabilities
- [ ] Resource limits set
- [ ] No privileged mode
- [ ] Network isolation configured
- [ ] Health checks defined

Container security isn’t one thing — it’s layers. Minimal images reduce attack surface. Non-root users limit damage. Scanning catches known vulnerabilities. Runtime monitoring catches exploitation.

The container that seems secure because it “works” is the one that gets compromised. Build security into the image, the deployment, and the runtime. The few minutes it takes pay off when you’re not explaining a breach.