Container security: users, capabilities and secrets

Drop root, cut capabilities, make the filesystem read-only, and get credentials into a build without baking them into a layer.

Never run as root by default

A container runs as root unless you say otherwise, and root inside a container is root on the host kernel — namespaces limit what it can see and reach, not what it is. The first hardening step is also the cheapest: create a user and use it.

FROM node:20-alpine
WORKDIR /app

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

USER 10001:10001
CMD ["node", "server.js"]
ProblemSymptomFix
Files created root-owned inside a volumeHost user cannot write themMatch UID/GID, or set the volume's ownership in an entrypoint
Bind mount hides the image's node_modulesModule not found at runtimeMount dependencies separately, or install into the mounted path
USER without a matching passwd entryTools fail to resolve the user nameSet HOME explicitly or create the account with adduser -D -u 10001
No write permission in the working directoryApp crashes on startchown the directory at build time, or mount a writable volume
Port below 1024 with a non-root userPermission denied on bindUse a high port, or add only CAP_NET_BIND_SERVICE

Hardening the runtime

docker run --rm \
  --user 10001:10001 \
  --read-only --tmpfs /tmp:rw,size=64m \
  --cap-drop ALL --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  --security-opt seccomp=/etc/docker/seccomp-app.json \
  --security-opt apparmor=docker-default \
  --pids-limit 200 --memory 256m \
  app:1.4.2

docker inspect -f '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}}' app

# rootless mode removes the daemon's root entirely
dockerd-rootless-setuptool.sh install
systemctl --user start docker && export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
FlagWhat it removes or limits
--cap-drop ALLEvery Linux capability, including CHOWN and SETUID
--security-opt no-new-privilegesSetuid binaries gaining more privilege than you granted
--read-onlyWrites to the container filesystem; pair with --tmpfs for scratch space
--pids-limitFork bombs that starve the host of PIDs
--memoryRunaway memory use reaching the host
seccomp profileRoughly 300 syscalls the default profile already blocks
⚠️
--privileged grants all capabilities, all devices and an unconfined profile — it is not a debugging convenience, it is removing the isolation. Mounting /var/run/docker.sock is the same trade by another route: whoever controls that socket can start a container that owns the host.

Secrets that never become layers

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./

# the token exists only for this RUN and leaves nothing behind
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

COPY . .
USER 10001:10001
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t app:1.4.2 .

# proof that nothing leaked into a layer
docker history --no-trunc app:1.4.2 | grep -i -E 'token|secret|password' || echo "no secret in layers"

# at runtime, give the process a file or an env var instead of the image
docker run --rm --env-file ./secrets.env app:1.4.2
docker run --rm -v "$PWD/secrets:/run/secrets:ro" app:1.4.2
  • ARG and ENV are recorded verbatim in the image config and in every derived layer — never put a credential in either, and treat any such value as compromised once pushed.
  • docker history --no-trunc is the fastest way to prove a suspicion, and a registry scan will find a secret you pushed even after you delete the tag.
  • Runtime secrets belong in a mounted file, an env file with restricted permissions, or the platform's secret store — not in the image and not in the Compose file committed to git.
  • Prefer short-lived credentials issued at start time over long-lived keys baked into a deployment, so a leaked copy expires by itself.

FAQ

If I run as a non-root user, is the container safe?
It is safer by a wide margin, but it is one layer of several. Combine it with dropped capabilities, a read-only root filesystem, resource limits, a current base image and no host socket mount.
Why does my app fail writing to a mounted volume after I added USER?
The volume is owned by root or by a different UID. Align the container UID with the host directory's owner (chown -R 10001:10001 ./data), or adjust ownership from an entrypoint that starts as root and drops privileges.

Data persistence and backup patterns Production concerns: resources, health and logging

Last refreshed 2026-09-18.