I recently completed a structured Docker security lab that walks through five progressively more secure versions of the same minimal Node.js application. Rather than just reading about Docker security best practices, I wanted to observe each vulnerability and its fix directly in the terminal.
This is what I found.
The Setup
The application is deliberately simple — a Node.js HTTP server that reports one thing: the user ID of the process running it. That single output makes security differences immediately visible.
const uid = process.getuid();
const username = uid === 0 ? 'root -- DANGER' : `non-root (uid: ${uid})`;
When uid is 0, the process is root. When it is anything else, it is a restricted user. Every scenario change shows up in that number.
I also created a .env file containing fake credentials:
DATABASE_PASSWORD=super_secret_password_123
API_KEY=sk-live-abc123xyz789
STRIPE_SECRET=rk_live_do_not_share_this
This simulates what exists in almost every real project. You will see exactly what happens to it.
Scenario 1 — The Insecure Default
The starting Dockerfile is what you might write if you are new to Docker and following a basic tutorial:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]
Layer Caching Is Real and Measurable
Before worrying about security, I observed something important about build performance. The first build took noticeably longer — every layer ran from scratch with no cache available.

shows the first cold build running each layer sequentially with real timing output
The second build, with nothing changed, was almost instant. Every layer showed CACHED. Docker had stored each layer separately and reused all of them.

shows all layers returning CACHED and the dramatically shorter build time
Then I added a single comment to app.js and rebuilt. The COPY . . layer was invalidated, and so was every layer above it — including RUN npm install. My dependencies reinstalled even though package.json had not changed at all.

shows exactly which layer lost the cache hit and every layer above it re-executing
This matters because the order of instructions in your Dockerfile directly controls how efficient your builds are. The correct pattern is:
# CORRECT — npm install only re-runs when package.json changes
COPY package.json ./
RUN npm install
COPY . .
# WRONG — any code change forces a full reinstall
COPY . .
RUN npm install
Running as Root
curl http://localhost:3000
App is running
User ID : 0
Running as : root -- DANGER

shows the terminal with uid 0 printed, confirming the process is running as root

shows the curl output with "root -- DANGER" in the response body
uid 0 is root. The application process and everything it can touch inside the container has unrestricted access.
The Secret Leak
This was the most striking observation. I opened a shell inside the running container:
docker exec -it $(docker ps -q) sh
cat /app/.env
DATABASE_PASSWORD=super_secret_password_123
API_KEY=sk-live-abc123xyz789
STRIPE_SECRET=rk_live_do_not_share_this

shows all three credentials printed inside the running container with no authentication required
COPY . . had silently copied the .env file into the image. Anyone who can pull that image from a registry can read those credentials. The container does not even need to be running — docker run --rm insecure-app cat /app/.env returns the same output.
Three problems, one basic Dockerfile:
- Application runs as root
- Credentials are baked into the image
- Layer caching breaks on every code change
Scenario 2 — Running as a Non-Root User
One addition to the Dockerfile:
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY . .
RUN npm install
RUN chown -R appuser:appuser /app
USER appuser
The USER instruction switches the active user. Every subsequent instruction in the Dockerfile — and the process that starts when the container runs — uses this identity instead of root.
App is running
User ID : 999
Running as : non-root (uid: 999)

shows curl output with uid 999, confirming the app is no longer running as root
To make this concrete, I opened a shell inside the running container and tried several things as appuser:
echo "test" > /etc/passwd # Permission denied
apt-get install curl # Permission denied (lock file)
touch /bin/backdoor # Permission denied

shows all three write attempts denied with "Permission denied" errors
An attacker who exploits the application now inherits appuser's restrictions — not root's unlimited access. This is called blast radius reduction. You cannot always prevent exploitation; you can ensure the damage is contained.
What this does not fix: appuser can still read files it has permission to access. The .env file is still inside the image. Running cat /app/.env inside the non-root container still works because appuser owns those files. The secrets problem needs a separate fix.
Scenario 3 — Protecting Secrets with .dockerignore
.dockerignore is a plain text file placed alongside your Dockerfile. It lists patterns that Docker excludes from the build context before any COPY instruction runs.
.env
.env.*
*.pem
*.key
*.cert
.git
node_modules
Dockerfile*
README.md
After rebuilding with this file in place:
docker run --rm secure-copy-app find /app -type f
/app/app.js
/app/package.json
/app/package-lock.json

shows find output listing only app.js, package.json, and package-lock.json with no .env present
The .env file is gone. I confirmed it:
docker run --rm secure-copy-app cat /app/.env
# cat: /app/.env: No such file or directory

shows the "No such file or directory" error when attempting to read .env
I then created a test.pem file in my project directory and rebuilt. The .pem pattern matched it automatically — no additional configuration needed.

shows test.pem absent from the find output after rebuild, proving the *.pem pattern worked
The critical thing to understand: .dockerignore runs before any COPY instruction executes. Files excluded this way are never sent to the Docker daemon at all. They cannot appear in any layer, not even temporarily. This is different from deleting a file inside the container — deletion still creates a layer, and forensic tools can recover data from earlier layers in the image history.
Scenario 4 — Multi-Stage Builds
After three scenarios, the image still contained compilers, npm, apt, curl, git, and hundreds of other programs the application does not need to serve HTTP traffic. A multi-stage build fixes this.
# Stage 1: builder — uses the full Node.js image
FROM node:20 AS builder
WORKDIR /build
COPY package.json ./
RUN npm install
COPY app.js .
# Stage 2: runtime — uses a minimal image
FROM node:20-slim
WORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=builder /build/app.js ./app.js
COPY --from=builder /build/node_modules ./node_modules
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "app.js"]
COPY --from=builder transfers only what I explicitly listed. Everything else — npm, the package cache, compilers — is gone permanently.
The Size Difference
| Image | Size |
|---|---|
| insecure-app | ~1.10 GB |
| nonroot-app | ~1.10 GB |
| multistage-app | ~240 MB |

shows docker images output with all three images listed side by side, making the 870 MB difference visible
Adding a non-root user does not change image size — it changes who runs the application. The multi-stage build removed roughly 870 MB, an 78% reduction.
Inside the multi-stage container, none of these existed:
npm --version # sh: npm: not found
curl --version # sh: curl: not found
git --version # sh: git: not found
apt-get # sh: apt-get: not found

shows each tool command returning "not found" inside the multi-stage container
An attacker who achieves code execution inside this container cannot use these tools to download malware, compile attack utilities, or interact with external systems. The tools are not there.
The 870 MB difference is not just storage. It is attack surface that no longer exists.
Scenario 5 — Runtime Hardening
The Dockerfile controls what is inside the image. Runtime flags control what a running container is permitted to do at the operating system level.
docker run \
--read-only \
--tmpfs /tmp \
--memory="128m" \
--cpus="0.5" \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
-p 3000:3000 \
multistage-app
Read-Only Filesystem
docker exec -it $(docker ps -q) sh -c "echo test > /app/hacked.txt"
# sh: /app/hacked.txt: Read-only file system

shows the kernel-level "Read-only file system" error rejecting the write attempt
The kernel rejected the write. An attacker cannot persist any file, install any tool, or modify any binary. --tmpfs /tmp provides a small in-memory scratch area if the application needs to write temporary files at runtime.
Resource Limits
docker inspect $(docker ps -q) | grep -E '"Memory"|"NanoCpus"'
# "Memory": 134217728, (exactly 128 MB)
# "NanoCpus": 500000000, (exactly 0.5 CPU cores)

shows the Memory and NanoCpus values returned by docker inspect confirming the limits are registered at the kernel level
These limits are enforced by the Linux kernel's cgroup subsystem — not by application code. A compromised container cannot exhaust host memory or CPU.
Linux Capabilities
Linux divides root privilege into roughly 40 distinct units called capabilities. By default, a container receives about 15 of them. --cap-drop=ALL removes every one. The application still served HTTP traffic correctly after dropping all capabilities — it needed none of them.

shows a capability-dependent command failing inside the container while curl http://localhost:3000 still succeeds, proving the app works without any capabilities
--security-opt no-new-privileges:true prevents any process inside the container from gaining more privileges than it started with, even if a setuid binary is present on the filesystem.
The Full Picture
| Scenario | Change | Root? | Secrets in Image? | Build Tools? | Runtime Constraints |
|---|---|---|---|---|---|
| 1 — Default | None | ✅ Yes | ✅ Yes | ✅ Yes | None |
| 2 — Non-Root | USER appuser |
❌ No | ✅ Yes | ✅ Yes | None |
| 3 — .dockerignore | Exclude secrets | ❌ No | ❌ No | ✅ Yes | None |
| 4 — Multi-Stage | Separate stages | ❌ No | ❌ No | ❌ No | None |
| 5 — Runtime | Kernel flags | ❌ No | ❌ No | ❌ No | Read-only, capped, no capabilities |
What Surprised Me Most
COPY . . is silent about what it includes. There is no warning when it bundles your .env file. No error. The build succeeds. The credentials are just there, readable by anyone with access to the image. A single .dockerignore file — two minutes to write — prevents this entirely.
Image size and security are directly related. I expected the multi-stage build to be a performance optimisation. I did not expect it to be a meaningful security improvement. But removing 870 MB of programs from an image is the same as removing 870 MB of potential attack tools.
The Dockerfile and the runtime flags protect different things. An attacker who bypasses the application layer still faces constraints from the operating system if the runtime flags are in place. Neither layer replaces the other.
If you are working through Docker security for the first time, run the insecure scenario first and look at the output of cat /app/.env inside the running container before adding .dockerignore. Seeing the credentials appear in the terminal makes the fix feel much more real than reading about it.
Top comments (0)