Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets

A container that runs is not a container that is safe. By default, a container runs as root, with a writable filesystem, the full set of Linux capabilities, and often a secret or two baked into the image. All of it is fixable. This post hardens a container step by step, without breaking it, and measures each change on a real Docker Engine.
Tip
node:22 carried 533 high or critical OS CVEs, node:22-alpine carried 2.Do not run as root. Add a non-root USER (or user: in Compose) so a container breakout is not instant host root.Make the root filesystem read-only with read_only: true, and add a tmpfs for the few paths that must be writable.Drop all capabilities with cap_drop: ALL, add back only what you need, and set no-new-privileges.Keep secrets out of the image. Use Compose secrets: at run time and RUN --mount=type=secret at build time. Never ENV or COPY a secret.Prerequisites
- Docker installed and running. See Install Docker on macOS, Windows (WSL2), and Linux.
- The lean multi-stage image from Lean Docker Images; we build on that slim base here.
Info
Scan first: what is actually in your image
Before hardening anything, look at what you are shipping. Trivy scans an image and reports known CVEs. The easiest way to run it is as a container. Here it scans the full Debian-based node:22, counting only HIGH and CRITICAL OS-package vulnerabilities:
1docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \2 aquasec/trivy:latest image --severity HIGH,CRITICAL --scanners vuln node:221node:22 (debian 12.15)2Total: 533 (HIGH: 501, CRITICAL: 32)533 high or critical vulnerabilities, 32 of them critical, before you have added a single line of your own code. Now scan the slim Alpine variant instead:
1node:22-alpine (alpine 3.24.1)2Total: 2 (HIGH: 2, CRITICAL: 0)Two. Same Node.js, a fraction of the attack surface, because Alpine ships almost none of the Debian userland those CVEs live in. (Trivy also reports the Node and npm packages bundled in the runtime, 11 either way; those live in the language layer, not the OS, so the base image does not change them.) This is the security half of the argument for a slim base, on top of the size win from the lean-images post. The scan.sh wrapper in the repo runs exactly this scan on any image.
Do not run as root
By default, the process inside a container runs as root. If an attacker escapes the container, or if a bind-mounted host path is involved, that root can become host root. The fix is a non-root user. The node images ship one called node (uid 1000); switch to it with a single USER line:
1# Hardened: slim base, and run as the built-in non-root 'node' user.2FROM node:22-alpine3WORKDIR /app4COPY server.js ./5# node:22-alpine ships a non-root 'node' user (uid 1000)6USER node7EXPOSE 30008CMD ["node", "server.js"]Build and check who the container runs as:
1docker build -t secure:hardened .2docker run --rm secure:hardened whoami3docker run --rm secure:hardened id -u1node21000Not root. That one line removes the most common and most dangerous default.
Warning
USER node # ... sets the username to the whole string including the #, and the container fails to start with "unable to find user". Put comments on their own line.Make the filesystem read-only
Most containers never need to write to their own filesystem at run time. If yours does not, mount it read-only so an attacker cannot drop a script or tamper with binaries. Compare a normal container with a read-only one:
1docker run --rm alpine touch /test.txt2docker run --rm --read-only alpine touch /test.txt1# first command: writes fine2# second command:3touch: /test.txt: Read-only file systemFor the paths that need to be writable (a cache, /tmp), add a tmpfs, which is an in-memory scratch space that never touches the image:
1docker run --rm --read-only --tmpfs /tmp alpine touch /tmp/test.txt # succeedsDrop capabilities
A root process inside a container still holds a set of Linux capabilities, fine-grained powers like changing file ownership or binding low ports. Most apps need none of them. Drop them all and see the difference. chown needs CAP_CHOWN:
1docker run --rm alpine chown nobody /tmp2docker run --rm --cap-drop ALL alpine chown nobody /tmp1# first command: chown succeeds2# second command:3chown: /tmp: Operation not permittedWith cap_drop: ALL the container cannot perform privileged operations, even as root. Add back only what you actually need with cap_add. Pair it with no-new-privileges:true, which stops a process from ever gaining more privileges (for example through a setuid binary).
Keep secrets out of the image
This is the one that bites teams hardest, because the mistake is invisible until someone pulls your image. The wrong way is to pass a token as a build arg and store it in the environment:
1# ANTI-PATTERN. Do NOT do this. Shown only to prove the leak.2ARG API_TOKEN3ENV API_TOKEN=$API_TOKENBuild that and the token is permanently in the image's metadata:
1docker history --no-trunc secure:baked | grep API_TOKEN1API_TOKEN=supersecret-token-valueAnyone who can pull the image can read it. The right way for build-time secrets is BuildKit's --mount=type=secret, which exposes the secret only during one RUN and never writes it to a layer:
1# syntax=docker/dockerfile:12RUN --mount=type=secret,id=api_token \3 test -s /run/secrets/api_token && echo "secret was available at build time"1docker build --secret id=api_token,src=api_token.txt -f Dockerfile.buildsecret -t secure:buildsecret .2docker history --no-trunc secure:buildsecret | grep -c "supersecret-token-value"3docker run --rm secure:buildsecret cat /run/secrets/api_token102cat: can't open '/run/secrets/api_token': No such file or directoryZero occurrences in the history, and the file does not exist in the final image. The secret did its job during the build and vanished. For run-time secrets, Compose has a secrets: block that mounts a file into the container at /run/secrets/, without putting it in the environment where docker inspect or a crash log would expose it.
Putting it all together
The stack's docker-compose.yml applies every one of these at once:
1services:2 web:3 build: .4 image: secure-demo5 ports:6 - "8080:3000"7 read_only: true # the container's root filesystem is read-only8 tmpfs:9 - /tmp # a small writable scratch space in memory10 cap_drop:11 - ALL # drop every Linux capability12 security_opt:13 - no-new-privileges:true # process can never gain more privileges14 secrets:15 - api_token # mounted at /run/secrets/api_token, not in env16 17secrets:18 api_token:19 file: ./api_token.txtBring it up and check the result:
1docker compose up -d2curl localhost:80801secure demo. running as uid 1000. secret mounted: trueNon-root, and the secret arrived as a mounted file, not an environment variable. Confirm the hardening holds from inside the container:
1docker compose exec web env | grep -i token # nothing: the token is not in the environment2docker compose exec web touch /oops.txt1touch: /oops.txt: Read-only file systemThe token is nowhere in the environment, and the read-only filesystem refuses the write. That is a container an attacker has very little room to work with.
The hardening checklist
Success
node:22-alpine had 2 OS CVEs versus 533 for node:22).Run as a non-root user (USER, or user: in Compose).Set read_only: true and add a tmpfs for writable paths.cap_drop: ALL, then cap_add only what you need.Set no-new-privileges:true.Keep secrets out of the image: Compose secrets: at run time, RUN --mount=type=secret at build time. Never ENV or COPY a secret.Common gotchas
The app breaks under read_only: true
It is trying to write somewhere. Find the path (logs, cache, a pid file) and add it as a tmpfs or a named volume, rather than removing read_only.
The app breaks under cap_drop: ALL
It needs a capability. The common one is binding a port below 1024, which needs CAP_NET_BIND_SERVICE. Add just that back with cap_add, or publish a high port and map it. Better still, run the app on a high port and let the proxy handle 80 and 443.
A non-root container cannot write to a mounted volume
The volume is owned by root on the host. Set the volume's ownership to your container's uid, or use a named volume, which Docker initializes with the right permissions.
Trivy reports vulnerabilities you cannot fix
Some CVEs have no patched version yet. Focus on HIGH and CRITICAL with a fix available, keep your base image updated, and rescan regularly rather than chasing an empty report.
Where to go next
Your container is now scanned, non-root, read-only, capability-stripped, and free of baked-in secrets. The last foundational question is which engine to run it with.
- Next in this series: Docker vs Podman, a hands-on comparison of the daemonless, rootless alternative and what actually changes when you migrate.
Verified on 2026-09-11 on a real Ubuntu 24.04.5 LTS system (arm64) with Docker Engine 29.8.0 (BuildKit). Captured: Trivy scans of node:22 (533 HIGH/CRITICAL OS CVEs, 32 critical) versus node:22-alpine (2, 0 critical); the hardened image running as uid 1000 (whoami node); `--read-only` refusing a write and `--tmpfs` allowing one; `--cap-drop ALL` turning a working `chown` into "Operation not permitted"; a BuildKit `--mount=type=secret` leaving zero occurrences of the token in `docker history` and no secret file in the image, versus an `ARG`/`ENV` build that printed `API_TOKEN=supersecret-token-value` in history; and the hardened Compose stack serving as uid 1000 with the secret mounted, no token in the environment, and a read-only root filesystem refusing `touch /oops.txt`.
Join the discussion on Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets
Likes, comments, and replies are available for authenticated readers with verified email addresses.


