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"]| Problem | Symptom | Fix |
|---|---|---|
| Files created root-owned inside a volume | Host user cannot write them | Match UID/GID, or set the volume's ownership in an entrypoint |
Bind mount hides the image's node_modules | Module not found at runtime | Mount dependencies separately, or install into the mounted path |
USER without a matching passwd entry | Tools fail to resolve the user name | Set HOME explicitly or create the account with adduser -D -u 10001 |
| No write permission in the working directory | App crashes on start | chown the directory at build time, or mount a writable volume |
| Port below 1024 with a non-root user | Permission denied on bind | Use 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| Flag | What it removes or limits |
|---|---|
--cap-drop ALL | Every Linux capability, including CHOWN and SETUID |
--security-opt no-new-privileges | Setuid binaries gaining more privilege than you granted |
--read-only | Writes to the container filesystem; pair with --tmpfs for scratch space |
--pids-limit | Fork bombs that starve the host of PIDs |
--memory | Runaway memory use reaching the host |
seccomp profile | Roughly 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:10001docker 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.2ARGandENVare 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-truncis 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.Related
Data persistence and backup patterns Production concerns: resources, health and logging
Last refreshed 2026-09-18.