Docker Security Best Practices: Images, Runtime, and Secrets
A comprehensive Docker security guide covering minimal base images, running as non-root, read-only filesystems, secrets management, image scanning with Trivy, and seccomp profiles.
Docker makes it easy to ship software, but the defaults favor developer convenience over security hardening. A container running as root with a full OS image and no resource limits is a significant risk — both from vulnerabilities in the image and from what an attacker can do if they escape the container. This guide covers the practices that actually move the needle.
Use Minimal Base Images
The most impactful thing you can do for container security is reduce the attack surface of the image itself. Every package, binary, and library in your image is a potential vector for exploitation. The smaller the image, the fewer vulnerabilities it contains.
Options in order of increasing minimalism:
- Debian Slim / Alpine: Stripped-down variants of common distributions. Alpine uses musl libc (sometimes causes compatibility issues).
- Distroless (Google): Contains only your application runtime — no shell, no package manager, no OS tools. An attacker who gains code execution in a distroless container has nothing to work with.
- Scratch: Empty base image, suitable for statically compiled Go or Rust binaries.
Dockerfile using multi-stage build with distroless:
# Build stage — full image with build tools
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server
# Final stage — distroless, no shell
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The final image contains only the compiled binary and the distroless base, which is around 2MB. No CVEs from userland packages. No shell for an attacker to spawn.
Run as Non-Root
By default, Docker containers run as root (UID 0) inside the container. If an attacker exploits a vulnerability in your application, they immediately have root within the container — and if there's a container escape, root in the container is a significant step toward root on the host.
Set a non-root user in your Dockerfile:
FROM node:20-alpine
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
In Kubernetes (or Docker Compose), also enforce via security context:
# Kubernetes Pod spec
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
If you're using distroless with :nonroot tag, the image already sets UID 65532. The USER directive in your Dockerfile overrides this, so you can choose any non-root UID.
Read-Only Filesystems
A read-only root filesystem prevents an attacker from modifying application binaries, dropping new tools, or persisting malware. Most applications don't need to write to the root filesystem — they write to specific mounted volumes.
In Docker:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /var/run \
my-app:latest
In Kubernetes:
containers:
- name: my-app
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: data
mountPath: /app/data
volumes:
- name: tmp
emptyDir: {}
- name: data
persistentVolumeClaim:
claimName: app-data
If your application fails with a read-only filesystem, run it once with strace or check the error logs to identify which paths need to be writable, then mount only those with emptyDir or persistent volumes.
Never Put Secrets in Images or Environment Variables
The common mistakes:
# BAD: Secret baked into the image layer
ENV DATABASE_PASSWORD=supersecretpassword123
# BAD: Secret added in a layer (still visible in layer history even if deleted)
RUN echo "password=secret" > /app/config.ini && \
do_something && \
rm /app/config.ini
Once a secret is in an image layer, docker history --no-trunc image-name reveals it. Even if you delete the file in a subsequent layer, the original layer remains in the image and anyone who can pull the image can inspect the layers.
Also avoid environment variables for secrets in production. Environment variables are visible in docker inspect, /proc/1/environ, crash reports, and child process environments.
Use a secrets management solution instead:
- Docker Secrets (Swarm mode): Mounts secrets as files in
/run/secrets/ - Kubernetes Secrets (mounted as volumes): Also files, better than env vars
- AWS Secrets Manager / Parameter Store: Application fetches at startup via SDK
- HashiCorp Vault: Dynamic secrets, automatic rotation, fine-grained access control
Kubernetes example — secret as a mounted file:
apiVersion: v1
kind: Pod
spec:
containers:
- name: my-app
volumeMounts:
- name: db-credentials
mountPath: /run/secrets/
readOnly: true
volumes:
- name: db-credentials
secret:
secretName: database-credentials
defaultMode: 0400 # Owner read-only
Your application reads the secret from the file, not from the environment.
Docker Content Trust and Image Signing
Docker Content Trust (DCT) ensures that the images you pull are exactly what the publisher signed — preventing image tampering and man-in-the-middle attacks in your registry.
Enable it by default:
export DOCKER_CONTENT_TRUST=1
When DOCKER_CONTENT_TRUST=1 is set, Docker will only pull images with valid signatures. Unsigned images are rejected.
For your own images, sign them during the build and push process:
export DOCKER_CONTENT_TRUST=1
docker build -t registry.example.com/myapp:1.0.0 .
docker push registry.example.com/myapp:1.0.0 # Prompts for signing key
In CI/CD, store the delegation key as a secret and pass it non-interactively:
export DOCKER_CONTENT_TRUST=1
export DOCKER_CONTENT_TRUST_SERVER=https://notary.registry.example.com
echo "$SIGNING_KEY" | docker trust sign registry.example.com/myapp:1.0.0
Image Scanning with Trivy
Trivy is the de facto open-source container vulnerability scanner. It scans OS packages, language dependencies, IaC files, and SBOM data.
Scan an image:
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--ignore-unfixed \
my-app:latest
--exit-code 1 makes Trivy return a non-zero exit code on findings, useful for blocking CI pipelines.
Integration in GitHub Actions:
- name: Scan Docker image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1
ignore-unfixed: true
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
Scan not just final images but also base images in your Dockerfile, and re-scan on a schedule (weekly at minimum) because new CVEs are published against existing images you've already deployed.
Resource Limits
Without resource limits, a compromised container can consume all CPU and memory on the host, causing a denial of service for other containers. Limits also prevent runaway processes from becoming an availability problem.
Docker run:
docker run \
--memory="256m" \
--memory-swap="256m" \
--cpus="0.5" \
--pids-limit=100 \
my-app:latest
Kubernetes:
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
Set memory-swap equal to memory to disable swap (swap usage can indicate a memory-pressured process that's about to OOM and is worth alerting on, not hiding).
seccomp and AppArmor Profiles
seccomp (secure computing mode) restricts which Linux syscalls a container can make. Docker's default seccomp profile blocks ~44 syscalls that are rarely needed by applications but commonly exploited in container escapes (e.g., ptrace, keyctl, add_key).
Use the default profile (Docker applies it automatically) unless you have a specific reason to override:
# Verify the default profile is applied
docker inspect container-id | grep -A2 Seccomp
For applications with well-defined syscall requirements, create a custom profile that only allows the specific syscalls your application uses:
# Generate a profile by tracing your application
docker run --security-opt seccomp=unconfined \
--cap-drop=ALL \
my-app:latest
# Use strace to capture syscalls, then generate a profile from the list
AppArmor profiles restrict file system access, network access, and capabilities at the kernel level. Docker ships with a default AppArmor profile (docker-default). On AppArmor-enabled systems, it's applied automatically.
Capabilities: Drop all Linux capabilities and add back only what's needed:
docker run \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \ # Only if binding to port <1024
my-app:latest
Most web applications require zero Linux capabilities. Dropping all capabilities significantly limits what an attacker can do even if they achieve code execution.