Container Security
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:
┌─────────────────────────────────────────────────────────┐
│ 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
# 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/pythonUse Minimal Images
# 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/nodejs20Image Security Comparison
| Image Type | Size | Shell | Package Manager | Attack Surface |
|---|---|---|---|---|
| Full | ~1GB | Yes | Yes | High |
| Slim | ~150MB | Yes | Yes | Medium |
| Alpine | ~50-150MB | Yes | Yes | Low |
| Distroless | ~20-50MB | No | No | Minimal |
| Scratch | 0MB | No | No | Absolute minimum |
Pin Image Versions
# 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
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
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
# 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/gidRead-Only Containers
Read-Only Filesystem
# 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 \
myappDockerfile for Read-Only
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"]# docker-compose.yml
services:
app:
image: myapp
read_only: true
tmpfs:
- /tmp
- /app/cache
volumes:
- logs:/app/logsLimiting Capabilities
Drop All Capabilities
# 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 myappCommon Capabilities
# docker-compose.yml
services:
app:
image: myapp
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Bind to ports < 1024Linux Capabilities
| Capability | Purpose | Usually Needed? |
|---|---|---|
NET_BIND_SERVICE | Bind ports < 1024 | Sometimes |
CHOWN | Change file ownership | Rarely |
DAC_OVERRIDE | Bypass file permissions | No |
SETUID | Change process UID | No |
SYS_ADMIN | Various admin operations | No |
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:
# Use default seccomp profile (recommended)
docker run --security-opt seccomp=default myapp
# Custom seccomp profile
docker run --security-opt seccomp=/path/to/profile.json myappAppArmor Profiles
# Use default AppArmor profile
docker run --security-opt apparmor=docker-default myapp
# Custom profile
docker run --security-opt apparmor=my-custom-profile myappNo New Privileges
# Prevent privilege escalation
docker run --security-opt no-new-privileges myapp# docker-compose.yml
services:
app:
image: myapp
security_opt:
- no-new-privileges:trueSecrets Management
Don't Embed Secrets in Images
# NEVER DO THIS
ENV API_KEY=supersecret123
RUN echo "password" > /app/config
# Secrets are visible in image history
docker history myimageUse Environment Variables at Runtime
# Pass secrets at runtime
docker run -e API_KEY=secret myapp
# Or from file
docker run --env-file .env myappDocker Secrets (Swarm/Compose)
# 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: trueBuildKit Secrets
# 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"]# 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
# 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.0Trivy
# 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:latestSnyk
# Authenticate
snyk auth
# Scan image
snyk container test myapp:latest
# Monitor continuously
snyk container monitor myapp:latestCI Integration
# .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
# 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# docker-compose.yml
services:
app:
image: myapp
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
pids: 100
reservations:
cpus: "0.5"
memory: 256MNetwork Security
# 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 myappLogging and Auditing
# 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 myappSecurity 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
# 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
| Flag | Purpose |
|---|---|
--read-only | Read-only root filesystem |
--cap-drop=ALL | Drop all capabilities |
--security-opt no-new-privileges | Prevent privilege escalation |
--memory | Memory limit |
--pids-limit | Process limit |
-p 127.0.0.1:8080:80 | Localhost-only binding |
Scanning Commands
| Tool | Command |
|---|---|
| Docker Scout | docker scout cves IMAGE |
| Trivy | trivy image IMAGE |
| Snyk | snyk container test IMAGE |
| Grype | grype IMAGE |
In the next chapter, we'll explore production deployment strategies for containerized applications.