Containers are not security boundaries. They share the host kernel. A container running as root with unrestricted capabilities is one kernel exploit away from owning the entire host — along with every other container on it.
Most teams treat Docker as a packaging format and ignore the security implications. This guide covers how to build, ship, and run containers that are hardened against the attacks that actually happen in production.
Image Security: Start Before Runtime
Minimal Base Images
| Base Image | Size | Packages | Attack Surface |
|---|
ubuntu:24.04 | 78MB | 200+ | Large — shells, package managers, utilities |
python:3.12 | 1.0GB | 400+ | Very large — full Debian with build tools |
python:3.12-slim | 130MB | 100+ | Medium — reduced Debian |
python:3.12-alpine | 50MB | 25 | Small — musl libc (compatibility issues) |
gcr.io/distroless/python3 | 52MB | ~5 | Minimal — no shell, no package manager |
scratch | 0MB | 0 | None — only your binary |
# ✅ Multi-stage build with distroless runtime
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/app/deps -r requirements.txt
COPY . .
# Stage 2: Runtime (minimal, no shell, no package manager)
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY --from=builder /app/deps /app/deps
COPY --from=builder /app/src /app/src
ENV PYTHONPATH=/app/deps
USER nonroot:nonroot
EXPOSE 8080
CMD ["src/main.py"]
Never Run as Root
# ❌ Bad: runs as root by default
FROM python:3.12-slim
COPY . /app
CMD ["python", "app.py"]
# ✅ Good: explicit non-root user
FROM python:3.12-slim
# Create non-root user
RUN groupadd --gid 1001 appuser && \
useradd --uid 1001 --gid 1001 --shell /bin/false appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
Image Scanning
Scan every image before it reaches production. Automate this in CI/CD so no unscanned image deploys.
# CI pipeline: scan before push
steps:
- name: Build image
run: docker build -t myapp:$SHA .
- name: Scan for vulnerabilities
run: |
trivy image --severity HIGH,CRITICAL \
--exit-code 1 \
--ignore-unfixed \
myapp:$SHA
- name: Push (only if scan passes)
run: docker push registry.example.com/myapp:$SHA
| Scanner | What It Finds | Speed |
|---|
| Trivy | CVEs in OS packages + app dependencies | Fast |
| Grype | CVEs in OS packages + app dependencies | Fast |
| Snyk Container | CVEs + license issues + configuration | Medium |
| Docker Scout | CVEs with remediation advice | Medium |
Runtime Security
Read-Only File System
# Kubernetes: read-only root filesystem
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# If the app needs to write (logs, temp files):
volumeMounts:
- name: tmp
mountPath: /tmp
- name: logs
mountPath: /var/log/app
volumes:
- name: tmp
emptyDir: {}
- name: logs
emptyDir: {}
Security Context Checklist
| Setting | Value | Why |
|---|
runAsNonRoot | true | Prevents root execution |
readOnlyRootFilesystem | true | Prevents file system modification |
allowPrivilegeEscalation | false | Prevents gaining extra privileges |
capabilities.drop | ALL | Removes all Linux capabilities |
capabilities.add | Only what’s needed | Principle of least privilege |
seccompProfile | RuntimeDefault | Restrict system calls |
Secrets in Containers
| Method | Security Level | Complexity |
|---|
| Environment variables | Low (visible in docker inspect) | Low |
| Docker secrets | Medium (encrypted at rest, swarm only) | Low |
| Mounted volumes from Vault | High (dynamic, rotated) | Medium |
| Init container + sidecar | Highest (secrets never on disk) | High |
# ❌ Never: secrets in environment variables in manifests
env:
- name: DATABASE_URL
value: "postgres://admin:password123@db:5432/production"
# ✅ Better: reference Kubernetes secrets
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-credentials
key: url
# ✅ Best: External Secrets Operator + Vault
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
spec:
secretStoreRef:
name: vault-backend
target:
name: database-credentials
data:
- secretKey: url
remoteRef:
key: production/database
property: connection_string
Supply Chain Security
| Practice | What It Prevents |
|---|
| Pin image digests (not just tags) | Malicious tag overwrite |
| Sign images (Cosign, Notary) | Unauthorized image deployment |
| Use private registry | Pulling compromised public images |
| Audit base images | Known vulnerabilities in base layer |
| SBOM generation | Unknown components in your images |
# ❌ Mutable tag — could change underneath you
FROM python:3.12-slim
# ✅ Pinned digest — immutable, verifiable
FROM python:3.12-slim@sha256:abc123def456...
Implementation Checklist