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

URL: https://www.matthewswong.com/en/blog/cara-mengamankan-container-docker/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: DevOps

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

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.

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.

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.

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.

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.

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

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.

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

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.

| 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

 draws the layers and shows which ones this site sets. For smaller images to scan, read

 and

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

## Sources

- Docker docs: docker container run reference
- Docker docs: running containers, runtime privilege and capabilities
- Docker docs: Compose services reference
- Docker docs: Dockerfile best practices
- Docker docs: docker scout cves reference
- Trivy docs: trivy image CLI reference

## Source links

- https://docs.docker.com/reference/cli/docker/container/run/
- https://docs.docker.com/engine/containers/run/
- https://docs.docker.com/reference/compose-file/services/
- https://docs.docker.com/build/building/best-practices/
- https://docs.docker.com/reference/cli/docker/scout/cves/
- https://trivy.dev/docs/latest/guide/references/configuration/cli/trivy_image/

## FAQ

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

---

- HTML version: https://www.matthewswong.com/en/blog/cara-mengamankan-container-docker/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-mengamankan-container-docker/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
