Docker Security: Rootless Containers, Secrets and Image Scanning (2026)
The Docker daemon runs as root by default — a container escape means root on the host. Hardening Docker deployments means running containers as non-root users, dropping Linux capabilities they don't need, keeping secrets out of image layers, and scanning images for CVEs before they reach production. This phase covers the full security stack: rootless Docker mode, Docker secrets in Swarm, BuildKit secret mounts for build-time credentials, read-only containers, capability dropping, seccomp profiles, and image scanning with Docker Scout and Trivy.
Table of Contents
Non-Root Users
# Rule #1: never run application processes as root inside containers.
# If an attacker exploits your app, they get root inside the container —
# and with a kernel bug or misconfiguration, that becomes root on the host.
# Dockerfile: always set USER before CMD
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
USER node # node:alpine includes user 'node' (uid 1000)
CMD ["node", "server.js"]
# Verify at runtime
docker exec myapp id
# uid=1000(node) gid=1000(node) groups=1000(node)
# For images without a built-in user, create one:
RUN addgroup --gid 1001 --system appgroup \
&& adduser --uid 1001 --system --ingroup appgroup --no-create-home appuser
USER appuser
# Prevent privilege escalation (even if process runs as root inside container)
docker run --security-opt no-new-privileges:true myapp
# Blocks setuid/setgid binaries from elevating privileges
# In Compose:
services:
web:
user: "1001:1001" # Override even if Dockerfile didn't set USER
security_opt:
- no-new-privileges:true
Rootless Docker Mode
# Rootless mode: the Docker daemon itself runs as a non-root user.
# Even a daemon exploit doesn't give host root access.
# Supported on Linux (Docker Desktop on Mac/Windows is already sandboxed).
# Install rootless Docker (per-user installation)
dockerd-rootless-setuptool.sh install
# The daemon now runs under your user account
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
docker info | grep "rootless"
# rootless: true
# Differences from rootful Docker:
# - Port numbers < 1024 require additional setup (sysctl or rootlesskit)
# - Overlay network (Swarm) has limitations
# - Some storage drivers may not be available
# - Performance: slight overhead from user namespaces
# Use rootless for: development environments, CI runners, shared hosts
# Use rootful (hardened) for: production servers where you control the host
# Rootless with Docker Compose
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
docker compose up -d # Works the same way
# Colima (Mac alternative to Docker Desktop — runs rootless by default)
# brew install colima
# colima start --cpu 4 --memory 8
Secrets Management
# Never put secrets in:
# ❌ Dockerfile ENV instructions (visible in docker history and docker inspect)
# ❌ docker-compose.yml environment: (committed to git)
# ❌ .env files committed to git
# ❌ Image layers (even if deleted in a later RUN — the layer still has it)
# Option 1: Runtime env vars from a secure source (simplest)
# Inject at container start from a secrets manager:
SECRET=$(aws secretsmanager get-secret-value --secret-id myapp/db-password --query SecretString --output text)
docker run -e DB_PASSWORD="$SECRET" myapp
# Option 2: Docker Secrets (Swarm mode)
# Create a secret from a file or stdin
echo "supersecret" | docker secret create db_password -
docker secret create ssl_cert ./certs/server.crt
# Use in a Swarm service
docker service create \
--name myapp \
--secret db_password \ # Mounted at /run/secrets/db_password
--secret source=ssl_cert,target=cert \ # Custom mount path /run/secrets/cert
myapp:latest
# In the container: read from /run/secrets/
import fs from 'fs'
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim()
# In Compose (Swarm mode):
services:
web:
secrets:
- db_password
- source: ssl_cert
target: /etc/ssl/cert.pem
mode: 0444
secrets:
db_password:
external: true # Already created with docker secret create
ssl_cert:
file: ./certs/server.crt # Compose creates the secret from a file
# Option 3: BuildKit secret mounts (secrets during build, never in layers)
# In Dockerfile:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
# Build command:
docker build --secret id=npm_token,src=.npmrc .
# or from env var:
docker build --secret id=npm_token,env=NPM_TOKEN .
Read-Only Containers
# --read-only: make the container's root filesystem read-only.
# An attacker who compromises the app can't write malware to the filesystem.
docker run -d \
--read-only \
--tmpfs /tmp \ # Allow writes only to tmpfs
--tmpfs /run \
-v app_logs:/app/logs \ # Allow writes to named volume
myapp
# In Compose:
services:
web:
read_only: true
tmpfs:
- /tmp
- /run
volumes:
- app_logs:/app/logs
# Find what your app tries to write (before enabling read-only):
# Run without read-only, then check:
docker diff mycontainer
# A = added, C = changed, D = deleted — shows all writes to container FS
# Common tmpfs mounts needed for read-only containers:
# /tmp — temp files
# /run — PID files, unix sockets
# /var/run — symlink to /run
# /app/tmp — app-specific temp (check your app's config)
# Nginx needs to write to /var/cache/nginx and /var/run:
docker run -d \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
nginx
Capabilities and Seccomp
# Linux capabilities break root's all-or-nothing model into fine-grained privileges.
# Docker grants a default set — drop everything you don't need.
# Default capabilities Docker grants (partial list):
# CAP_CHOWN — change file ownership
# CAP_NET_BIND_SERVICE — bind to ports < 1024
# CAP_SETUID/SETGID — change user/group
# CAP_KILL — send signals to any process
# Drop ALL capabilities, add back only what's needed:
docker run -d \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \ # Only if binding port < 1024
myapp
# In Compose:
services:
web:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
# Most web apps need zero capabilities if running as non-root on port > 1024:
services:
web:
cap_drop:
- ALL
# No cap_add needed — app runs on port 3000 as non-root
# Seccomp profiles: restrict which syscalls a container can make
# Docker's default profile blocks ~44 dangerous syscalls (ptrace, reboot, etc.)
# Custom profiles allow even tighter restriction:
docker run --security-opt seccomp=/path/to/profile.json myapp
# Minimal seccomp profile generation:
# Run your app with strace to see which syscalls it uses, then allow only those.
# Tools: docker-slim, seccomp-profiler
# AppArmor (Ubuntu/Debian) — mandatory access control for containers
docker run --security-opt apparmor=docker-default myapp # Already default
docker run --security-opt apparmor=myapp-profile myapp # Custom profile
Image Scanning
# Scan images for known CVEs (Common Vulnerabilities and Exposures)
# before pushing to production.
# Docker Scout (built into Docker CLI since 2023)
docker scout cves myapp:latest # Show all CVEs
docker scout cves --only-severity critical,high myapp:latest
docker scout recommendations myapp:latest # Suggest base image upgrades
docker scout compare myapp:v1 myapp:v2 # Diff between two images
docker scout quickview myapp:latest # Summary dashboard
# Trivy (open source, by Aqua Security — excellent for CI)
# brew install aquasecurity/trivy/trivy
trivy image myapp:latest
trivy image --severity HIGH,CRITICAL myapp:latest
trivy image --exit-code 1 --severity CRITICAL myapp:latest # Fail CI on critical CVEs
# In GitHub Actions:
# - name: Scan image
# uses: aquasecurity/trivy-action@master
# with:
# image-ref: myapp:${{ github.sha }}
# severity: HIGH,CRITICAL
# exit-code: 1
# Grype (by Anchore — another option)
# brew install anchore/grype/grype
grype myapp:latest
grype myapp:latest --fail-on high
# Best practices to reduce CVE count:
# 1. Use minimal base images (alpine, slim, distroless)
# 2. Update base images regularly (weekly automated PRs)
# 3. Don't install unnecessary packages
# 4. Multi-stage builds (build tools don't end up in final image)
# 5. Pin base image digests (immutable): FROM node:20-alpine@sha256:abc123...
Daemon Hardening
# /etc/docker/daemon.json — Docker daemon configuration
{
"live-restore": true, // Containers keep running if daemon restarts
"userland-proxy": false, // Use iptables instead of docker-proxy (faster)
"no-new-privileges": true, // Global: prevent privilege escalation in all containers
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2",
"icc": false, // Disable inter-container communication on default bridge
// (containers must use explicit networks)
"dns": ["8.8.8.8", "8.8.4.4"],
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 64000,
"Soft": 64000
}
}
}
# Never expose the Docker socket to the internet:
# ❌ -H tcp://0.0.0.0:2375 (unauthenticated — gives root to anyone)
# ✅ Use SSH: docker -H ssh://user@host ps
# ✅ Use TLS: docker --tlsverify -H tcp://host:2376 ps
# Protect the Docker socket in containers:
# Never mount /var/run/docker.sock into untrusted containers — it gives root.
# If a CI runner needs Docker, use Docker-in-Docker (dind) or Kaniko instead.
Security Checklist
# Pre-production Docker security checklist:
# Image
# ✅ Non-root USER in Dockerfile
# ✅ Minimal base image (alpine, slim, distroless, or scratch)
# ✅ No secrets in ENV, ARG, or image layers
# ✅ BuildKit secret mounts for build-time credentials
# ✅ Image scanned with Scout/Trivy — no critical CVEs
# ✅ Base image pinned to digest (for immutability)
# ✅ .dockerignore excludes .env, .git, tests
# Runtime
# ✅ --read-only filesystem where possible
# ✅ --cap-drop ALL + add only required capabilities
# ✅ --security-opt no-new-privileges:true
# ✅ Secrets injected at runtime (env vars from vault, /run/secrets/)
# ✅ Resource limits set (--memory, --cpus)
# ✅ Network isolation: internal networks for backend services
# ✅ Ports not exposed unless necessary
# ✅ Healthcheck defined
# Infrastructure
# ✅ Docker daemon not exposed on TCP without TLS
# ✅ Docker socket not mounted in untrusted containers
# ✅ Rootless daemon or strong host user separation
# ✅ daemon.json: icc=false, no-new-privileges=true, live-restore=true
# ✅ Image scanning in CI pipeline (blocks on critical CVEs)
# ✅ Regular base image updates (automated weekly rebuild)
Next: Phase 9 — Docker Registry covers Docker Hub, GHCR, AWS ECR and private registries: pushing, pulling, automated builds, image retention policies and mirror caching.