Docker Security
If you haven't yet, start with Introduction to Docker for the fundamentals - this page assumes you know what an image and a container are, and focuses entirely on hardening them for production.
The Docker Attack Surface
A container is not a security boundary by default - it's a process isolation mechanism. Containers share the host kernel, which means a container escape or kernel exploit can compromise the host and every other container on it. Docker security is about closing the gaps between "runs" and "runs safely":
| Layer | Risk if unhardened |
|---|---|
| Base image | Inherited CVEs, bloated attack surface, unnecessary packages |
| Dockerfile | Running as root, secrets baked into layers, writable filesystem |
| Registry | Pulling untrusted/unscanned images, :latest tag drift |
| Runtime | Excess Linux capabilities, host mounts, privileged mode |
| Host | Unpatched Docker daemon, exposed Docker socket |
Hardening the Dockerfile
Use Minimal or Distroless Base Images
Every package in your base image is attack surface you didn't need. Prefer alpine, slim variants, or distroless images that ship no shell, no package manager, nothing beyond your app's runtime dependencies.
Run as a Non-Root User
# Insecure - runs as root by default
FROM python:3.12
COPY . /app
WORKDIR /app
CMD ["python", "app.py"]
# Hardened
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --chown=appuser:appuser . /app
WORKDIR /app
USER appuser
CMD ["python", "app.py"]
If the container is compromised, a non-root process is far more limited in what it can do to the host or other containers sharing the kernel.
Multi-Stage Builds
Keep build tools (compilers, dev dependencies) out of the final image entirely:
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go build -o /app
FROM gcr.io/distroless/base-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The final image contains only the compiled binary - no Go toolchain, no source code, no shell to exec into.
Never Bake Secrets Into Images
# Insecure - secret is permanently in the image layer history
ARG API_KEY=sk-abc123
ENV API_KEY=${API_KEY}
Anyone with docker history or access to the image can extract that value, even from a layer that was later "overwritten." Use runtime secret injection instead (Docker secrets, Kubernetes Secrets/external-secrets, or environment variables set at container start - not build time).
Runtime Hardening
- Read-only root filesystem -
docker run --read-only(with explicittmpfsmounts for anything that genuinely needs to write) prevents an attacker from persisting a payload inside the container filesystem. - Drop unnecessary Linux capabilities - containers get a default capability set that's broader than almost any app needs. Drop everything and add back only what's required:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
- Never run
--privileged- this disables nearly all container isolation and gives the container full access to host devices. If you think you need it, you almost certainly need a specific capability instead. - Never mount the Docker socket into a container (
-v /var/run/docker.sock:/var/run/docker.sock) unless that container is a trusted, purpose-built management tool - it's equivalent to giving the container root on the host. - Set resource limits (
--memory,--cpus) to prevent a single compromised or buggy container from starving the host.
Image Scanning
Every image you build or pull should be scanned for known CVEs before it runs - see SCA for the underlying dependency-scanning concept. For containers specifically:
# Trivy - fast, widely used in CI/CD
trivy image myapp:latest
# Grype - alternative scanner, pairs with Syft for SBOM generation
grype myapp:latest
Gate your CI/CD pipeline on critical/high findings, and re-scan images periodically even after they're deployed - new CVEs get published against base images you haven't rebuilt in weeks.
Auditing Your Setup
Docker Bench for Security runs a battery of checks against the CIS Docker Benchmark on a running host/daemon - it's the fastest way to get a "here's what's misconfigured" report:
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-v /var/lib:/var/lib -v /var/run/docker.sock:/var/run/docker.sock \
-v /etc:/etc --label docker_bench_security \
docker/docker-bench-security
Best Practices Checklist
- [ ] Base image is minimal/distroless, pinned to a specific digest (not
:latest) - [ ] Container runs as a non-root
USER - [ ] Multi-stage build strips build tools from the final image
- [ ] No secrets in
ENV,ARG, or layer history - injected at runtime instead - [ ] Root filesystem is read-only where possible
- [ ] Unnecessary Linux capabilities dropped (
--cap-drop=ALL, add back only what's needed) - [ ] Never run with
--privilegedor mount the Docker socket into untrusted containers - [ ] Images scanned for CVEs in CI/CD (Trivy/Grype) and gated on critical/high findings
- [ ] Resource limits set (
--memory,--cpus) - [ ] Docker daemon itself patched and audited (Docker Bench for Security)