CoursesDocker in depthDocker best practices

Docker best practices

The habits that make images small, fast, and safe.

Intermediate12 min · lesson 29 of 30

A shipping container is boring on purpose. Packed tight, sealed shut, stencilled with a number, and whatever went in at one port is exactly what comes out at the other. A production image, the packaged ready-to-run copy of your app that Docker starts containers from, earns its keep the same way. Nothing on the list below is clever. The skill is running the whole list on every build instead of the two or three items that feel important that morning.

The list splits into two piles. One pile makes images small and builds quick, which buys back time and money. The other makes a container hard to break out of, which buys back the 2 a.m. page you would otherwise take. You want both piles done on every image, not one pile on your favourites. Every command here you have already met earlier in the course. What follows gathers them into something you can run down before you ship.

The pre-ship checklist
smaller & faster
minimal, pinned base
alpine/distroless @digest
multi-stage build
no toolchain in the final image
cache-friendly order
lockfile + deps before source
.dockerignore
keep the context tiny
safer
non-root USER
no root inside the container
read-only rootfs + tmpfs
immutable at runtime
secrets as files, not ENV
nothing in layers or inspect
healthcheck + limits
real readiness, capped resources
The left pile buys back time and money. The right pile keeps you off the incident bridge. Ship nothing until both columns are done.

Pick the smallest base, then nail it down

Start from the smallest base image that still runs your app. For most stacks that means an -alpine or -slim tag, or a distroless image, a base stripped of the shell and the package manager so there is nothing inside to run but your own program. Then pin it. A tag like node:22-alpine is a nickname, and nicknames get handed to new bytes every time somebody rebuilds. The digest works differently. That long sha256: hash Docker prints when you pull is a fingerprint of the exact contents, and it never moves. Copy the fingerprint into your FROM line. A rebuild a year from now then starts from the base you tested today, not from whatever inherited the nickname in the meantime.

terminal
$ docker pull node:22.11.0-alpine3.20
terminal
22.11.0-alpine3.20: Pulling from library/node
Digest: sha256:5e3f7a9c2b1d8e4f6a0c9b3d5e7f1a2c4b6d8e0f2a4c6b8d0e2f4a6c8b0d2e4f
Status: Image is up to date for node:22.11.0-alpine3.20
docker.io/library/node:22.11.0-alpine3.20

Build clean: two stages, and the right order

Two things matter inside the Dockerfile. First, build in two stages. You install and prepare everything in an early stage, then copy only the finished node_modules folder into the image you actually ship. Whatever the app does not need at runtime stays behind: npm's download cache here, and in a compiled language like Go, the entire compiler. Second, order the steps from least likely to change to most likely. Copy the lockfile and install dependencies before you copy your source. Edit one line of code after that and Docker still reuses the cached dependency layer rather than rerunning a slow install. Finish with the exec form of CMD, the JSON-array form. Your process then runs as PID 1 (process ID 1, the container's init slot) and receives the stop signal directly. Use the shell form and a /bin/sh wrapper takes PID 1, swallows the signal, and your container takes ten seconds to die when it should take one.

Dockerfile
# ---- build stage: install deps away from the final image ----
FROM node:22.11.0-alpine3.20@sha256:5e3f... AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# ---- final stage: only runtime artifacts ship ----
FROM node:22.11.0-alpine3.20@sha256:5e3f...
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]
terminal
$ docker build -t payments-api:1.4.2 .
[+] Building 21.4s (14/14) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/node:22.11.0-alpine3.20 0.8s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 118B 0.0s
=> [build 1/4] FROM docker.io/library/node:22.11.0-alpine3.20@sha256:5e3f... 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 2.31kB 0.0s
=> CACHED [build 2/4] WORKDIR /app 0.0s
=> [build 3/4] COPY package*.json ./ 0.0s
=> [build 4/4] RUN npm ci --omit=dev 17.9s
=> [stage-1 3/4] COPY --from=build /app/node_modules ./node_modules 0.5s
=> [stage-1 4/4] COPY . . 0.1s
=> exporting to image 1.0s
=> => writing image sha256:8b1c9d2e4f7a... 0.0s
=> => naming to docker.io/library/payments-api:1.4.2 0.0s
$ docker images payments-api
REPOSITORY TAG IMAGE ID CREATED SIZE
payments-api 1.4.2 8b1c9d2e4f7a 6 seconds ago 181MB

The same app on the full node:22 base comes to roughly 1.1 GB. This one is 181 MB. Every megabyte you cut is a megabyte you stop pulling, storing and scanning on every single deploy, forever. A smaller base also ships fewer packages, so a vulnerability scanner has less to flag in code your app never calls.

Keep the junk out of the build context

The build context is the pile of files Docker hands the builder before the first instruction runs. With no .dockerignore, that pile is your entire working directory: the .git history, a fat node_modules you are about to reinstall anyway, and any local .env file holding real credentials. All of it gets uploaded. Anything a COPY . . sweeps up gets baked into a layer, and layers are forever. A .dockerignore file fixes it, and it reads almost exactly like a .gitignore.

.dockerignore
.git
node_modules
npm-debug.log
Dockerfile
.dockerignore
.env
*.md
coverage
terminal
$ docker run --rm payments-api:1.4.2 ls -a /app
. .. node_modules package-lock.json package.json server.js

No .env, no .git. The credentials sitting in your working directory never crossed into the image, which is the whole point.

Now lock down the run

A spotless image can still hand over the host if you start it carelessly. Drop every Linux capability the app does not use with --cap-drop ALL. Capabilities are the individual root powers, like binding a low port or loading a kernel module, that Linux hands out piecemeal. Mount the root filesystem read-only and give back only the paths the app writes to as tmpfs, an in-memory filesystem that vanishes when the container stops. Hand secrets over as a mounted file, never with -e, because environment variables show up in docker inspect and tend to end up in logs. Cap memory, CPUs and process count so one runaway container cannot drag the whole node down with it. This image already runs as the node user thanks to its USER line, so there is no --user to add here, and the healthcheck you baked in tells the orchestrator when the app is genuinely ready rather than merely started.

terminal
$ docker run -d --name payments \
--cap-drop ALL \
--read-only --tmpfs /tmp \
--memory 256m --cpus 1.0 --pids-limit 200 \
-v "$PWD/db_password.txt:/run/secrets/db_password:ro" \
-p 127.0.0.1:3000:3000 \
payments-api:1.4.2
terminal
7c4e9a1b3d5f8e2a6c0b4d7e9f1a3c5b7d9e1f3a5c7b9d1e3f5a7c9b1d3e5f7a
terminal
$ docker ps --format '{{.Names}}\t{{.Status}}'
payments Up 45 seconds (healthy)
$ docker exec payments id
uid=1000(node) gid=1000(node) groups=1000(node)
$ docker exec payments sh -c 'echo hi > /app/pwned'
sh: can't create /app/pwned: Read-only file system
$ docker inspect -f '{{json .Config.Env}}' payments
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NODE_VERSION=22.11.0","YARN_VERSION=1.22.22"]

A healthy status, uid 1000 (user ID 1000, so not root), a filesystem that refuses writes, and no database password anywhere in the environment. Four claims, four commands. Checked, not assumed.

--read-only fails in production, not in the build

What survives contact with production

The habits that hold up under real schedule pressure are the dull ones: a minimal pinned base, a multi-stage build, a non-root USER, a read-only root filesystem wherever the app allows it, explicit memory and CPU limits, a healthcheck, secrets kept out of the environment, and digests in the deploy manifest. None of them are clever. All of them are easy to skip when a release is late, which is exactly why they belong in CI (continuous integration, the pipeline that builds and tests every change) rather than in somebody's head.

The trade-off arrives the day you need a shell to debug something. Resist the urge to leave a package manager in the production image for that. Build a separate debug image and run that one when you need to poke around. Set the scanner to fail the build rather than warn, and write down every exception with a date it expires. Whoever is on call should be able to name the digest running right now and the digest they would roll back to, without opening a chat search.

Team habits beat individual heroics. A Dockerfile linter in the pipeline, a CODEOWNERS entry (the file that forces named reviewers onto specific paths) covering every Dockerfile, and a four-line pre-ship checklist in the pull request template will catch more than any one careful engineer. Track image size and build time across releases so a regression shows up as a number instead of a complaint.

When you tighten a real service this way, move one flag at a time and write down what happened. Record the digest before and after, the host you ran the commands on, and the flag you would pull first if the container starts crash-looping. --read-only and --pids-limit are the two that bite hardest in production, so give each its own deploy instead of shipping the whole checklist in one change. A colleague reading the ticket next month should be able to rebuild the same image and watch the same 'Up 45 seconds (healthy)' line appear.

Try this

Run these against a lab engine (Docker 24 or newer is fine) on an image you do not mind breaking. Read the sample output first so you know what a pass looks like before you wire the same commands into a pipeline.

terminal
$ docker build -t app:check .
$ docker image inspect app:check --format 'user={{.Config.User}} size={{.Size}}'
user=1000 size=48233411
$ docker run --rm --read-only --tmpfs /tmp app:check /healthz || true
$ trivy image --exit-code 1 --severity CRITICAL app:check
# STATUS: PASS — non-root; scanner exit 0 on CRITICAL

Takeaway

Two lines tell you whether an image is ready to ship: the FROM line carries a digest, and docker image inspect reports a non-root user. If either one fails, the rest of the checklist is decoration. Everything else here belongs in the pipeline, and every exception belongs in writing with a date on it.

Quick check
01You deploy production from the tag payments-api:1.4.2 and run it read-only. Weeks later somebody rebuilds that same tag on a base with a fresh vulnerability, and your next deploy quietly picks it up. What would have stopped that?
Incorrect — A tag is a movable label. Anyone with push access can repoint 1.4.2 at new bytes, and your next pull takes them without a word.
Correct — A digest is a hash of the contents, so the same digest always resolves to the same bytes. Pin it in the FROM line and in the deploy manifest, not only in a version tag.
Incorrect — read-only governs what the running container may write to disk. It says nothing about which image bytes you pulled.
Incorrect — Dropping capabilities hardens the container once it is running. It does not change which image got deployed.
02The lesson ends the Dockerfile with the exec form, CMD ["node", "server.js"], instead of the shell form. Why does that choice show up at shutdown?
Incorrect — The difference lands at runtime and shutdown. Build speed is unaffected.
Correct — The shell form parks /bin/sh at PID 1, the shell swallows the signal, your process never hears about it, and Docker eventually has to kill the container.
Incorrect — The shell form passes arguments fine. Signal handling at PID 1 is the real problem.
Incorrect — HEALTHCHECK runs whichever CMD form you picked.
03You add --read-only to a container whose app writes session files under /app/sessions. The build passes, then production crash-loops. What does the lesson tell you to do?
Incorrect — read-only and non-root are separate controls. You can and should run both.
Incorrect — The build stages have nothing to do with a failed write. The root filesystem is read-only, that is all.
Correct — read-only fails at runtime on writes, so you hand back exactly the paths the app needs as tmpfs or a volume and leave the rest sealed.
Incorrect — Distroless changes nothing about read-only behaviour, and it adds traps of its own, like having no shell for healthchecks or debugging.

Related