Docker best practices
The habits that make images small, fast, and safe.
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.
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.
$ docker pull node:22.11.0-alpine3.20
22.11.0-alpine3.20: Pulling from library/nodeDigest: sha256:5e3f7a9c2b1d8e4f6a0c9b3d5e7f1a2c4b6d8e0f2a4c6b8d0e2f4a6c8b0d2e4fStatus: Image is up to date for node:22.11.0-alpine3.20docker.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.
# ---- build stage: install deps away from the final image ----FROM node:22.11.0-alpine3.20@sha256:5e3f... AS buildWORKDIR /appCOPY package*.json ./RUN npm ci --omit=dev# ---- final stage: only runtime artifacts ship ----FROM node:22.11.0-alpine3.20@sha256:5e3f...WORKDIR /appCOPY --from=build /app/node_modules ./node_modulesCOPY . .USER nodeEXPOSE 3000HEALTHCHECK --interval=30s --timeout=3s --retries=3 \CMD wget -qO- http://localhost:3000/healthz || exit 1CMD ["node", "server.js"]
$ 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-apiREPOSITORY TAG IMAGE ID CREATED SIZEpayments-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.
.gitnode_modulesnpm-debug.logDockerfile.dockerignore.env*.mdcoverage
$ 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.
$ 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
7c4e9a1b3d5f8e2a6c0b4d7e9f1a3c5b7d9e1f3a5c7b9d1e3f5a7c9b1d3e5f7a
$ docker ps --format '{{.Names}}\t{{.Status}}'payments Up 45 seconds (healthy)$ docker exec payments iduid=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.
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.
$ 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.
CMD ["node", "server.js"], instead of the shell form. Why does that choice show up at shutdown?--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?