Key Takeaway
To secure a Docker container as a beginner: run it as a non-root user with USER, add --read-only with --tmpfs for the folders that must be writable, drop every capability with --cap-drop ALL, keep secrets out of the image, pin the base image version, and scan with Docker Scout or Trivy. Each control has a one-line check.
Your container runs, the app answers on its port, and you call it done. Then someone asks whether it is secure, and you realise you have never looked at who the process runs as, what it is allowed to write, or what is baked into the image. Beginners usually get stuck in three places: not knowing the default is root, not knowing how to prove a setting took effect, and turning on a control that then breaks the app with an error nobody explains.
This guide hardens one small Node.js container in seven steps, and every step ends with a command that shows the result. I checked each flag against the official Docker and Trivy documentation. The point is not to make a container unbreakable, because no single control does that. It is to shrink what an attacker could use if your app is ever compromised, and to be honest about what is still missing. The last step compares the result with this site's own Compose file.
Want the bigger picture first, with an interactive diagram of the layers around one container? Take the free lesson Docker Container Hardening: Non-Root, Small Image, Scanning in the DevOps from zero track. This post is the hands-on walkthrough of the task itself.
What you need
You need Docker running on your machine and a terminal. If you have never built an image, start with the Docker from zero guide first. Splitting build and runtime into stages is its own topic, covered in the multi-stage build walkthrough linked at the end, so the Dockerfile here stays single-stage.
- Docker Engine or Docker Desktop, and a terminal
- A folder with one file, server.js, shown in step 1
- Optional for step 6: the Trivy CLI. Docker Scout needs nothing extra if your Docker has the scout command
Step 1: Build a baseline and prove it runs as root
Start with the unhardened version so you can see what changes. The app is a single file that answers hello. Note the port: it is published as 127.0.0.1:3000:3000, which means only your own machine can reach it. Writing just 3000:3000 would listen on every interface, a mistake that comes back in the error table.
// server.js
const http = require("node:http");
http.createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0");
# Dockerfile.root (no USER line, so the process runs as root)
FROM node:22-alpine
WORKDIR /app
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]docker build -f Dockerfile.root -t hardening-lab:root .
# Wrong: 3000:3000 listens on EVERY interface of the machine
# docker run -d --name hardening-lab -p 3000:3000 hardening-lab:root
# Right: 127.0.0.1 in front means only this machine can connect
docker run -d --name hardening-lab -p 127.0.0.1:3000:3000 hardening-lab:root
curl http://127.0.0.1:3000
# hello
# Which user does the process run as?
docker exec hardening-lab id
# uid=0(root) ... <- root, the default when no USER is set
docker rm -f hardening-labThe id command prints the user the process runs as. With no USER instruction the image runs as root, and the Compose reference says the same: if the image sets nothing, the user is root. Root inside a container is not root on the host, but it holds far more power than your app needs.
Step 2: Run as a non-root user
Create the user and group in the Dockerfile, give them an explicit ID, and switch with USER. Docker's best-practice page recommends an explicit UID and GID because automatically assigned ones can change between rebuilds. The two addgroup and adduser lines are the same ones this site's own Dockerfile uses, with group nodejs and user nextjs at ID 1001.
# Dockerfile.hardened
FROM node:22-alpine
WORKDIR /app
# Same two lines this site's Dockerfile uses: explicit ID 1001
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# Wrong: COPY server.js ./ (the file would belong to root)
# Right: hand ownership to the new user while copying
COPY --chown=nextjs:nodejs server.js ./
# Keep USER late: everything above still runs as root
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]docker build -f Dockerfile.hardened -t hardening-lab:hardened .
docker run -d --name hardening-lab -p 127.0.0.1:3000:3000 hardening-lab:hardened
docker exec hardening-lab id
# uid=1001(nextjs) ... <- no longer 0
docker rm -f hardening-labOrder matters. Files copied before USER belong to root, so the new user may not be able to write to them. Use COPY --chown for anything the app must modify, and keep USER as late as possible so earlier build steps still have the rights they need.
Check every control the same way: change one thing, rebuild, then run a command that proves it. A setting you cannot verify is a setting you only hope is on.
Step 3: Make the filesystem read-only
The --read-only flag mounts the container's root filesystem as read-only, so an attacker cannot drop a script or edit your code in place. Anything that legitimately needs to write gets its own writable spot: --tmpfs for throwaway files, or a volume for data you keep. The Docker reference shows --tmpfs taking mount options such as rw,noexec,nosuid, which also forbids running programs from that folder.
docker run -d --name hardening-lab \
-p 127.0.0.1:3000:3000 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=65536k \
hardening-lab:hardened
# The root filesystem refuses writes...
docker exec hardening-lab touch /var/tmp/x
# touch: /var/tmp/x: Read-only file system
# ...but the folder you opened on purpose still works (no output = success)
docker exec hardening-lab touch /tmp/x
curl http://127.0.0.1:3000
# hello
docker rm -f hardening-labThe test is to try to write. A read-only filesystem refuses everywhere except the folders you opened, and your app keeps working because it only reads its own code.
Step 4: Drop capabilities and block new privileges
Linux splits root power into capabilities, and Docker starts containers with a restricted default set. Docker's security page says the best practice is to remove all capabilities except those a process explicitly requires. The flag accepts the value ALL, so you drop everything and add back only what a real error asks for. A Node server on port 3000 needs none.
docker run -d --name hardening-lab \
-p 127.0.0.1:3000:3000 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=65536k \
--cap-drop ALL \
--security-opt no-new-privileges \
hardening-lab:hardened
# Read the settings back instead of trusting them
docker inspect -f '{{.HostConfig.CapDrop}}' hardening-lab
# [ALL]
docker inspect -f '{{.HostConfig.SecurityOpt}}' hardening-lab
# [no-new-privileges]
curl http://127.0.0.1:3000
# hello <- the app did not need a single capabilityThe second flag, no-new-privileges, stops a process from gaining more privileges than it started with, so tools such as su and sudo stop working inside the container. The pair costs nothing for a normal web app, and docker inspect reads the settings back to you.
Never reach for --privileged to silence an error. The Docker documentation says it gives all capabilities to the container, which undoes this whole step. Find the one capability the error names and add only that with --cap-add.
Step 5: Keep secrets out of the image and pin the base version
An image is a stack of layers that anyone who can pull it can read. A secret written with ENV in the Dockerfile stays in the image history forever, even if a later line tries to remove it. Pass secrets when the container starts instead. The full walkthrough is in the post on storing Docker secrets safely, linked at the end.
# Wrong: the value is stored in an image layer and shows in history
ENV API_KEY=example-not-a-real-key
# Right: nothing in the Dockerfile, the value arrives when the container starts
docker run -d --env-file .env hardening-lab:hardened
# Prove a built image is clean: --no-trunc stops long lines being cut short
docker history --no-trunc hardening-lab:hardened | grep API_KEY
# (no output = nothing found)The second half is pinning. Docker's best-practice page notes that image tags are mutable, so node:22-alpine can point at a different image next month. Pinning to a digest keeps the build on exactly the same image. The trade-off the page names is that you then update digests yourself and give up automatic security fixes, and it suggests Dependabot to keep them current.
# Show the digest of the base image you already pulled
docker image ls --digests node
# REPOSITORY TAG DIGEST IMAGE ID ...
# node 22-alpine sha256:(...) (...)
# Dockerfile: tag for humans, digest for exactness
FROM node:22-alpine@sha256:PASTE-THE-DIGEST-FROM-THE-OUTPUT-ABOVEStep 6: Scan the image for known vulnerabilities
Hardening the container does not fix a vulnerable package inside the image. A scanner compares the image contents with databases of known CVEs. Docker Scout and Trivy both read the image straight from your local Docker, and both can filter by severity so a first scan does not drown you in low-priority findings.
# Docker Scout: only the serious findings
docker scout cves --only-severity critical,high hardening-lab:hardened
# Trivy: the same idea
trivy image --severity HIGH,CRITICAL hardening-lab:hardenedBoth tools can also turn findings into a failing exit code, which is how you make a CI pipeline refuse a bad image. Scout returns exit code 2 when it finds vulnerabilities. For Trivy you pick the exit code yourself with --exit-code. The usual fix for a finding is to rebuild on a newer base image, then scan again.
# Scout: exit code 2 when vulnerabilities are detected
docker scout cves --exit-code --only-severity critical,high hardening-lab:hardened
echo $?
# Trivy: you choose the exit code to return on a finding
trivy image --severity HIGH,CRITICAL --exit-code 1 hardening-lab:hardened
echo $?Step 7: Move the settings into Compose
Long docker run lines do not survive for long, so write the same controls into a Compose file. Every key below is in the Compose services reference: user, read_only, tmpfs, cap_drop, security_opt, mem_limit and pids_limit.
# docker-compose.yml
services:
web:
image: hardening-lab:hardened
container_name: hardening-lab
user: "1001:1001" # same ID as the Dockerfile
read_only: true
tmpfs:
- /tmp # the one writable scratch folder
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 512m # same cap this site uses on its VPS
pids_limit: 100 # an example value, tune it for your app
ports:
- "127.0.0.1:3000:3000" # loopback only, as in step 1
# Start it, then run the same proof as before
docker compose up -d
docker exec hardening-lab idCompare this with the real file behind this site. Its Compose file already binds the port to loopback, caps memory at 512m and one CPU, defines a health check, and its Dockerfile runs as uid 1001. It does not set read_only or cap_drop, and no image scanner runs anywhere in the repository. I name those gaps on purpose: a hardening list is only useful if it says what is not done yet.
Common errors and how to fix them
Most breakage after hardening comes from one control meeting one assumption. Match your symptom to a row.
| Symptom | Cause | Fix |
|---|---|---|
| The app crashes on start with Read-only file system after adding --read-only | The app writes a temp file, cache or log to a folder that is now locked | Find the path in the error, then open only that path with --tmpfs, or a volume if the data must survive restarts |
| permission denied when the non-root user writes to the app folder | Files were copied before USER, so they belong to root | Use COPY --chown=nextjs:nodejs for files the app must change, or write to a tmpfs or volume instead |
| A mounted volume is not writable after switching to a non-root user | A host folder bind-mounted into the container is owned by root | Use a named volume, which Docker seeds with the ownership from the image, or chown the host folder to the container UID |
| operation not permitted after --cap-drop ALL | The app, or a startup script, needs one specific capability | Add back just that capability with --cap-add. Do not use --privileged |
| The scan shows hundreds of findings and you do not know where to start | No severity filter, so every low-priority CVE is listed | Filter with --only-severity critical,high for Scout or --severity HIGH,CRITICAL for Trivy, then rebuild on a newer base image |
| The container is reachable from other machines even though the app is hardened | The port was published as 3000:3000, which listens on every interface. EXPOSE in the Dockerfile only documents a port and publishes nothing | Publish as 127.0.0.1:3000:3000 and let a reverse proxy on the same machine face the internet |
Checklist before you ship the container
- docker exec shows a non-root uid, not 0
- Writing to the app folder fails with Read-only file system, while the tmpfs folder still works
- docker inspect shows ALL under CapDrop and no-new-privileges under SecurityOpt
- docker history shows no secret, and the FROM line is pinned to a digest
- A scan of critical and high findings has been run, and the port is bound to 127.0.0.1
Learn more
The free lesson Docker Container Hardening: Non-Root, Small Image, Scanning draws the layers and shows which ones this site sets. For smaller images to scan, read Docker containerization best practices and distroless image optimization. Next in this series: split build from runtime in the multi-stage build walkthrough, and keep secrets out of images in the Docker secrets guide.
The rule to carry: harden a container one control at a time, and never keep a control you cannot prove with a command. Non-root, read-only, no capabilities, no secrets, pinned and scanned will not make you invulnerable, but each one removes something an attacker would otherwise get for free.

