Docker Security Best Practices — Rootless, Image Scanning, Secrets

Why Docker Security Matters

Docker containers share the host OS kernel. If a container runs as root without proper isolation, a container escape can expose the entire host system to risk. Additionally, vulnerable base images, hardcoded secrets, and excessive privileges widen the attack surface.

The core security principle is the Principle of Least Privilege. Grant containers only the minimum permissions, files, and network access they need.

Running as a Non-Root User

Docker containers run as root by default. This is the most common and dangerous security issue.

# Bad example: runs as root (default)
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
# Runs as root user — dangerous!
CMD ["node", "server.js"]
# Good example: create and use a non-root user
FROM node:22-alpine
WORKDIR /app

# Create system user/group (no login, no home directory)
RUN addgroup --system appgroup && \
    adduser --system --ingroup appgroup --no-create-home appuser

COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --omit=dev
COPY --chown=appuser:appgroup . .

# Switch to non-root user
USER appuser

# Use ports above 1024 (non-root cannot bind to ports below 1024)
EXPOSE 3000
CMD ["node", "server.js"]
# Check the user of a running container
docker exec my-app whoami
# appuser  ← confirmed it's not root

# Check container processes
docker exec my-app ps aux
# PID   USER     COMMAND
#   1   appuser  node server.js  ← running as non-root

The official Node.js image includes a built-in node user, so simply adding USER node works too. However, you must also change file ownership with COPY --chown=node:node.

Image Vulnerability Scanning

Scan base images for known vulnerabilities (CVEs) in OS packages and libraries.

# Docker Scout (built into Docker Desktop)
docker scout cves my-app:latest
# ✗ CRITICAL  1   CVE-2024-xxxxx  openssl  3.1.0 → 3.1.5
# ✗ HIGH      3   CVE-2024-yyyyy  libcurl  8.1.0 → 8.5.0
# ✗ MEDIUM    7   ...

# Trivy (open-source scanner)
# Install
docker pull aquasec/trivy:latest

# Scan image
docker run --rm aquasec/trivy:latest image my-app:latest
# my-app:latest (alpine 3.19)
# ==============================
# Total: 12 (CRITICAL: 1, HIGH: 3, MEDIUM: 7, LOW: 1)

# Filter CRITICAL/HIGH only
docker run --rm aquasec/trivy:latest image --severity CRITICAL,HIGH my-app:latest

# Use in CI/CD: fail build if CRITICAL vulnerabilities exist
docker run --rm aquasec/trivy:latest image --exit-code 1 --severity CRITICAL my-app:latest
# Image scanning in GitHub Actions CI
# .github/workflows/docker-scan.yml
name: Docker Image Scan
on: [push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t my-app:ci .
      - name: Run Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'my-app:ci'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

Secrets Management

Never include secrets in Dockerfiles or images. They are permanently recorded in image layers and can be extracted by anyone.

# NEVER do this: hardcode secrets in Dockerfile
ENV DATABASE_PASSWORD=mysecretpassword
# Anyone can see this with docker history!

# NEVER do this: COPY secrets during build
COPY .env /app/.env
# Permanently recorded in image layers!

Here are the correct approaches to managing secrets.

# docker-compose.yml — inject via environment variable file
services:
  api:
    build: ./api
    # Method 1: inject via env_file (.env file is not included in the image)
    env_file:
      - .env
    # Method 2: specify individually with environment
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - API_KEY=${API_KEY}
# Pass environment variables at runtime
docker run -d \
  --name api \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="${API_KEY}" \
  my-app:latest

# Docker Swarm secrets (encrypted storage)
echo "my-secret-password" | docker secret create db_password -

# Use Docker secrets in Compose
# docker-compose.yml
services:
  api:
    image: my-app:latest
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true
# BuildKit secrets (secrets are NOT recorded in layers during build)
# Dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

# Build command
docker build --secret id=npmrc,src=.npmrc -t my-app:latest .

Hardening Image Security

How to build secure images containing only the minimum required files.

# Security-hardened Dockerfile example
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app

# Remove unnecessary packages
RUN apk --no-cache upgrade && \
    # Restrict shell access (inconvenient for debugging but improves security)
    rm -rf /bin/sh /bin/ash 2>/dev/null || true

# Non-root user
RUN addgroup --system app && adduser --system --ingroup app app

# Copy only build artifacts
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./

# Set filesystem to read-only
RUN chmod -R a-w /app/dist

USER app

EXPOSE 3000
# Use exec form CMD for proper signal handling
CMD ["node", "dist/server.js"]
# Run with read-only filesystem
docker run -d --read-only \
  --tmpfs /tmp:rw,noexec,nosuid \
  --name api \
  my-app:latest

# Restrict kernel capabilities (remove unnecessary Linux capabilities)
docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --name api \
  my-app:latest

# Apply comprehensive security options
docker run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  --memory=512m \
  --cpus=1 \
  --pids-limit=100 \
  --name api \
  my-app:latest

Rootless Docker

A mode where the Docker daemon itself runs as a non-root user. Since the daemon operates without root privileges, even a container escape cannot gain host root access.

# Install Rootless Docker (Ubuntu)
# Prerequisites
sudo apt install -y uidmap dbus-user-session

# Run install script
dockerd-rootless-setuptool.sh install

# Set environment variables (add to ~/.bashrc)
export PATH=/usr/bin:$PATH
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock

# Verify rootless mode
docker info | grep -i rootless
# Security Options: rootless

# Run a container in rootless mode
docker run -d --name web -p 8080:80 nginx:alpine

Rootless mode has the following limitations.

ItemLimitation
PortsCannot bind to ports below 1024 (can be overridden via sysctl)
NetworkingOverlay networks not supported
StorageSome storage drivers not supported
cgroupRequires cgroup v2

Security Checklist

# Docker Bench Security — automated CIS benchmark check
docker run --rm --net host --pid host \
  --userns host --cap-add audit_control \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v /etc:/etc:ro \
  docker/docker-bench-security

# Sample output
# [PASS] 1.1  - Ensure Docker is up to date
# [WARN] 2.1  - Run the Docker daemon as a non-root user
# [PASS] 4.1  - Ensure a user for the container has been created
# [WARN] 4.6  - Ensure HEALTHCHECK instructions have been added

Practical Tips

  • Pin image tags: Use specific versions like FROM node:22.12-alpine instead of FROM node:latest. latest can change without notice, making build reproducibility and security auditing impossible.
  • Multi-stage builds are essential: If build tools, source code, and devDependencies are included in the production image, the attack surface widens. Copy only the files needed for execution to the final stage.
  • Be cautious with docker.sock mounts: Mounting /var/run/docker.sock inside a container gives that container full control over the host’s Docker. Use it only when absolutely necessary for CI/CD tools, and explore alternatives.
  • Set resource limits: Use --memory, --cpus, and --pids-limit options to limit container resources. Without limits, a single container can consume all host resources.
  • Update images regularly: Regularly update base images and include vulnerability scanning in your CI/CD pipeline. The key is never deploying images with known vulnerabilities to production.

Was this article helpful?