DevOps Security

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.

October 1, 20257 min readShipSafer Team

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:

  1. Debian Slim / Alpine: Stripped-down variants of common distributions. Alpine uses musl libc (sometimes causes compatibility issues).
  2. 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.
  3. 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.

docker
container security
devops security
trivy
secrets management

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.