Docker Security Tips
Non-Root Users
# Dockerfile: create and switch to non-root user
FROM node:20-alpine
# Create app directory and user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy and install as root, then switch
COPY package*.json ./
RUN npm ci --omit=dev
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
# Run with --user flag (override image USER)
docker run --user 1001:1001 myapp
# Verify running as non-root
docker exec my-container id
docker exec my-container whoami
Read-Only Filesystem
# Run with read-only root filesystem
docker run \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid \
--tmpfs /run:rw,noexec,nosuid \
myapp
# Allow specific directories to be writable
docker run \
--read-only \
-v app-logs:/app/logs \
--tmpfs /tmp \
myapp
# Dockerfile: avoid writing to filesystem at runtime
# Write logs to stdout/stderr (Docker captures them)
# Use env vars for config instead of writing config files
Linux Capabilities
# Drop ALL capabilities, add back only what's needed
docker run \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
myapp
# Common capabilities and their purpose:
# CHOWN - change file ownership
# DAC_OVERRIDE - bypass file permission checks
# NET_BIND_SERVICE - bind to ports < 1024
# NET_ADMIN - network config (iptables, etc.)
# SYS_PTRACE - process tracing (debuggers)
# SETUID/SETGID - change user/group IDs
# Verify capabilities in container
docker run --rm alpine sh -c "apk add libcap && capsh --print"
# Check if container runs with elevated privileges
docker inspect mycontainer --format='{{json .HostConfig.CapAdd{{"}}"}}'
Seccomp Profiles
# Use default seccomp profile (blocks ~44 syscalls)
# Enabled by default in Docker Desktop and most runtimes
# Run without seccomp (only for debugging)
docker run --security-opt seccomp=unconfined myapp
# Apply custom seccomp profile
docker run \
--security-opt seccomp=/path/to/profile.json \
myapp
# Minimal seccomp profile (deny-all + allowlist)
# {
# "defaultAction": "SCMP_ACT_ERRNO",
# "syscalls": [
# {
# "names": ["read", "write", "exit", "exit_group",
# "open", "close", "stat", "fstat",
# "mmap", "mprotect", "munmap", "brk",
# "rt_sigaction", "rt_sigprocmask"],
# "action": "SCMP_ACT_ALLOW"
# }
# ]
# }
# AppArmor profile (Linux)
docker run --security-opt apparmor=docker-default myapp
Image Scanning
# Trivy (open source vulnerability scanner)
trivy image nginx:latest
trivy image --severity CRITICAL,HIGH myapp:v1.0
# Scan local image
trivy image --input myapp.tar
# Docker Scout (built into Docker Desktop)
docker scout cves nginx:latest
docker scout recommendations myapp:latest
# Snyk
snyk container test myapp:latest
snyk container monitor myapp:latest
# Grype
grype myapp:latest
# In CI (fail on critical vulnerabilities)
trivy image --exit-code 1 --severity CRITICAL myapp:${TAG}
Secrets Management
# NEVER use ENV or ARG for secrets in Dockerfile
# BAD: ARG DB_PASSWORD=secret (visible in image layers)
# Docker Swarm secrets
echo "mysecret" | docker secret create db_password -
docker service create \
--name myapp \
--secret db_password \
myimage
# Secret available at /run/secrets/db_password
# Docker Compose secrets (v3.1+)
services:
app:
image: myapp
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
# BuildKit secret (not baked into image layer)
# Dockerfile:
# RUN --mount=type=secret,id=npmrc cat /run/secrets/npmrc
# Build with secret:
# docker build --secret id=npmrc,src=$HOME/.npmrc .