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
Key takeaways
- Scan your image. A slim base is not just smaller, it is safer:
node:22carried 533 high or critical OS CVEs,node:22-alpinecarried 2.- Do not run as root. Add a non-root
USER(oruser:in Compose) so a container breakout is not instant host root.- Make the root filesystem read-only with
read_only: true, and add atmpfsfor the few paths that must be writable.- Drop all capabilities with
cap_drop: ALL, add back only what you need, and setno-new-privileges.- Keep secrets out of the image. Use Compose
secrets:at run time andRUN --mount=type=secretat build time. NeverENVorCOPYa 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
Get the code. The hardened Dockerfiles, compose file, and Trivy wrapper for this post are in the docker-foundations repo, under
08-security/. Clone it to follow along.
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:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --severity HIGH,CRITICAL --scanners vuln node:22
node:22 (debian 12.15)
Total: 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:
node:22-alpine (alpine 3.24.1)
Total: 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:
# Hardened: slim base, and run as the built-in non-root 'node' user.
FROM node:22-alpine
WORKDIR /app
COPY server.js ./
# node:22-alpine ships a non-root 'node' user (uid 1000)
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Build and check who the container runs as:
docker build -t secure:hardened .
docker run --rm secure:hardened whoami
docker run --rm secure:hardened id -u
node
1000
Not root. That one line removes the most common and most dangerous default.
Warning
Do not put an inline comment on a
USERline. Dockerfiles have no inline comments: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:
docker run --rm alpine touch /test.txt
docker run --rm --read-only alpine touch /test.txt
# first command: writes fine
# second command:
touch: /test.txt: Read-only file system
For 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:
docker run --rm --read-only --tmpfs /tmp alpine touch /tmp/test.txt # succeeds
Drop 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:
docker run --rm alpine chown nobody /tmp
docker run --rm --cap-drop ALL alpine chown nobody /tmp
# first command: chown succeeds
# second command:
chown: /tmp: Operation not permitted
With 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:
# ANTI-PATTERN. Do NOT do this. Shown only to prove the leak.
ARG API_TOKEN
ENV API_TOKEN=$API_TOKEN
Build that and the token is permanently in the image's metadata:
docker history --no-trunc secure:baked | grep API_TOKEN
API_TOKEN=supersecret-token-value
Anyone 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:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_token \
test -s /run/secrets/api_token && echo "secret was available at build time"
docker build --secret id=api_token,src=api_token.txt -f Dockerfile.buildsecret -t secure:buildsecret .
docker history --no-trunc secure:buildsecret | grep -c "supersecret-token-value"
docker run --rm secure:buildsecret cat /run/secrets/api_token
0
cat: can't open '/run/secrets/api_token': No such file or directory
Zero 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:
services:
web:
build: .
image: secure-demo
ports:
- "8080:3000"
read_only: true # the container's root filesystem is read-only
tmpfs:
- /tmp # a small writable scratch space in memory
cap_drop:
- ALL # drop every Linux capability
security_opt:
- no-new-privileges:true # process can never gain more privileges
secrets:
- api_token # mounted at /run/secrets/api_token, not in env
secrets:
api_token:
file: ./api_token.txt
Bring it up and check the result:
docker compose up -d
curl localhost:8080
secure demo. running as uid 1000. secret mounted: true
Non-root, and the secret arrived as a mounted file, not an environment variable. Confirm the hardening holds from inside the container:
docker compose exec web env | grep -i token # nothing: the token is not in the environment
docker compose exec web touch /oops.txt
touch: /oops.txt: Read-only file system
The 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
For any container you run in production:
- Start from a slim base and scan it (
node:22-alpinehad 2 OS CVEs versus 533 fornode:22).- Run as a non-root user (
USER, oruser:in Compose).- Set
read_only: trueand add atmpfsfor writable paths.cap_drop: ALL, thencap_addonly what you need.- Set
no-new-privileges:true.- Keep secrets out of the image: Compose
secrets:at run time,RUN --mount=type=secretat build time. NeverENVorCOPYa 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.
Top comments (0)