Docker path · Running containers · Topic 8 of 12

Running as non-root and other basics

Not started

A container is only as isolated as the kernel features around it, and by default its process runs as root. Docker removes many dangerous capabilities and applies seccomp and AppArmor or SELinux profiles, but root inside a container is still user 0 to the host kernel unless you use user namespaces. Running as an unprivileged user, dropping the remaining privileges and keeping the image small are cheap and stop a large class of attacks.

Why it matters: Containers share the host kernel, so a process running as root with broad privileges is one bug away from the host. A few defaults close most of that gap.

  1. 1Do not run as root

    Set USER in the image. Without user namespaces, UID 0 in a container is UID 0 on the host kernel, only with fewer capabilities.

  2. 2Drop what you do not need

    Docker already removes many capabilities. --cap-drop ALL, no-new-privileges and a read-only root filesystem shrink it further.

  3. 3The Docker API is root

    Anyone who can talk to the daemon (the docker group, a mounted docker.sock) can start a privileged container and own the host.

  4. 4Less in the image, less to exploit

    Minimal bases, no secrets in layers, and regular rebuilds and scans keep known vulnerabilities out.

1

How it works

The explanation, with examples you can run.

Who the process is

docker run --rm alpine:3.22 id# uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),... docker run --rm hello:1 id        # image with USER node# uid=1000(node) gid=1000(node) groups=1000(node)

Set the user in the image so every runtime uses it:

FROM node:22-alpineWORKDIR /appCOPY --chown=node:node . .USER nodeCMD ["node", "server.js"]

The official node images ship a node user (UID 1000); distroless :nonroot tags run as UID 65532. Ports below 1024 traditionally need privileges, so let the app listen on 3000 or 8080 and publish whatever port you like on the host.

Capabilities and other switches

Root's power is split into capabilities. Docker's default set is much smaller than the full list, and you can drop the rest:

docker run --rm alpine:3.22 grep CapEff /proc/self/status# CapEff: 00000000a80425fb      (Docker's default set)docker run --rm --cap-drop ALL alpine:3.22 grep CapEff /proc/self/status# CapEff: 0000000000000000 docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \  --user 10001:10001 alpine:3.22 sh -c 'id; touch /x'# uid=10001 gid=10001 groups=10001# touch: /x: Read-only file system
  • --cap-drop ALL, then --cap-add only what is needed (for example NET_BIND_SERVICE).
  • --security-opt no-new-privileges stops setuid binaries from raising privileges.
  • --read-only with --tmpfs /tmp keeps the app's files unchangeable.
  • Never use --privileged for ordinary workloads: it gives the container all capabilities and access to host devices.

The daemon is root

The Docker daemon runs as root (unless you run it in rootless mode), and its API can mount any host path into a new container. Being in the docker group, or having /var/run/docker.sock mounted into a container, is therefore equivalent to root on that host. Treat CI jobs and tools that ask for the socket accordingly, or use rootless builders.

Two ways to make root in the container less powerful:

  • User namespaces (userns-remap) map container UID 0 to an unprivileged range on the host.
  • Rootless mode runs the daemon and containers as an ordinary user, at the cost of some features.

What goes into the image

  • Start from a minimal, pinned base, and rebuild regularly so patched packages arrive.
  • Scan images in CI (Docker Scout, Trivy or Grype) and fail on fixable critical issues.
  • Keep secrets out of layers and build arguments; use BuildKit secret mounts at build time and runtime secrets (files or a secrets manager) at run time.
  • Publish only the ports you need, and on servers bind internal ones to 127.0.0.1.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.A CI job mounts /var/run/docker.sock into a build container. What can code in that container do?
  2. 2.What does USER node in a Dockerfile change?
  3. 3.You add --read-only to docker run and the app fails writing /tmp/upload. What is the usual fix?

0 of 3 right so far. Get all three to finish this topic.