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.

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.