Bekzod Erkinov
Listen to Article
Loading...Table of contents · 11 sections
Docker Best Practices You Can Verify: Measured Before and After
One small Express app, eight practices, and a command after each that proves it worked. The headline numbers, measured on one machine on 2026-10-07 (Docker Engine 29.7.2 on Docker Desktop, Trivy 0.75.0): 1.87 GB to 244 MB, uid 0 to 1000, 20 CRITICAL findings to 0.
The size drop is not spread evenly across the practices. Almost all of it comes from one change, switching the base image to alpine. Multi-stage builds and digest pinning do nothing for size here. I built each step separately so the table shows it:
| Image | What changed | Disk | Content | uid | CRITICAL / HIGH |
|---|---|---|---|---|---|
| v0 | node:22, COPY . . |
1.87 GB | 494 MB | 0 | 20 / 465 |
| v1 | + .dockerignore |
1.74 GB | 431 MB | 0 | 20 / 465 |
| v2 | + npm ci --omit=dev, layer order |
1.65 GB | 413 MB | 0 | 20 / 464 |
| s1 | v2 as a multi-stage build, still node:22 |
1.64 GB | 411 MB | 0 | not scanned |
| s2 | s1 on node:22-alpine |
244 MB | 61.5 MB | 0 | not scanned |
| s3 | s2 pinned to a digest | 244 MB | 61.5 MB | 0 | not scanned |
| v3 | s3 + USER node + HEALTHCHECK |
244 MB | 61.5 MB | 1000 | 0 / 12 |
| v4 | v3 minus npm in the runtime stage | 244 MB | 61.5 MB | 1000 | 0 / 1 |
"Disk" and "Content" are the two columns of docker image ls on Docker Desktop's containerd image store: unpacked size on disk, and the size of the compressed content blobs. These are Docker Desktop's column names; if you use the classic image store you'll see a single SIZE column instead. The scan counts for v1 and v2 barely move from v0, which is a finding on its own: .dockerignore and production-only dependencies shrink the image, but they don't fix a single CVE. The CVEs live in the base image.
This is a local, one-container test. It tells you what each change does to size, privileges and scan results. It says nothing about behavior under production traffic, replica counts or noisy neighbours; that needs a real environment.
The test app and how to reproduce the numbers
server.js is 17 lines. Four probe endpoints: / returns the uid, /health returns ok, /write?path= tries a write, /alloc?mb= holds memory.
const express = require('express');
const fs = require('fs');
const app = express();
const hold = [];
app.get('/', (_req, res) => res.json({ ok: true, uid: process.getuid() }));
app.get('/health', (_req, res) => res.send('ok'));
app.get('/write', (req, res) => {
const p = req.query.path || '/app/x.txt';
try { fs.writeFileSync(p, 'hi'); res.send('wrote ' + p); }
catch (e) { res.status(500).send(e.code + ' ' + p); }
});
app.get('/alloc', (req, res) => {
const mb = Number(req.query.mb || 10);
hold.push(Buffer.alloc(mb * 1024 * 1024, 1));
res.send('held ' + hold.length * mb + ' MB');
});
app.listen(3000, () => console.log('listening on 3000'));
package.json:
{ "name": "demo", "version": "1.0.0", "main": "server.js",
"scripts": { "start": "node server.js", "lint": "eslint ." },
"dependencies": { "express": "4.21.2" },
"devDependencies": { "eslint": "9.17.0", "jest": "29.7.0" } }
Generate the lockfile once with npm install --package-lock-only, then plant what a careless COPY . . picks up:
mkdir -p logs .git
head -c 58000000 /dev/urandom > logs/big.log # 58 MB of junk "logs"
head -c 5000000 /dev/urandom > .git/pack.bin # stand-in for git history
echo 'SECRET=abc' > .env
Baseline Dockerfile.v0:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
$ docker image ls demo:v0
IMAGE DISK USAGE CONTENT SIZE
demo:v0 1.87GB 494MB
$ docker run --rm demo:v0 sh -c 'cat /app/.env; ls /app/.git'
SECRET=abc
pack.bin
$ curl -s localhost:3010/
{"ok":true,"uid":0}
Three problems in one run: a 1.87 GB image for a 17-line app, a secret baked into a layer, and a process running as uid 0.
Add a .dockerignore to keep secrets and junk out of the image
node_modules
.git
logs
.env
Dockerfile*
Dockerfile.v1 is identical to v0; only the ignore file is new. To measure the build context alone, I built FROM scratch / COPY . /src in two fresh copies of the directory, one with the ignore file:
without .dockerignore: transferring context: 63.21MB 9.1s
with .dockerignore: transferring context: 193.39kB 0.0s
Use fresh directories and --no-cache: BuildKit syncs contexts incrementally, so a rebuild of the same directory reports only a few hundred bytes and fools you.
$ docker run --rm demo:v1 cat /app/.env
cat: /app/.env: No such file or directory
Content size fell from 494 MB to 431 MB (1.87 GB to 1.74 GB on disk), and the secret is gone from the image. Vulnerability count: unchanged at 20 CRITICAL / 465 HIGH.
Install only production dependencies
Dockerfile.v2 copies the manifests first, installs without dev tools, then copies the source. The install layer stays cached until the lockfile changes.
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
node_modules shrinks from 55 MB in v0 to 4.9 MB (both from du -sh /app/node_modules inside the container), but the image is still 1.65 GB on disk and 413 MB of content, because the node:22 base is 1.65 GB by itself:
$ docker image ls node
IMAGE DISK USAGE CONTENT SIZE
node:22 1.65GB 427MB
node:22-alpine 238MB 61.1MB
node:22-slim 329MB 82.5MB
The base is the weight. Everything you do to your own layers is a rounding error next to it.
Multi-stage build to shrink a Docker image (and what actually does the shrinking)
A multi-stage build builds in one stage and copies only the result into a clean runtime stage. s1 does that on the same node:22 base:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY server.js ./
RUN npm prune --omit=dev
FROM node:22
WORKDIR /app
COPY --from=build /app ./
EXPOSE 3000
CMD ["node", "server.js"]
Result: 1.64 GB on disk, 411 MB content. That is v2 within a rounding error. Multi-stage helps when the build stage carries compilers or source you don't want to ship; for a pure-JS app on a fat base it buys nothing. The next change is the one that matters: s2 is identical except both FROM lines say node:22-alpine.
demo:s2 244MB disk 61.5MB content
1.65 GB to 244 MB on disk (about 85% smaller), 413 MB to 61.5 MB compressed. Of the 244 MB, node_modules is 4.8 MB (du -sh); the rest is the Node runtime and alpine.
Decision rule: alpine (musl) if your dependencies are pure JS. With native modules built against glibc, use -slim (329 MB on disk above) instead of fighting musl.
Pin the base image by digest
s3 adds the digest to both FROM lines, as the Docker best-practices guide suggests:
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
...
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
Size: unchanged, 244 MB. The benefit is reproducibility, not weight. That digest is what node:22-alpine pointed to on 2026-10-07 (Node v22.23.3, Alpine 3.24.2):
$ docker image ls node:22-alpine --digests --format '{{.Digest}}'
sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
$ docker run --rm node:22-alpine sh -c 'node -v; cat /etc/alpine-release'
v22.23.3
3.24.2
To prove the pin is honoured, change one character of the digest and build. Docker doesn't fall back to the tag; it refuses:
$ sed 's/0a7108bf/0a7108be/' Dockerfile.v3 > Dockerfile.bad
$ docker build -f Dockerfile.bad -t demo:bad .
ERROR: failed to build: failed to solve: node:22-alpine@sha256:0a7108be6c7bf5de...: not found
The tag moves, the digest doesn't, so the scan results below describe exactly these bytes. If you rebuild from the tag later, expect different sizes and counts. The cost of pinning is that nothing patches it for you; let Dependabot or Renovate open PRs for the digest.
Run a Docker container as non-root
v3 is s3 plus USER node and a HEALTHCHECK (section on health checks below):
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY server.js ./
RUN npm prune --omit=dev
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
WORKDIR /app
COPY --from=build --chown=node:node /app ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["node", "server.js"]
The proof is the reported uid, id inside the container, and failed writes:
$ curl -s localhost:3013/
{"ok":true,"uid":1000}
$ docker exec u3 id
uid=1000(node) gid=1000(node) groups=1000(node)
$ docker run --rm demo:v3 sh -c 'touch /etc/x'
touch: /etc/x: Permission denied
Gotcha: COPY --chown changes ownership of the files it copies, not the /app directory that WORKDIR created. The ls -ld output shows it:
$ docker exec u3 ls -ld /app /app/server.js /app/node_modules
drwxr-xr-x 1 root root 4096 Oct 7 04:41 /app
drwxr-xr-x 81 node node 4096 Oct 7 04:41 /app/node_modules
-rwxr-xr-x 1 node node 661 Oct 7 04:36 /app/server.js
$ curl -s "localhost:3013/write?path=/app/x.txt"
EACCES /app/x.txt
For most services that's what you want. If yours writes files, give it an explicit writable volume rather than loosening ownership.
Docker read-only filesystem with a tmpfs for /tmp
--read-only mounts the container's root filesystem read-only; see the docker run reference for the flag.
$ docker run -d --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m -p 3014:3000 demo:v3
/app/x.txt -> EROFS /app/x.txt [500]
/etc/x.txt -> EROFS /etc/x.txt [500]
/tmp/x.txt -> wrote /tmp/x.txt [200]
$ docker run --rm --read-only demo:v3 touch /tmp/x
touch: /tmp/x: Read-only file system
EROFS means the kernel refused the write. Many runtimes expect a writable /tmp, so add the tmpfs before enabling this in a real deployment and check the logs for EROFS for a day.
Docker HEALTHCHECK: prove it detects a failure
The Dockerfile reference lists defaults of --interval=30s, --timeout=30s, --start-period=0s and --retries=3. By arithmetic (I did not measure it), a container that breaks right after a passing check needs 3 failed checks at 30 s spacing, so roughly 90 s plus up to 30 s of timeout on each hung check before it reports unhealthy. I used a 10 s interval and 3 s timeout.
$ for i in 1 2 3 4; do docker inspect h3 --format '{{.State.Health.Status}}'; sleep 4; done
starting
starting
healthy
healthy
To prove it detects failure, I overrode the command with a path that doesn't exist, using a 2 s interval and 2 retries (the image's 5 s start period still applies):
$ docker run -d --name h4 --health-cmd 'wget -qO- http://127.0.0.1:3000/nope' --health-interval 2s --health-retries 2 demo:v3
$ docker inspect h4 --format '{{.State.Health.Status}} failing={{.State.Health.FailingStreak}}' # ~4 s in
starting failing=0
$ docker inspect h4 --format '{{.State.Health.Status}} failing={{.State.Health.FailingStreak}}' # ~10 s in
unhealthy failing=3
$ docker inspect h4 --format '{{.State.Health.Status}} failing={{.State.Health.FailingStreak}}' # ~14 s in
unhealthy failing=5
$ docker inspect h4 --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{end}}'
1 wget: server returned error: HTTP/1.1 404 Not Found
1 wget: server returned error: HTTP/1.1 404 Not Found
Why failing=3 with --health-retries 2: retries is the threshold for flipping to unhealthy, not a cap. FailingStreak keeps counting while the check keeps failing, so the number depends on when you look.
Two caveats. Plain docker run only records the status; it doesn't restart an unhealthy container, so something else (Swarm, an orchestrator) has to act on it. And alpine has wget via busybox but no curl:
$ docker exec h3 curl -s localhost
OCI runtime exec failed: exec failed: unable to start container process: exec: "curl": executable file not found in $PATH
That's an easy mistake when copying a healthcheck from a Debian image.
Docker resource limits: memory, CPU and --pids-limit
$ docker run -d --memory 64m --memory-swap 64m --cpus 0.5 --pids-limit 50 demo:v3
$ docker inspect m3 --format 'mem={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}'
mem=67108864 nanocpus=500000000 pids=50
$ curl "localhost:3015/alloc?mb=20" # three times
held 20 MB
held 40 MB
(connection dropped)
$ docker inspect m3 --format 'status={{.State.Status}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'
status=exited oom=true exit=137
The third 20 MB allocation pushed the process (Node itself uses memory too) past 64 MB and the kernel killed it, as the resource constraints docs describe. --pids-limit is covered in the docker run reference; I only checked that it was applied (pids=50), I did not fork-bomb it. Without a memory limit, a leak takes the host's memory; with one, it takes down one container and leaves a loud oom=true.
To pick a number, run the app under realistic load, read docker stats, and set the limit with headroom over the peak. I didn't load-test this app, so I can't give you a ratio. Setting --memory-swap equal to --memory disables swap, so failures are fast instead of slow.
Scan a Docker image for vulnerabilities with Trivy
I used Trivy pinned to 0.75.0, against exported tarballs:
docker save demo:v3 -o tars/v3.tar
docker run --rm -v "$PWD/tars:/t:ro" aquasec/trivy:0.75.0 image --input /t/v3.tar \
--scanners vuln --severity HIGH,CRITICAL --format table
For counts, I used --format json and tallied Results[].Vulnerabilities[].Severity (a few lines of Python; jq works too). The big Debian images are slow: v1 and v2 each took about 6 minutes here (file timestamps, not a stopwatch), so pass --timeout 30m rather than trusting the default.
| Image | CRITICAL | HIGH | Notes |
|---|---|---|---|
v0 (node:22, Debian 12.15) |
20 | 465 | 4,561 findings in total, mostly OS packages |
v1 (+ .dockerignore) |
20 | 465 | same base, same findings |
| v2 (+ prod deps) | 20 | 464 | one fewer HIGH: a dev-only package is gone |
| v3 (alpine 3.24.2) | 0 | 12 | no OS-package findings |
| v4 (v3 minus npm) | 0 | 1 | only path-to-regexp 0.1.12, fixed in 0.1.13 |
Raw counts overstate risk, since many Debian findings have no fix or aren't reachable. But the direction is clear. Of v3's 12 HIGH findings, 11 sit under /usr/local/lib/node_modules/npm/... (brace-expansion, pacote, picomatch, sigstore, ip-address and others), which the app never runs. One is Express's own path-to-regexp.
So v4 deletes npm from the runtime stage. The RUN rm goes after the COPY --from=build line and before USER node, because it deletes root-owned files and fails as uid 1000. Diff against v3:
COPY --from=build --chown=node:node /app ./
+RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
USER node
HIGH went from 12 to 1, and the container still reported healthy. The gotcha: image size didn't change (244 MB, 61.5 MB content), because a delete in a later layer hides files without removing the earlier layer. The scan improves; the download doesn't. To shrink it you'd need a base without npm.
Trivy or Docker Scout? I ran only Trivy. docker scout quickview needs a Docker ID login and I didn't sign in, so I have no Scout numbers to compare. The decision rule I'd use: start with Trivy, which is free and found everything that mattered above; look at Docker Scout when you want policy dashboards or Hub-integrated alerts across many repositories, and check its current plan limits on Docker's pricing page before committing, since they change.
What to do next
- Add a
.dockerignorewith.git,.env,node_modulesand build output, and test it in a fresh directory with aFROM scratch/COPY . /srcbuild. - Look at
docker image ls <base>before anything else; if the base is 1 GB+, switching to alpine or-slimis the only change that will move the size. - Pin the base by digest, and add Dependabot or Renovate to update it.
- Add
USER, then rundocker exec <container> idand a failing write to prove it. - Try
--read-only --tmpfs /tmpin staging and grep logs forEROFS. - Add a HEALTHCHECK using a tool actually present in your base image, and break it once on purpose to see
unhealthy. - Set
--memoryfrom a measured peak and confirmOOMKilledshows up when you exceed it. - Run
trivy imagein CI pinned to a version, fail on CRITICAL, and read what's inside the HIGH findings before acting.
Keep reading
Production-Ready Authentication: A Deep Dive into JWT, OAuth2, and Session Security
21 min · 1,248 views
Artificial IntelligenceAdvanced Security Measures for Protecting Against Sophisticated Threats
18 min · 1,204 views
Mobile DevelopmentComplete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 948 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In