DEV Community

Cover image for Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets
Shubham Sharma
Shubham Sharma

Posted on Originally published at techdevmantra.com

Docker Security Basics: Non-Root, Read-Only, Image Scanning, and Secrets

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:22 carried 533 high or critical OS CVEs, node:22-alpine carried 2.
  • Do not run as root. Add a non-root USER (or user: in Compose) so a container breakout is not instant host root.
  • Make the root filesystem read-only with read_only: true, and add a tmpfs for the few paths that must be writable.
  • Drop all capabilities with cap_drop: ALL, add back only what you need, and set no-new-privileges.
  • Keep secrets out of the image. Use Compose secrets: at run time and RUN --mount=type=secret at build time. Never ENV or COPY a secret.

Prerequisites

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
Enter fullscreen mode Exit fullscreen mode
node:22 (debian 12.15)
Total: 533 (HIGH: 501, CRITICAL: 32)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
node
1000
Enter fullscreen mode Exit fullscreen mode

Not root. That one line removes the most common and most dangerous default.

Warning

Do not put an inline comment on a USER line. 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
Enter fullscreen mode Exit fullscreen mode
# first command: writes fine
# second command:
touch: /test.txt: Read-only file system
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# first command: chown succeeds
# second command:
chown: /tmp: Operation not permitted
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build that and the token is permanently in the image's metadata:

docker history --no-trunc secure:baked | grep API_TOKEN
Enter fullscreen mode Exit fullscreen mode
API_TOKEN=supersecret-token-value
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
0
cat: can't open '/run/secrets/api_token': No such file or directory
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Bring it up and check the result:

docker compose up -d
curl localhost:8080
Enter fullscreen mode Exit fullscreen mode
secure demo. running as uid 1000. secret mounted: true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
touch: /oops.txt: Read-only file system
Enter fullscreen mode Exit fullscreen mode

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:

  1. Start from a slim base and scan it (node:22-alpine had 2 OS CVEs versus 533 for node:22).
  2. Run as a non-root user (USER, or user: in Compose).
  3. Set read_only: true and add a tmpfs for writable paths.
  4. cap_drop: ALL, then cap_add only what you need.
  5. Set no-new-privileges:true.
  6. Keep secrets out of the image: Compose secrets: at run time, RUN --mount=type=secret at build time. Never ENV or COPY a 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)