
A container is a process with a filesystem you chose. It is not a VM, and it is not safe because the file is named Dockerfile.
Running as root, mounting the Docker socket, and shipping a years-old base image turn an app bug into a host bug. Docker’s own engine page still says containers are quite secure if you run them correctly. The ‘if’ is the page.
The usual mistake is a 2020 checklist you never revisited after the runtime and the CVEs moved on.
This page is the image and runtime settings that still matter, on a URL that still says 2020 in the slug.
This URL still says 2020. Docker’s own Engine security page still says containers are quite secure if you run processes as non-privileged users inside the container. A fresh image with no USER line still starts as uid 0. That default is the 2026 failure. CIS Docker control 4.1 is still “create a user for the container.” The rest of this page is the five lines that make that default lose.
It is not the first line I would keep. The first line is who the process is, whether the rootfs is writable, whether FROM moves under you, whether a scanner saw the layers, and whether a secret ever became an env var. Pair the image with the vulnerable vs malicious dependency split when the scan lights up a pin. Keep the secure coding checklist next to the app that runs inside.
The 2020 title is expired
Docker Engine in 2026 still defaults the main process to root if the Dockerfile never sets USER. BuildKit has been the default builder since Docker 23.0. Secret mounts are first-party. The build secrets page says build arguments and environment variables are inappropriate for passing secrets, because they persist in the image config. That sentence is the whole ENV section of this article.
Three rings have to hold: a numeric USER that is not 0, a read-only rootfs, and a FROM pin that cannot float to latest.
SecureCoding
The CIS Docker Benchmark is still the checklist auditors map to. Control 4.1 is the user. Control 4.10 is secrets not baked into layers. Runtime read-only rootfs is the flag in the next section. User namespaces exist on the daemon and are still off by default. This page does not turn them on for you. It makes uid 0 lose inside the container first.
Rootless dockerd is a host control. It belongs next to the Ubuntu page, not in a Dockerfile. If the daemon is root and you mount /var/run/docker.sock into an app container, you have given that app the host. Do not.
USER that is not 0
Install packages as root. Then create a numeric uid. Then switch. Kubernetes runAsNonRoot can only prove the uid is not 0 when the uid is a number. A name-only USER app can still resolve to 0 if the image is wrong. Use USER 10001.
# syntax=docker/dockerfile:1
FROM node:22.8.0-bookworm-slim
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid 10001 --create-home app
WORKDIR /home/app
COPY --chown=app:app package.json package-lock.json./
RUN npm ci --omit=dev
COPY --chown=app:app dist./dist
USER 10001
CMD ["node", "dist/server.js"]
Identifiers stay app, 10001, and dist/server.js. Do not install sudo in the final image. Do not switch back to root after USER. A bind to port 80 inside the container is a capability problem, not a reason to stay 0. Publish 8080 and let the host proxy terminate TLS, the way the Ubuntu page already does.
The official Engine security page is the citation for running as a non-privileged user. CIS 4.1 is the audit name. docker inspect --format '{{.Config.User}}' must print 10001. Empty means root.
A multi-stage build keeps the compiler and the npm cache out of the last layer. Stage one installs build tools as root, compiles, and dies. Stage two copies only dist and the production node_modules, then switches to 10001. The scanner then sees a smaller tree. It also cannot “fix” a secret you copied in stage one if that stage is the one you tagged. Tag the last stage. Name it runtime so a tired docker build --target cannot publish the builder.
# syntax=docker/dockerfile:1
FROM node:22.8.0-bookworm-slim AS build
WORKDIR /src
COPY package.json package-lock.json./
RUN npm ci
COPY..
RUN npm run build
FROM node:22.8.0-bookworm-slim AS runtime
RUN groupadd --gid 10001 app \
&& useradd --uid 10001 --gid 10001 --create-home app
WORKDIR /home/app
COPY --from=build --chown=app:app /src/dist./dist
COPY --from=build --chown=app:app /src/node_modules./node_modules
USER 10001
CMD ["node", "dist/server.js"]
Read-only rootfs, tmpfs for scratch
Most app containers never need to write their own image filesystem. --read-only stops a break from dropping a binary or rewriting the tree you just scanned. /tmp, and often $HOME/.cache, still need somewhere writable. That is a tmpfs, not a hole in the rootfs.
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--user 10001:10001 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--name webapp \
registry.example.com/webapp:1.4.2
Compose:
services:
webapp:
image: registry.example.com/webapp:1.4.2
read_only: true
user: "10001:10001"
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
Identifiers stay webapp, 10001, and /tmp. If the process writes a pidfile or a compiled template, mount an explicit volume at that path. Do not turn read-only off because one library wanted /tmp. robinhoodexe’s note is the same: the tricky field is the read-only root, because it includes /tmp. Give it a memory mount. Confirm with docker inspect --format '{{.HostConfig.ReadonlyRootfs}}'. You want true.
Pin the digest, never latest
:latest is a moving pointer. Yesterday’s scan is not today’s layers. Pin a version tag you chose, then pin the digest that tag resolved to on the day you built.
docker buildx imagetools inspect node:22.8.0-bookworm-slim --format '{{json.Manifest}}'
# copy the sha256 into FROM
FROM node:22.8.0-bookworm-slim@sha256:REPLACE_WITH_INSPECT_DIGEST
Identifiers stay node:22.8.0-bookworm-slim and REPLACE_WITH_INSPECT_DIGEST. I am not inventing a digest. Copy one from inspect on your builder. A tag without a digest can be retargeted. A digest cannot. Rebuild on a schedule so the pin moves on purpose, the way you bump a lockfile. Floating FROM node:latest is how a Friday incident becomes “the base changed.”
Content trust, DOCKER_CONTENT_TRUST=1, is a signature check on pull. Use it if your registry and your signing workflow already exist. A digest pin is the control that works when you have not stood that workflow up. Unsigned Hub pulls of random community images are still a bad idea. Prefer a base you can name and a registry you admin.
Scan before you push
A CVE in a lockfile is homework. A fresh malicious layer is a different clock. The dependency page on this site is the split. On the image, run the scanner against the tag you are about to push, fail the job on the severity you agreed, then push that exact digest.
docker build -t registry.example.com/webapp:1.4.2.
docker scout cves registry.example.com/webapp:1.4.2
# or: trivy image registry.example.com/webapp:1.4.2
docker push registry.example.com/webapp:1.4.2
If your pipeline already runs Trivy or Grype, keep that. Do not add a second scanner to feel busy. Do not scan :latest after a retag. Scan the digest. A known CVE in glibc is a bump. A surprise high-severity in a package you did not add is a stop. The 8 September 2025 chalk and debug window is why a lockfile pin and npm ci still matter inside the image. npm install on the build agent during a two-hour hijack is how a clean Dockerfile ships a dirty layer.
Secrets are files, not ENV
The official build secrets page is the first-party line: arguments and environment variables persist in the image config. docker history will show an ARG. docker inspect will show an ENV. /proc/1/environ will show it to every process in the container, and to anyone who can exec. Child processes inherit it. Crash dumps inherit it. That is why a Stripe key in ENV is a leak waiting for a support bundle.
# BAD: lives in history and in the image config
# ARG NPM_TOKEN
# ENV STRIPE_KEY=sk_live_do_not
# FIX: secret exists only for this RUN
RUN --mount=type=secret,id=npm,target=/run/secrets/npm,uid=10001,mode=0400 \
NPM_CONFIG_USERCONFIG=/run/secrets/npm npm ci --omit=dev
docker build --secret id=npm,src=$HOME/.npmrc -t registry.example.com/webapp:1.4.2.
At run time, mount a file. Swarm secrets, Compose secrets, and Kubernetes secret volumes all land under a path you choose. Read the path from config, not from the environment, if the app can. Mode 0400, uid 10001. The Engine security page is also the citation for not exposing the daemon API over plain HTTP. That is a host control. This section is the image and the container.
--cap-drop ALL starts from zero capabilities and adds back only what you name. Most Node and Go servers need none. NET_BIND_SERVICE is the one people add to bind 80. Prefer 8080 and a proxy. no-new-privileges stops a later setuid binary from climbing. The default seccomp profile is on unless someone passed --security-opt seccomp=unconfined. Leave the default. --privileged turns all of this off. There is no “just for CI” exception that stays off the production compose file once it is in git.
Memory and CPU limits are the other half of the Engine security page’s cgroup story. A container that can allocate without a ceiling can take the host with it. Set --memory and --cpus on the same run line as --read-only. That is not a substitute for USER. It is how one runaway process stays a ticket instead of an outage.
Prove the image you ship
You are not scanning other people’s registries. You are reading the image and the running container you own.
docker inspect --format '{{.Config.User}}' registry.example.com/webapp:1.4.2must print10001.docker history --no-trunc registry.example.com/webapp:1.4.2must not contain a token, a pem, orENV STRIPE.- Start with the
docker run --read-onlyline above.docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' webappmust printtrue. docker exec webapp id -umust print10001.docker exec webapp cat /proc/1/environ | tr '\\0' '\\n'must not show a live secret.- The
FROMline in the Dockerfile must contain@sha256:.grep -n latest Dockerfilemust be empty. - The scan step in CI must run on that tag before
docker push.
docker inspect --format 'user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}}' webapp
docker exec webapp id -u
# Expect: user=10001 ro=true and uid 10001
If User is empty, the Dockerfile never reached USER 10001, or the compose file overrode it with user: "0". If rootfs is not read-only, the run flag lost to a compose file. If /proc/1/environ still has a key, you put it in environment:. Move it to a file. If the host sshd still allows root passwords, stop and do the Ubuntu page before you celebrate the image.
Questions we keep getting
Is root inside a container the same as root on the host?
No. Namespaces and the default capability drop shrink what uid 0 can do. Official docs still tell you to run as a non-privileged user. Several past runtime CVEs were fully or partly mitigated when the process was not uid 0. Stay off 0. Do not mount the docker socket to “make it easier.”
Do I still need DOCKER_CONTENT_TRUST?
If you already sign and verify, keep it. If you do not, pin the digest and scan. Content trust without a signing workflow is a flag nobody set. This page leads with USER, read-only, and the pin.
Can a read-only rootfs work with a secret file?
Yes, if the secret is a mount, not a copy into the image. Swarm and Kubernetes secret volumes are mounts. If a tool tries to write the secret onto the rootfs at start, that tool is the problem. Point it at the mount. Do not disable read-only to paper over a copy.



