Tools: Complete Guide to I Made My Docker Container Progressively More Secure

Tools: Complete Guide to I Made My Docker Container Progressively More Secure

The Setup

Scenario 1 — The Insecure Default

Layer Caching Is Real and Measurable

Running as Root

The Secret Leak

Scenario 2 — Running as a Non-Root User

Scenario 3 — Protecting Secrets with .dockerignore

Scenario 4 — Multi-Stage Builds

The Size Difference

Scenario 5 — Runtime Hardening

Read-Only Filesystem

Linux Capabilities

The Full Picture

What Surprised Me Most 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 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. 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: This simulates what exists in almost every real project. You will see exactly what happens to it. The starting Dockerfile is what you might write if you are new to Docker and following a basic tutorial: 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: 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. This was the most striking observation. I opened a shell inside the running container: 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: One addition to the Dockerfile: 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. 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: 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. .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. After rebuilding with this file in place: 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: 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. 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. COPY --from=builder transfers only what I explicitly listed. Everything else — npm, the package cache, compilers — is gone permanently. 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: 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. The Dockerfile controls what is inside the image. Runtime flags control what a running container is permitted to do at the operating system level. 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. 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 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

const uid = process.getuid(); const username = uid === 0 ? 'root -- DANGER' : `non-root (uid: ${uid})`; const uid = process.getuid(); const username = uid === 0 ? 'root -- DANGER' : `non-root (uid: ${uid})`; const uid = process.getuid(); const username = uid === 0 ? 'root -- DANGER' : `non-root (uid: ${uid})`; DATABASE_PASSWORD=super_secret_password_123 API_KEY=sk-live-abc123xyz789 STRIPE_SECRET=rk_live_do_not_share_this DATABASE_PASSWORD=super_secret_password_123 API_KEY=sk-live-abc123xyz789 STRIPE_SECRET=rk_live_do_not_share_this DATABASE_PASSWORD=super_secret_password_123 API_KEY=sk-live-abc123xyz789 STRIPE_SECRET=rk_live_do_not_share_this FROM node:20 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["npm", "start"] FROM node:20 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["npm", "start"] FROM node:20 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["npm", "start"] # CORRECT — npm install only re-runs when package.json changes COPY package.json ./ RUN npm install COPY . . # CORRECT — npm install only re-runs when package.json changes COPY package.json ./ RUN npm install COPY . . # 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 # WRONG — any code change forces a full reinstall COPY . . RUN npm install # WRONG — any code change forces a full reinstall COPY . . RUN npm install curl http://localhost:3000 curl http://localhost:3000 curl http://localhost:3000 App is running User ID : 0 Running as : root -- DANGER App is running User ID : 0 Running as : root -- DANGER App is running User ID : 0 Running as : root -- DANGER docker exec -it $(docker ps -q) sh cat /app/.env docker exec -it $(docker ps -q) sh cat /app/.env 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 DATABASE_PASSWORD=super_secret_password_123 API_KEY=sk-live-abc123xyz789 STRIPE_SECRET=rk_live_do_not_share_this DATABASE_PASSWORD=super_secret_password_123 API_KEY=sk-live-abc123xyz789 STRIPE_SECRET=rk_live_do_not_share_this RUN groupadd -r appuser && useradd -r -g appuser appuser COPY . . RUN npm install RUN chown -R appuser:appuser /app USER appuser RUN groupadd -r appuser && useradd -r -g appuser appuser COPY . . RUN npm install RUN chown -R appuser:appuser /app USER appuser RUN groupadd -r appuser && useradd -r -g appuser appuser COPY . . RUN npm install RUN chown -R appuser:appuser /app USER appuser App is running User ID : 999 Running as : non-root (uid: 999) App is running User ID : 999 Running as : non-root (uid: 999) App is running User ID : 999 Running as : non-root (uid: 999) echo "test" > /etc/passwd # Permission denied apt-get install curl # Permission denied (lock file) touch /bin/backdoor # Permission denied echo "test" > /etc/passwd # Permission denied apt-get install curl # Permission denied (lock file) touch /bin/backdoor # Permission denied echo "test" > /etc/passwd # Permission denied apt-get install curl # Permission denied (lock file) touch /bin/backdoor # Permission denied .env .env.* *.pem *.key *.cert .git node_modules Dockerfile* README.md .env .env.* *.pem *.key *.cert .git node_modules Dockerfile* README.md .env .env.* *.pem *.key *.cert .git node_modules Dockerfile* README.md docker run --rm secure-copy-app find /app -type f docker run --rm secure-copy-app find /app -type f docker run --rm secure-copy-app find /app -type f /app/app.js /app/package.json /app/package-lock.json /app/app.js /app/package.json /app/package-lock.json /app/app.js /app/package.json /app/package-lock.json docker run --rm secure-copy-app cat /app/.env # cat: /app/.env: No such file or directory docker run --rm secure-copy-app cat /app/.env # cat: /app/.env: No such file or directory docker run --rm secure-copy-app cat /app/.env # cat: /app/.env: No such file or directory # 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"] # 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"] # 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"] 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 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 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 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 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 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 docker exec -it $(docker ps -q) sh -c "echo test > /app/hacked.txt" # sh: /app/hacked.txt: Read-only file system docker exec -it $(docker ps -q) sh -c "echo test > /app/hacked.txt" # sh: /app/hacked.txt: Read-only file system docker exec -it $(docker ps -q) sh -c "echo test > /app/hacked.txt" # sh: /app/hacked.txt: Read-only file system docker inspect $(docker ps -q) | grep -E '"Memory"|"NanoCpus"' # "Memory": 134217728, (exactly 128 MB) # "NanoCpus": 500000000, (exactly 0.5 CPU cores) docker inspect $(docker ps -q) | grep -E '"Memory"|"NanoCpus"' # "Memory": 134217728, (exactly 128 MB) # "NanoCpus": 500000000, (exactly 0.5 CPU cores) docker inspect $(docker ps -q) | grep -E '"Memory"|"NanoCpus"' # "Memory": 134217728, (exactly 128 MB) # "NanoCpus": 500000000, (exactly 0.5 CPU cores) - Application runs as root - Credentials are baked into the image - Layer caching breaks on every code change