Post

Day 10: Docker Security Hardening - Breaking the Root Container Myth

A practical walkthrough of Docker security hardening - non-root users, read-only filesystems, dropped Linux capabilities, multi-stage builds, and Docker Bench for Security.

Day 10: Docker Security Hardening - Breaking the Root Container Myth

The Dangerous Myth: “Containers Are Virtual Machines”

When junior developers start working with Docker, a dangerous misconception often takes root:

“A container is like a mini virtual machine, right? It has its own isolated OS, so even if an attacker hacks into my container, they are trapped in a sandbox and can’t touch my actual host server.”

This assumption is completely false!

Containers are NOT virtual machines.

  • Virtual Machines (VMs): Run on a hypervisor. Each VM runs its own separate operating system kernel. Escaping a VM requires exploiting hardware virtualization bugs, which is rare.
  • Docker Containers: Simply isolated Linux processes running on the host server. They share the exact same host Linux kernel, memory, and CPU, isolated only by Linux kernel namespaces and control groups (cgroups).

If a process inside a Docker container runs as root (User ID 0), it has the exact same UID 0 as the root user on the host server! If an attacker finds a way to break out of the container boundary, they land on your host server with full root privileges.

Today, we will break down the essential principles of Docker security hardening, how to write production-grade secure Dockerfiles, and how to verify your setup using Docker Bench for Security.


Principle 1: Never Run as Root

By default, Docker executes all container processes as the root superuser (UID 0).

If your Node.js or Python application has a Remote Code Execution (RCE) flaw and is running as root, the attacker has complete administrative control over the container filesystem and a direct path to host compromise.

The Fix: Create a Dedicated Non-Root User

Always define and switch to a non-root user in your Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FROM node:20-alpine

# Create a dedicated system group and user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy dependency manifests and install
COPY package*.json ./
RUN npm ci --only=production

# Copy application source code
COPY . .

# Set proper ownership for the non-root user
RUN chown -R appuser:appgroup /app

# Switch to the non-root user before running the application
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]

Verifying Non-Root Execution

Always verify that your container is not running as root:

1
2
docker run --rm myapp whoami
# Output must be: appuser (NOT root)

For third-party public images where you cannot edit the Dockerfile, force a non-root user at container launch:

1
docker run --user 1001:1001 nginx

Principle 2: Enforce a Read-Only Root Filesystem

When an attacker compromises a web application via an exploit (e.g. an arbitrary file upload or command injection), their first three moves are predictable:

  1. Download a malware binary or reverse shell script using curl or wget.
  2. Write the payload into /tmp or /var/www/html.
  3. Modify configuration files or drop persistence cron jobs.

If the root filesystem is mounted as read-only, those write attempts fail instantly with Read-only file system errors!

1
docker run --read-only myapp

Handling Required Temporary Files with tmpfs

If your application legitimately needs to write temporary files or session caches, do not make the entire filesystem writable. Instead, mount a temporary in-memory filesystem (tmpfs) to /tmp:

1
2
3
4
docker run \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  myapp

In docker-compose.yml:

1
2
3
4
5
6
services:
  web:
    image: myapp:v1.0
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m

The flags noexec and nosuid prevent any binaries written to /tmp from being executed!


Principle 3: Drop Linux Kernel Capabilities

In standard Linux, the root user has all privileges. Linux Capabilities divide superuser power into approximately 40 distinct privileges (e.g. CHOWN, KILL, NET_BIND_SERVICE, SYS_ADMIN).

By default, Docker grants approximately 14 capabilities to every container. Most applications do not need 90% of them!

The Least-Privilege Capability Pattern

Drop ALL capabilities, and add back only what the process strictly requires:

1
2
3
4
docker run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  myapp

NET_BIND_SERVICE allows binding to low-numbered privileged ports (below 1024 like port 80). If your application listens on port 3000 or 8080, you do not even need NET_BIND_SERVICE!

In Docker Compose:

1
2
3
4
5
6
7
services:
  web:
    image: myapp:v1.0
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

Principle 4: Never Run with --privileged

Running a container with docker run --privileged disables almost all security mechanisms:

  • Grants all Linux kernel capabilities.
  • Mounts all host devices directly into the container.
  • Turns off AppArmor and seccomp profiles.

Running --privileged in production is effectively giving the container total control of the host machine. Never use it outside of specialized local testing!

Dangerous Docker Flags to Avoid in Production

1
2
3
4
5
6
# NEVER do these in production:
docker run --privileged myapp       # Removes all container isolation
docker run --pid=host myapp         # Shares the host process table
docker run --network=host myapp     # Bypasses network isolation
docker run -v /:/host myapp         # Mounts host root filesystem
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp # Grants host root!

Principle 5: Multi-Stage Builds & Minimal Base Images

Multi-Stage Builds

Why do attackers love standard Docker images? Because developers leave build tools (compilers, gcc, make, python-dev, git, npm) inside production containers. If an attacker uploads raw C exploit code, the container kindly compiles it for them!

A multi-stage build separates the build environment from the runtime environment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Stage 1: Build & Compile
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # Compiles TypeScript to JavaScript

# Stage 2: Minimal Production Runtime
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final production image contains zero compilers, zero devDependencies, and zero build scripts.

Minimal Base Images & Distroless

Image TypeExampleSizeAttack Surface
Full Distributionubuntu:22.04~80 MBHigh (Contains bash, package manager, curl)
Alpine Linuxalpine:3.19~5 MBLow (Lightweight musl libc, apk)
Distrolessgcr.io/distroless/nodejs20~30 MBUltra-Low (No shell, no package manager)

Google’s Distroless images contain only your application and its runtime dependencies. There is no /bin/sh, no /bin/bash, and no package manager. If an attacker exploits an RCE vulnerability in your application, they cannot even spawn a basic reverse shell because no shell binary exists on the filesystem!


Principle 6: Never Bake Secrets into Image Layers

A classic junior developer mistake:

1
2
3
# WRONG: Secrets are permanently stored in docker image layers!
ENV STRIPE_SECRET_KEY=sk_live_98127398127
ARG DB_PASSWORD=SecretPassword123

Even if you delete the environment variable or file in a later RUN rm statement, Docker’s layer caching preserves every previous layer. Anyone with docker pull access can run docker history --no-trunc myapp and extract the secret in seconds.

Always inject secrets at container startup using runtime environment variables or secrets managers:

1
docker run -e DB_PASSWORD=$(aws secretsmanager get-secret-value ...) myapp

Auditing with Docker Bench for Security

How do you know if your Docker host and daemon follow CIS Docker Benchmark standards?

Run Docker Bench for Security, an open-source script that audits your configuration:

1
2
3
4
5
6
7
8
9
10
11
docker run --rm --net host --pid host --userns host --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /etc:/etc:ro \
  -v /lib/systemd/system:/lib/systemd/system:ro \
  -v /usr/bin/containerd:/usr/bin/containerd:ro \
  -v /usr/bin/runc:/usr/bin/runc:ro \
  -v /usr/lib/systemd:/usr/lib/systemd:ro \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --label docker_bench_security \
  docker/docker-bench-security

The script outputs [PASS], [WARN], and [FAIL] badges across your host configuration, Docker daemon settings, container images, and runtime flags.


Production-Hardened Dockerfile Template

Here is a ready-to-use template incorporating all the hardening principles we covered:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ----------------------------------------------------
# Stage 1: Build Environment
# ----------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ----------------------------------------------------
# Stage 2: Hardened Production Runtime
# ----------------------------------------------------
FROM node:20-alpine AS production

# 1. Create dedicated non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# 2. Copy only production dependencies and compiled artifacts
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist

# 3. Restrict file permissions
RUN chown -R appuser:appgroup /app

# 4. Drop superuser privileges
USER appuser

# 5. Lock environment
ENV NODE_ENV=production

EXPOSE 3000

# 6. Use exec form for proper signal handling
CMD ["node", "dist/server.js"]

Production Launch Command

1
2
3
4
5
6
7
docker run \
  --read-only \
  --cap-drop ALL \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --security-opt no-new-privileges \
  -p 3000:3000 \
  myapp:hardened

--security-opt no-new-privileges ensures that processes inside the container can never gain additional privileges through setuid or setgid binaries.


Junior Pitfalls to Avoid

  1. Mounting the Docker Socket (docker.sock): Mounting /var/run/docker.sock inside a container allows that container to speak directly to the host Docker daemon. An attacker can simply tell the daemon to run a new root container that mounts the host’s / root drive. It is an instant, complete host takeover.
  2. Forgetting to Chown Application Files: If you switch to USER appuser at line 20, but the files copied in lines 10-15 are owned by root:root with mode 0700, your app will crash with EACCES: permission denied on startup. Always set ownership before switching users.
  3. Assuming Alpine is Always Vulnerability-Free: While Alpine is small, you still need to run apk update && apk upgrade or scan it regularly with Trivy to catch musl or busybox vulnerabilities.
  4. Baking Secrets into Docker Build Arguments (ARG): Build arguments are visible in image metadata and history. Use Docker BuildKit secrets (--mount=type=secret) for build-time secrets like private npm tokens.

Key Takeaways

  • Containers are shared-kernel processes, not virtual machines: running as root inside means running as root on the host.
  • Always create a dedicated non-root user (USER appuser) in every Dockerfile.
  • Mount the container filesystem as read-only (--read-only) with a scoped in-memory /tmp mount.
  • Drop all Linux capabilities (--cap-drop ALL) and never use --privileged.
  • Use multi-stage builds and distroless images to strip out compilers and shells.
  • Never mount /var/run/docker.sock into workload containers.

References


You can find me online at:

My signature image

This post is licensed under CC BY 4.0 by the author.