Docker Container Hardening: Non-Root, Small Image, Scanning
Harden a Docker container step by step: non-root user, minimal base image, loopback ports, resource caps and CVE scanning, checked against a real setup.
By Matthews WongReviewed on
Key takeaway
Hardening a container means shrinking what an attacker could use if your app is compromised. The basics are a small base image, a non-root user, a port bound only where it is needed, resource limits, and scanning the image for known vulnerabilities. No single control is enough, and a real setup usually has gaps worth naming.
You will be able to
- List the layers of container hardening and say which ones this site's Docker files actually set.
- Bind a published port to loopback, and explain why EXPOSE alone publishes nothing.
- Scan an image with Docker Scout or Trivy and make a pipeline fail on serious findings.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
The layers around one container
Five controls stack around a container: a small image, a non-root process, a loopback-only port, resource caps with a health check, and a vulnerability scan of the image, which this repo does not run.
Keys: ← → step · Home / End · K play or pause
A visitor reaches a container through a proxy and a port. Around that container sit the controls this lesson walks through, one layer at a time.
Start with a minimal base, here Alpine, and let a multi-stage build copy in only three folders. Less software in the image means less to patch and less to scan.
FROM node:20-alpine AS runnerThe server runs as a system user with uid 1001, not as root. If the app is compromised, the attacker starts with an unprivileged account.
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs USER nextjsPredict the next step The compose file publishes the app's port. Which host address does it bind to? (Every interface of the host · Loopback only)The port binds to 127.0.0.1, so only the proxy on the same machine reaches it. A visitor who tries the port directly from outside finds nothing.
ports: bound to 127.0.0.1 onlyThe container may use 512 MB of memory and one CPU, and a health check probes it every 30 seconds. A runaway app is contained and a dead one is noticed.
mem_limit: 512m cpus: "1.0"Predict the next step What does a vulnerability scanner compare an image's contents against? (A list of the host's open ports · A database of known vulnerabilities)A scanner lists the image's components and matches them against a vulnerability database. This repo runs none; it is the layer to add next, and the dashed wire shows where.
docker scout cves --only-severity critical,high --exit-code
What does hardening a container actually mean?
Hardening is not one switch. It is a set of small decisions that each remove something an attacker could use after getting into your app: software they could abuse, privileges they could inherit, networks they could reach, resources they could exhaust, and known flaws they could exploit. Docker's own security guide says root inside a container has much less privilege than the host's root, and that Docker is quite secure especially if you run your processes as non-privileged users inside the container.
Read more (1)Show less
That sentence is also a warning against stopping early. Dropping privileges makes serious damage harder, not impossible, so you stack controls. This lesson walks the stack on this site's own files and marks each layer as set, or not set, because an honest list of gaps is more useful than a claim of being secure.
Why run as a non-root user?
A container process runs as whatever user the image says. The Compose reference is blunt: if the image sets no user, the container runs as root. Docker's best-practice guide gives the rule in one line: if a service can run without privileges, use USER to change to a non-root user. This site's runner stage creates a system user with uid 1001, copies the build output with that owner, and then switches with USER.
Read more (1)Show less
The multi-stage lesson covers the COPY and ownership mechanics. The point here is where it sits in the stack: it limits what a compromised app can do inside the container, and it costs nothing at runtime. It is the cheapest layer, which is why it comes before the harder ones.
How small should the base image be?
Docker's guidance is to build from a trusted source and keep the image small, choosing a minimal base that matches your needs, and it names Alpine as tightly controlled and small. It also suggests a fuller image for building and a slimmer one for production, which is exactly what a multi-stage build gives you. This Dockerfile uses node:20-alpine for every stage and ships only three folders from the builder.
Read more (2)Show less
The .dockerignore file is a quieter part of the same idea. It keeps .git, .env and .env.* out of the build context, so they are not copied into the builder by COPY . . in the first place. Secrets that never enter a stage cannot leak from it.
Two honest limits. This Dockerfile pins a tag, not a digest, and Docker's guide says you can pin an image to a specific digest to secure your supply chain, and should rebuild regularly to pick up updated dependencies. A tag is easier to keep current; a digest is easier to trust. Whichever you choose, a rebuild is how fixes arrive.
What does the Compose file add around the container?
The published port is bound to 127.0.0.1 on the host. The Compose reference warns that a port published without a host address uses all interfaces and can expose the container directly to the internet if the host has a public address. Binding to loopback means only something on the same machine, here the reverse proxy, can reach it. Note that EXPOSE 3000 in the Dockerfile is documentation: Docker says it does not publish the port.
Read more (2)Show less
mem_limit caps the memory the container can allocate and cpus caps the CPUs it may use, here 512 MB and one. A health check runs wget against /robots.txt every 30 seconds so a dead app is noticed, and the json-file log driver is capped at three files of 10 MB so logs cannot fill the disk.
Now the gaps. The Compose reference documents read_only for a read-only filesystem, cap_drop for removing Linux capabilities, and security_opt for options such as no-new-privileges. This file sets none of them, and it sets no user override either, because the image already sets one. They are the next hardening steps, not things to pretend are done.
How do you scan an image for known vulnerabilities?
A scanner builds an inventory of what is inside an image, a software bill of materials, and matches it against a continuously updated vulnerability database. Docker Scout does this from the Docker CLI: docker scout cves analyzes an image, --only-severity filters to levels such as critical and high, and --exit-code makes the command return exit code 2 when vulnerabilities are found, which is what fails a CI job. Without an artifact argument it analyzes the most recently built image.
Read more (2)Show less
Trivy is the common open-source alternative: trivy image followed by the image name. Per its docs, vulnerability and secret scanning are on by default, while license scanning is off until you ask with --scanners.
This repository runs neither: no scanner appears in the Dockerfile, the compose file or the two workflows. That is a finding, not a failure of the lesson. Because the database keeps updating, an image that scanned clean last month can report findings today, so a scan belongs in the pipeline and on a schedule, not run once.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjsThe whole non-root setup is one line here plus USER nextjs at line 64. The group and the user are both system accounts. The fixed numeric id 1001 matters again in docker-compose.yml, whose comment relies on it to explain who owns a named volume.
node_modules
.git
.env
.env.*
.DS_Store
npm-debug.log*The first lines of the ignore file. .git, .env and .env.* never enter the build context, so no later COPY can carry history or secrets into an image layer. node_modules is excluded too, since the builder takes it from the deps stage.
ports:
# Bind to loopback only — nginx terminates TLS and proxies to this port.
- "127.0.0.1:${APP_PORT:-3002}:3000"
mem_limit: 512m
cpus: "1.0"Three hardening decisions in five lines: who can reach the app, how much memory it may take, and how much CPU. The comment states the reason for the loopback bind. The Dockerfile's NODE_OPTIONS comment explains that its Node heap limit must stay below this memory cap.
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://127.0.0.1:3000/robots.txt",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20sA health check turns a silent failure into a visible state. It probes a cheap page from inside the container, so it needs no outside access. It tells you the app has stopped answering; it does not tell you the app was compromised.
Mistakes that cost real time
A port published on every interface
Writing 3000:3000 with no host address binds every interface. On a host with a public address the app is then reachable straight from the internet, around the proxy, its rate limits and its TLS. Write 127.0.0.1:3000:3000 when only a local proxy needs it.
Trusting EXPOSE to close or open a port
EXPOSE only documents the port for people reading the image. It neither publishes the port nor protects it. Publishing happens with -p or a ports entry, and protection comes from where you bind and what filters traffic before it.
A scan that reports but never blocks
A scanner that prints findings into a log nobody reads protects nothing. Use the exit code option so serious findings fail the job, filter by severity so the gate stays usable, and rescan on a schedule, because the vulnerability database keeps changing after the image is built.
Pinned and never rebuilt
Pinning a base image to a digest makes builds reproducible and also freezes you on its flaws. Docker's guidance pairs pinning with regular rebuilds for updated dependencies. If you pin, schedule the rebuild; if you use a tag, accept that the same line can mean different content next month.
| Layer | What it limits | In this repo |
|---|---|---|
| Minimal base, multi-stage | Software in the image | Set: alpine, three folders |
| Non-root USER | Privilege inside the container | Set: uid 1001 |
| Loopback port | Who can reach the app | Set |
| Memory and CPU caps | Resource exhaustion | Set |
| read_only, cap_drop, no-new-privileges | Writes, capabilities, privilege gain | Not set |
| Image vulnerability scan | Known flaws in components | Not wired in |
Do it on your own machine
You need Docker. You will check which user a container runs as, build a non-root image, harden how it runs, and scan it.
Ask a stock image who it runs as. If it prints uid=0, the process is root.
docker run --rm node:20-alpine idWrite a tiny image that creates an unprivileged user and switches to it, then build it.
# Dockerfile FROM node:20-alpine RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app USER app CMD ["node", "-e", "setInterval(() => {}, 1000)"] # shell docker build -t hardening-lab . docker run --rm hardening-lab idRun it from Compose with a read-only filesystem, no Linux capabilities and no privilege gain, then confirm it is up.
# compose.yaml services: lab: image: hardening-lab read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true mem_limit: 128m ports: - "127.0.0.1:3000:3000" # shell docker compose up -d docker compose psScan the image you just built, showing only critical and high findings, and read the exit code. With Trivy installed, trivy image hardening-lab does the same job.
docker scout cves --only-severity critical,high --exit-code echo $?
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Which hardening controls does this site's compose file set, and which does it not?Show the answer
Answer:It sets a loopback-only port, a memory cap, a CPU cap, a health check and capped logs. It does not set read_only, cap_drop or no-new-privileges. The non-root user comes from the Dockerfile, not from Compose.
2.The Dockerfile says EXPOSE 3000. Does that publish the port?Show the answer
Answer:No. EXPOSE documents the port. Publishing is done by a ports entry in Compose or -p on docker run, and this site's entry binds it to 127.0.0.1 only.
3.Is this site's image scanned for vulnerabilities in its pipeline?Show the answer
Answer:Not in anything this repository contains: the Dockerfile, the compose file and the two workflows reference no scanner. Docker Scout or Trivy would be the next layer to add, with an exit code so serious findings fail the job.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.