Skip to main content
BlogDevOps

How to Secure a Docker Container: 7 Steps for Beginners

How to secure a Docker container for beginners: non-root user, read-only filesystem, cap-drop, no image secrets, pinned versions and scans, plus checks.

Matthews Wong

Software Engineer

October 202610 min read
How to Secure a Docker Container: 7 Steps for Beginners

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-lab

The 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-lab

Order 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-lab

The 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 capability

The 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-ABOVE

Step 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:hardened

Both 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 id

Compare 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.

SymptomCauseFix
The app crashes on start with Read-only file system after adding --read-onlyThe app writes a temp file, cache or log to a folder that is now lockedFind 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 folderFiles were copied before USER, so they belong to rootUse 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 userA host folder bind-mounted into the container is owned by rootUse 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 ALLThe app, or a startup script, needs one specific capabilityAdd back just that capability with --cap-add. Do not use --privileged
The scan shows hundreds of findings and you do not know where to startNo severity filter, so every low-priority CVE is listedFilter 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 hardenedThe port was published as 3000:3000, which listens on every interface. EXPOSE in the Dockerfile only documents a port and publishes nothingPublish 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why does my Docker container run as root by default?

Docker does not change the user for you. If the image sets no USER instruction, the process runs as root inside the container. Add a USER line after creating a user with an explicit ID, then check with docker exec CONTAINER id.

Is running a container as root really dangerous?

Root inside a container is not the same as root on the host, and Docker removes some capabilities by default. Still, if the app is compromised, root gives an attacker far more to work with than a plain user. Docker's own security page says containers are safer when processes run as non-privileged users.

What does docker run --read-only do?

It mounts the container's root filesystem as read-only, so nothing can be written except to volumes or tmpfs mounts you open on purpose. An attacker cannot modify your code or drop a file in the app folder. If your app needs a scratch folder, add --tmpfs for that path.

Why does my app say operation not permitted after cap-drop ALL?

The process needs a Linux capability that is now removed. Read the error to find which one, then add back only that with --cap-add instead of removing the restriction or using --privileged. Most web apps on a port above 1024 need none.

How do I scan a Docker image for vulnerabilities for free?

Run docker scout cves IMAGE with --only-severity critical,high, or trivy image --severity HIGH,CRITICAL IMAGE. Both can read an image from your local Docker. Rebuild on a newer base image when something serious shows up, then scan again.