DEV Community

Cover image for Docker Security: Stop Running Everything as Root
Harshit Luthra
Harshit Luthra

Posted on • Originally published at harshit.cloud

Docker Security: Stop Running Everything as Root

Originally published at harshit.cloud on 2024-12-10.


Docker Security: Stop Running Everything as Root

The audit came back with 47 critical issues, 129 highs, 156 containers running as root, and 300-plus unpatched CVEs. We had been shipping the same Node Dockerfile for two years. It was the one from the official node image's README, with our app dropped on top. Nobody had ever questioned it. The auditor wrote one line in the summary: one RCE in any of these and you own the cluster.

the report

Here's what landed in my inbox on a Tuesday morning, paraphrased into the format the scanner emits:

Critical Issues: 47
High Severity: 129
Running as root: 156 containers
Unpatched CVEs: 300+
Enter fullscreen mode Exit fullscreen mode

The 156 number was the one that hurt. We didn't have 156 services. We had about thirty. The rest were sidecars, jobs, debug images, one-off tools that someone had built three years ago and never thought about again. Each one ran as UID 0 because the base image did, and nobody had bothered to override it.

running as root, by accident

This is the Dockerfile we had. Maybe yours too.

FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

The node image runs as root by default. There's a node user already created inside it, but you have to opt in with USER node. Almost nobody does. Six years of Stack Overflow answers, including the accepted ones, omit it. The fix is one line, and the version that creates a fresh user is a habit worth keeping for images that don't ship one.

FROM node:20-slim

# Create non-root user
RUN groupadd -r nodejs && useradd -r -g nodejs nodejs

WORKDIR /app

# Install dependencies as root
COPY package*.json ./
RUN npm ci --production

# Copy application files
COPY --chown=nodejs:nodejs . .

# Switch to non-root user
USER nodejs

EXPOSE 3000
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

The thing the --chown flag buys you is that the running process can't chmod its own binaries. An attacker who pops the app can read what it can read and write to what it can write to, but can't go and rewrite server.js to add a backdoor. That's a real piece of mitigation that costs you nothing.

images that arrived with everything

Our prod image was 1.2 GB. The base was ubuntu:latest, then a kitchen-sink apt-get install of curl, wget, git, build-essential, Python, Node, and npm. The build engineer who wrote it had reasons for each one at some point. None of those reasons were still true in production.

FROM ubuntu:latest

RUN apt-get update && apt-get install -y \
    curl \
    wget \
    git \
    build-essential \
    python3 \
    nodejs \
    npm

# ... rest of the Dockerfile
Enter fullscreen mode Exit fullscreen mode

Every binary in there is a CVE waiting to be reported. The replacement is the same app on node:20-alpine, with dumb-init for signal handling and nothing else.

FROM node:20-alpine

# Only install what you need
RUN apk add --no-cache dumb-init

WORKDIR /app

COPY package*.json ./
RUN npm ci --production --ignore-scripts

COPY . .

USER node

ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

The image dropped to 150 MB. Vulnerability count fell by 97% the morning we shipped it, mostly because we stopped shipping git and a C compiler in production. Build time is 60% shorter. None of that required clever engineering. We deleted things.

secrets baked into layers

The first time I saw this in our codebase I assumed it was a stub:

FROM node:20

# DON'T DO THIS!
ENV DB_PASSWORD=supersecret123
ENV API_KEY=abc123xyz

COPY . .
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

It wasn't. It had been deployed for eight months. The defense everyone offers is "the registry is private". The problem is that ENV lives in the image layer history forever, and docker history and docker save will hand it to anyone who pulls the image once.

docker history myapp:latest
docker save myapp:latest | tar -xO | grep -a "API_KEY"
Enter fullscreen mode Exit fullscreen mode

BuildKit secrets fix the build-time half. The secret mounts during the RUN step and never lands in a layer.

# syntax=docker/dockerfile:1

FROM node:20-alpine

WORKDIR /app

# Use build-time secrets
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --production

COPY . .
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Build with:

docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
Enter fullscreen mode Exit fullscreen mode

For runtime secrets, the right answer depends on where you're running. On a single host:

docker run -e DB_PASSWORD="$(cat /path/to/secret)" myapp
Enter fullscreen mode Exit fullscreen mode

On Swarm or Kubernetes, use the platform's secret store. Anything else is a layer of chmod 600 and hope.

capabilities you didn't ask for

A vanilla container gets fourteen Linux capabilities by default — including CAP_NET_RAW, which lets the process craft raw packets. Most apps need NET_BIND_SERVICE and nothing else. Drop the lot, add back what you actually use.

# Drop all capabilities, add only what's needed
docker run --rm \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges:true \
  myapp
Enter fullscreen mode Exit fullscreen mode

The Compose form, which is what most teams actually deploy:

services:
  webapp:
    image: myapp
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
Enter fullscreen mode Exit fullscreen mode

no-new-privileges:true is the sleeper line. It blocks setuid binaries from elevating during a process exec, closing the residual escalation path that capability drops leave open if a setuid binary is still inside the image.

a writable root for no reason

Most apps write to /tmp, maybe a logging volume, and nothing else. Their root filesystem can be read-only and the app will never notice. Attackers will.

docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=100m \
  myapp
Enter fullscreen mode Exit fullscreen mode

In Compose:

services:
  webapp:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=100m
      - /var/run:rw,noexec,nosuid,size=10m
Enter fullscreen mode Exit fullscreen mode

The first time you ship this you'll discover one library that writes a cache file to /var/cache at startup. Add a tmpfs for it and move on. After that the surprises stop.

base images that age

The :latest tag pins nothing. The pin you actually want is a digest, but a version-with-distro tag (node:20.10.0-alpine3.19) is the working compromise. Then automate the bump.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"
Enter fullscreen mode Exit fullscreen mode

And scan the image. It doesn't matter much which scanner you pick (the lists overlap heavily), but pick one and run it on every build.

# Using Trivy
trivy image myapp:latest

# Using Snyk
snyk container test myapp:latest

# Using Docker Scout
docker scout cves myapp:latest
Enter fullscreen mode Exit fullscreen mode

Wire it into CI as a hard gate on critical and high:

# .github/workflows/security.yml
- name: Scan image
  run: |
    trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:latest
Enter fullscreen mode Exit fullscreen mode

Yes, you'll have weeks where the gate fires on a CVE you can't fix because there's no patched base image yet. That's a feature. It tells you which deploys are knowingly carrying risk.

the docker socket

If a container has /var/run/docker.sock mounted, it can start a sibling container with --privileged --pid=host -v /:/host and own the host. There is no way to make this safe.

# NEVER DO THIS
services:
  webapp:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock  # DON'T!
Enter fullscreen mode Exit fullscreen mode

It still shows up in build agents, log shippers, and "monitoring" sidecars from vendors who should know better. If you genuinely need to build images from inside a container, Kaniko does that without the socket. If you need to inspect other containers, the orchestrator's API is the supported path.

resource limits as a security control

Resource limits feel like a performance concern, but the most common DoS we saw on our cluster was a container OOM-killing its node by allocating until the kernel reaper showed up. Limits don't prevent that, they contain it.

docker run --rm \
  --memory="512m" \
  --memory-swap="512m" \
  --cpus="0.5" \
  --pids-limit=100 \
  myapp
Enter fullscreen mode Exit fullscreen mode

The Compose version:

services:
  webapp:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M
    pids_limit: 100
Enter fullscreen mode Exit fullscreen mode

--pids-limit is the underrated one. A fork bomb in your container will still take the container down, but it won't take its neighbors with it.

the hardened dockerfile, end to end

Putting it all together. This is roughly what every Node service in our prod cluster now looks like:

# syntax=docker/dockerfile:1

# Use specific version, not 'latest'
FROM node:20.10.0-alpine3.19 AS builder

# Install build dependencies
RUN apk add --no-cache dumb-init

WORKDIR /app

# Copy dependency files
COPY package*.json ./

# Install dependencies with audit
RUN npm ci --production --ignore-scripts && \
    npm audit --audit-level=moderate

# Production stage
FROM node:20.10.0-alpine3.19

# Install only runtime dependencies
RUN apk add --no-cache dumb-init

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Copy built artifacts from builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .

# Remove unnecessary files
RUN rm -rf .git .gitignore .dockerignore README.md tests/

# Switch to non-root user
USER nodejs

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node healthcheck.js || exit 1

# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]

# Run application
CMD ["node", "server.js"]

# Metadata
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp" \
      org.opencontainers.image.version="1.0.0" \
      org.opencontainers.image.vendor="My Company"
Enter fullscreen mode Exit fullscreen mode

And the matching Compose, with the runtime hardening that the Dockerfile can't express:

version: '3.8'

services:
  webapp:
    image: myapp:1.0.0
    container_name: webapp

    # Security options
    user: "1001:1001"
    read_only: true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true
      - seccomp:./seccomp.json

    # Resource limits
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 512M
    pids_limit: 200

    # Writable tmpfs for temp files
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=100m

    # Network isolation
    networks:
      - internal

    # Health check
    healthcheck:
      test: ["CMD", "node", "healthcheck.js"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

    # Logging
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

    # Restart policy
    restart: unless-stopped

networks:
  internal:
    driver: bridge
    internal: true
Enter fullscreen mode Exit fullscreen mode

the tools I actually used

For image scanning we landed on Trivy in CI and Docker Scout for local checks. Snyk has a nicer UI but the per-developer license adds up; Clair is what you reach for when nothing can leave the network. For runtime, Falco watches for the syscall patterns nobody should ever see in production (a shell spawned inside a webserver container is the canonical one). Open Policy Agent and its Kubernetes-native cousins, Gatekeeper and Kyverno, are where you encode the rules from this post so the next person can't push a Dockerfile that violates them. The policy engine is the part that makes the work stick.

the receipts

Six months after the audit, the same scanner came back with vulnerabilities down 94%, image sizes down 70%, root containers at zero (from 156), and a compliance score of 95% (from 23%). Zero security incidents that we know about, which is the only honest way to phrase that number.

The change none of those metrics capture is the cultural one. The CI gate caught seven Dockerfiles in the next quarter that would have shipped a USER root or a mounted Docker socket. Each of them was added by someone who'd read this exact post in our wiki and still missed something. The point of the gate isn't that engineers are careless. It's that the wrong defaults will outlast any number of training sessions.

The auditor who wrote one RCE and you own the cluster came back the next year. The line in this year's summary read no findings rated critical. I keep both of them in the same Slack channel. They're more useful together.

Top comments (0)