Docker & Kubernetes
Writing a Dockerfile: Layers, Caching and Small Images
Instruction by instruction, layer caching, multi-stage builds, base image choice, .dockerignore, and a Node app image built the right way.
On this page
- Words used in this lesson
- The app we will containerize
- A Dockerfile that works but is bad
- The instructions you need
- Layers and the build cache
- .dockerignore
- Choosing a base image
- Multi-stage builds: the biggest win
- Build secrets done right
- Build commands you will use
- A short checklist for every Dockerfile
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a Dockerfile is a short text file of instructions that builds an image layer by layer, and the two things that decide whether your image is good are the order of the instructions (so Docker can reuse its cache) and what you leave out (so the final image is small and safe). A multi-stage build is the tool that makes both easy.
In the last lesson you saw that an image is a stack of read-only layers. Now you will write the recipe. We will do it the way people really learn it: first a Dockerfile that works but is bad, measured with real numbers, then the fixed version, and a comparison. The app is a small Node and TypeScript API called notes-api. We will keep using it for the rest of the series.
This is lesson 1 of 10. It builds on Why Containers. The next lesson, Volumes, Networks and Running Containers Safely, is about what happens when the container runs: where data goes and how to keep it safe. If you want the Node side of shutdown signals, the ideas here connect to the Node lessons in this blog, and the Security series explains why "smaller and non-root" is a security habit and not only a size habit.
What I ran / checked. Same machine as lesson 0: Docker Engine 29.8.2 with a real daemon, BuildKit 0.37.1, Node 22 images, and hadolint 2.15.1 (a Dockerfile linter, downloaded as a static program from its GitHub releases). All sizes, timings and outputs below were pasted from real runs. Times vary from run to run, so treat them as an order of magnitude. One sandbox detail: my build machine sits behind a proxy that re-signs HTTPS traffic, so npm install inside a build failed with a certificate error. To get around that I built with copies of the Node images that also contain the proxy's certificate (the only change is one extra file and two environment variables). I swapped the FROM line to that image only for my builds, so the Dockerfiles you read here are exactly what I ran, except for the base image name. The sizes differ from the official images by only a few hundred kilobytes (one 217 kB certificate layer). Sources I read (own words): the Docker docs page "Building best practices", the "Multi-stage builds" page, the Docker docs page on the build cache, the hadolint README and its rule table, and several items from the goldbergyoni/nodebestpractices repository (the Docker section: multi-stage builds, bootstrap with node and not npm start, graceful shutdown, .dockerignore, smaller base images). I did not verify any GitHub star counts. Not run: pushing to a registry (no credentials), and Windows containers.
Words used in this lesson
- Dockerfile is a text file of instructions that tells Docker how to build an image.
- Build context is the folder of files you send to the builder with
docker build .. - Instruction is one line of a Dockerfile, such as
FROMorRUN. The word is written in capitals by habit. - Build cache is Docker's memory of earlier build steps. If a step and its inputs have not changed, Docker reuses the old layer.
- Base image is the image you start
FROM. Everything you add goes on top. - Multi-stage build is a Dockerfile with more than one
FROM. Early stages build things, and the last stage copies only what it needs. .dockerignoreis a file that lists what must not be sent in the build context.- Exec form is the JSON-array way of writing a command, such as
["node", "app.js"]. - Shell form is the plain-text way, such as
node app.js, which runs through/bin/sh -c. - ENTRYPOINT is the fixed part of the command a container runs. CMD is the default, replaceable part.
- Pinning means naming an exact version, so a rebuild gives the same result.
- Linter is a tool that reads your file and warns about bad habits without running it.
The app we will containerize
A small API. It has two routes, /health and /notes, and listens on port 3000. It is written in TypeScript, so there is a build step (tsc turns src/server.ts into dist/server.js). That detail matters: it means we have dev tools that we need to build but not to run, which is what multi-stage builds are for.
// src/server.tsimport express from "express";type Note = { id: number; text: string };const app = express();const notes: Note[] = [{ id: 1, text: "hello from a container" }];app.get("/health", (_req, res) => { res.json({ ok: true });});app.get("/notes", (_req, res) => { res.json(notes);});const port = Number(process.env.PORT ?? 3000);const server = app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`),);process.on("SIGTERM", () => { console.log("got SIGTERM, closing"); server.close(() => process.exit(0));});{ "name": "notes-api", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc", "start": "node dist/server.js" }, "dependencies": { "express": "4.21.2" }, "devDependencies": { "@types/express": "4.17.21", "@types/node": "22.10.0", "typescript": "5.7.2" }}Two small things to notice. The server listens on 0.0.0.0, not localhost. Inside a container, localhost means only the container itself, so a server bound to 127.0.0.1 cannot be reached through a published port. And there is a SIGTERM handler. We will see why in a moment. I ran this app on my host first (npm install, npm run build, node dist/server.js) and it answered /notes correctly. Always check the app works outside a container before you blame Docker.
A Dockerfile that works but is bad
Here is the Dockerfile most people write on day one:
FROM node:latestCOPY . /appWORKDIR /appRUN npm installRUN npm run buildEXPOSE 3000CMD npm startIt builds. It runs. And every line has a problem. Before I explain them, let a tool find some. hadolint reads a Dockerfile and prints rule violations:
$ hadolint Dockerfile.badDockerfile.bad:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tagDockerfile.bad:5 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.Dockerfile.bad:7 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments(I removed the colour codes. The messages are real.) The linter found three problems. Here is the full list, including the ones a linter cannot see:
| Line | Problem | Why it hurts |
|---|---|---|
FROM node:latest | Unpinned tag | A rebuild next month can give a different Node. |
FROM node:latest | Huge base image | The default Node image includes compilers and many tools you do not need at runtime. |
COPY . /app before npm install | Wrong order | Any change in any file invalidates the cache, so dependencies reinstall every time. |
npm install | Not npm ci | install can change the lock file, so two builds can differ. |
(no .dockerignore) | Whole folder copied | Secrets such as .env and the heavy node_modules can enter the image. |
(no USER) | Runs as root | If someone breaks into the app, they are root inside the container. |
CMD npm start | Shell form + npm | The process tree is deeper, and signals may not reach Node. |
| Dev dependencies stay | Fat image | TypeScript and its types ship to production for nothing. |
Let me build it and measure.
docker build -f Dockerfile.bad -t notes-api:bad .docker images notes-api:baddocker run --rm notes-api:bad ida-notes:bad 1.69GBuid=0(root) gid=0(root) groups=0(root)(My local tag was a-notes:bad, a name I used to keep my lab separate.) 1.69 GB for a tiny API, and it runs as root. Let's fix it step by step. First we need to know what each instruction does.
The instructions you need
A Dockerfile is read from top to bottom. These are the instructions you will use in nearly every file.
FROM
FROM node:22-alpine sets the base image. Every Dockerfile starts here (comments and ARG can come before it). Choose a specific tag, never latest.
WORKDIR
WORKDIR /app sets the working directory for the lines after it, and creates it if needed. Use it instead of RUN cd /app && ... because cd in one RUN does not carry over to the next one. (hadolint has a rule for this, DL3003.) Use absolute paths.
COPY and ADD
COPY src dest copies files from the build context into the image. ADD does the same, plus it can fetch URLs and unpack local tar files, which can surprise you. The Docker best practices page says to prefer COPY for simple copying. hadolint (DL3020) flags ADD when COPY would do.
RUN
RUN npm ci runs a command while building and saves the changes as a new layer. This is where you install packages. Each RUN is a layer, so related commands are often joined with &&. We will see why in a moment.
ENV and ARG
Both set variables, but at different times. I proved the difference with a tiny Dockerfile that has ARG BUILD_ONLY=1 and ENV RUNTIME=2:
build step prints: BUILD_ONLY=1 RUNTIME=2in the running container (env | grep ...): RUNTIME=2ARGexists only during the build. It is gone from the running container.ENVis saved in the image and visible at run time.
ARG is not a safe place for secrets. You might think "ARG disappears, so a token is safe." It does not. I built an image with --build-arg NPM_TOKEN=s3cr3t-token, and then ran docker history --no-trunc. The token is right there in the history:
RUN |1 NPM_TOKEN=s3cr3t-token /bin/sh -c echo "using token" > /dev/null # buildkitARG NPM_TOKEN=s3cr3t-tokenAnyone who can pull the image can read it. The same is true for ENV and for files you COPY in and delete later. The right tool is a build secret (below).
EXPOSE
EXPOSE 3000 records that the app listens on port 3000. It is documentation. It does not open or publish anything. You still need -p when you run, or ports: in Compose. (Lesson 2 covers ports.)
USER
USER node makes later instructions, and the running container, use that user instead of root. The official Node images already contain a user called node (UID 1000). The Docker best practices page says to use USER for any service that does not need root.
CMD and ENTRYPOINT
These decide what runs when the container starts. They are easy to mix up:
CMDis the default command.docker run IMAGE other-commandreplaces it.ENTRYPOINTis the fixed program. Whatever you add after the image name is passed to it as arguments.
I tested a small file with ENTRYPOINT ["echo", "hello"] and CMD ["world"]:
$ docker run --rm imghello world$ docker run --rm img dockerhello docker$ docker run --rm --entrypoint echo img overrideoverrideSo the CMD was a default argument, replaceable on the command line, and the ENTRYPOINT needed --entrypoint to be changed. For a simple app, a single CMD ["node", "dist/server.js"] is enough. The Docker docs say CMD in the array form is recommended for service images.
Shell form versus exec form
There are two ways to write CMD:
CMD npm start # shell form: runs as /bin/sh -c "npm start"CMD ["node", "dist/server.js"] # exec form: runs node directlyWith the shell form, Docker starts /bin/sh and the shell starts your program. Then the shell is PID 1, not your app. A shell does not pass SIGTERM to its child. I looked inside the "bad" container's process list:
PID COMMAND 1 /bin/sh -c npm start 9 npm start 20 sh -c node dist/server.js 21 node dist/server.jsFour processes in a chain, and Node is number 21, hidden behind three wrappers. Now the good image:
PID COMMAND 1 node dist/server.jsNode is PID 1. This matters when you stop a container. I timed docker stop for both:
good (exec form, SIGTERM handler): real 0.167s logs: "got SIGTERM, closing"bad (shell form, npm start): real 10.180s logs: nothingThe bad container ignored the polite stop signal and was killed after Docker's 10 second wait. The good one closed its server and exited in a fraction of a second. In production this is the difference between a deploy that takes seconds and one where requests are cut mid-flight. (There is one more finding: my first version of the good image, with the exec form but no process.on("SIGTERM") handler, also took 10.2 seconds. That is the PID 1 rule from lesson 0: PID 1 does not get the default "die on SIGTERM" action, so Node needs its own handler.) The nodebestpractices list says the same: start Node directly with node, not npm start, so that signals reach your code, and handle SIGTERM to finish current requests.
On busybox/alpine, a shell-form CMD sleep 100 happened to run sleep directly as PID 1 (the shell replaced itself with it). I tried it. So the problem is not always visible, and it depends on the shell. With npm start it is always visible, because npm spawns more processes. Use the exec form and you never have to wonder.
Layers and the build cache
Docker builds an image by running each instruction in order. For each instruction it asks: "Have I done exactly this before, with exactly the same inputs?" If yes, it reuses the saved layer (CACHED). If no, it runs the step. And the key rule:
As soon as one step changes, every step after it must run again.
For COPY, "the inputs" means the contents of the files copied. For RUN, it is the command text and the layers before it. Docker does not know that npm install depends on the network. It only sees the same text, and may reuse an old result.
Why order matters: a real measurement
The bad file does COPY . /app first, then npm install. So any edit to any source file changes the copied layer, and npm install runs again. The good file copies only package.json and the lock file, runs npm ci, and then copies the source. Dependencies change rarely, source changes constantly.
I changed one line in src/server.ts and rebuilt both:
good Dockerfile, after editing a source file: COPY package.json package-lock.json ./ CACHED RUN npm ci CACHED COPY tsconfig.json ./ CACHED COPY src ./src (ran) RUN npm run build (ran)bad Dockerfile, after editing a source file: COPY . /app (ran) RUN npm install (ran again) RUN npm run build (ran)(I trimmed the build log to the lines that matter. The CACHED markers are what BuildKit printed.) Timing, with time docker build -q ...:
good rebuild: real 1.6 sbad rebuild: real 7.1 sSeven seconds against under two, on a warm network and a tiny app. With a real project that has hundreds of packages, the same shape is "two minutes against five seconds". Over a team and a CI system running hundreds of builds, this is real time and real money.
The rule you can remember: put the things that change least at the top, and the things that change most at the bottom. For Node: package*.json, install, then source.
A layer never gets smaller
This one surprises people. Each layer only adds to the one below it. Deleting a file in a later layer hides it, but the data stays in the earlier layer. I made a 50 MB file and deleted it, in two ways:
# two RUNsRUN dd if=/dev/zero of=/big.bin bs=1M count=50RUN rm /big.bin# one RUNRUN dd if=/dev/zero of=/big.bin bs=1M count=50 && rm /big.bintwo RUNs: 64.6MB (history: 52.4MB layer + a 4.1kB "rm" layer)one RUN: 12.1MB (same as the alpine base)With two RUNs the 50 MB file is still shipped inside the first layer, even though rm ran. With one RUN, the file was created and removed inside a single layer, so it never made it into the image. This is why you see long RUN a && b && c chains in Dockerfiles: do the clean-up in the same step that made the mess. (The same reason is why apt-get install and rm -rf /var/lib/apt/lists/* belong in one RUN.)
The linter's "multiple RUN" info message (DL3059) is the opposite advice. It says consecutive RUNs could be joined. Neither rule is always right. Join steps that must go together (create then clean), but do not squash everything into one line, or you lose cache reuse. The Docker docs also suggest sorting multi-line arguments to make diffs readable.
.dockerignore
When you run docker build ., the . is the build context: the folder whose files are sent to the builder. COPY . /app copies from it. Without limits, that includes node_modules (which you reinstall anyway), .git, logs, and your .env file with passwords.
To see the danger, I made a folder with a .env containing DB_PASSWORD=hunter2 and a Dockerfile that does COPY . /app:
$ docker run --rm img ls -a /app. .. .env Dockerfile app.txt$ docker run --rm img cat /app/.envDB_PASSWORD=hunter2The password is inside the image. If that image goes to a registry, everyone with pull access has it. Now add a .dockerignore:
.envDockerfile$ docker run --rm img2 ls -a /app. .. .dockerignore app.txtThe .env file is gone from the image. For our app, this is the .dockerignore:
node_modulesdist.git.env*.logDockerfile*It has the same pattern style as .gitignore. Excluding node_modules and dist matters for a second reason: you build them inside the image, on Linux, with the right native modules. Copying a Mac-built node_modules into a Linux image is a classic way to get strange errors. The nodebestpractices project lists .dockerignore as its own item for exactly the secrets reason.
I tried to measure "context size shrinks" with the progress output, but BuildKit transfers only the files a COPY actually needs, so the numbers were tiny both ways (hundreds of bytes). That is a feature of BuildKit, not a reason to skip the file. The leak above is the reason to have it.
Choosing a base image
The FROM line decides most of your image size. These are the three Node 22 variants on my machine (sizes as reported by docker images, which show uncompressed disk use):
| Image | Size on disk | Notes |
|---|---|---|
node:22 | 1.64 GB | Full Debian with compilers and many tools. Easy, huge. |
node:22-slim | 326 MB | Debian with only the basics. Uses glibc. |
node:22-alpine | 238 MB | Alpine Linux. Smallest of the three. Uses musl instead of glibc. |
(Mine were the CA-enabled copies, which are a few hundred kB bigger than official ones.)
How to choose:
- Alpine is small and fine for most pure JavaScript apps. The trade-off is
musl: some native modules and some precompiled binaries are built for glibc and fail or behave differently on Alpine. If an install fails there, tryslim. - Slim (Debian) is a good safe middle. Big ecosystem support, mid size.
- Full images are for builds that need compilers and system headers. Even then, use them in a build stage only.
- Smaller is not only about disk. Fewer packages means fewer things that can have security holes. The Docker docs and the Node best practices both give this as a reason.
Whichever you choose, pin it: node:22-alpine follows 22.x updates, which is usually what you want. node:22.12.0-alpine3.21 is exact. A digest (node@sha256:...) is exactly one image. Pick a level and update on purpose, not by accident. The Docker docs recommend rebuilding often so base image security fixes arrive, and docker build --pull makes sure you fetch a fresh base.
Multi-stage builds: the biggest win
Now the tool that fixes the "dev dependencies ship to production" problem. A multi-stage Dockerfile has more than one FROM. Each FROM starts a new stage, a fresh image. You can copy files from an earlier stage with COPY --from=stagename. Only the last stage becomes your image. Everything else is thrown away (though it is kept in the cache).
Here is the good Dockerfile in full. Read it top to bottom, it is a lot more readable than it looks:
# syntax=docker/dockerfile:1FROM node:22-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY tsconfig.json ./COPY src ./srcRUN npm run buildFROM node:22-alpine AS prod-depsWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=devFROM node:22-alpineENV NODE_ENV=productionWORKDIR /appCOPY --from=prod-deps /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distCOPY package.json ./USER nodeEXPOSE 3000CMD ["node", "dist/server.js"]What each stage does:
buildinstalls all dependencies (including TypeScript) and compiles. It exists only to makedist/.prod-depsinstalls only production dependencies, withnpm ci --omit=dev. It exists only to make a cleannode_modules.- The last stage starts from a fresh Node image and copies in just the two results (
node_modulesanddist), pluspackage.json. It switches to the non-rootnodeuser and uses the exec form forCMD.
The first line, # syntax=docker/dockerfile:1, tells BuildKit which Dockerfile syntax to use. It is a good habit.
npm ci (clean install) installs exactly what the lock file says and fails if package.json and the lock file disagree. That makes builds repeatable. Plain npm install may quietly update the lock. So npm ci is what you want in CI and in Docker.
Results
$ docker imagesnotes-api:good 243MBnotes-api:bad 1.69GBAnd what is inside:
good: /app = dist, node_modules, package.json 68 packages in node_modules no typescriptbad: 70 packages in node_modules typescript presentThe good image is about 7 times smaller (243 MB against 1.69 GB), has no compiler, runs as uid=1000(node), and has Node as PID 1:
$ docker exec good iduid=1000(node) gid=1000(node) groups=1000(node)Here is the layer list of the good image, from docker history. Look at the sizes:
SIZE CREATED BY0B CMD ["node" "dist/server.js"]0B EXPOSE [3000/tcp]0B USER node12.3kB COPY package.json ./16.4kB COPY /app/dist ./dist4.59MB COPY /app/node_modules ./node_modules8.19kB WORKDIR /app0B ENV NODE_ENV=production... (base image layers)162MB RUN ... addgroup -g 1000 node ... (Node itself)9.08MB ADD alpine-minirootfs-3.24.2-x86_64.tar.gz /Our own app adds about 4.6 MB. The rest is the Node base image. This also shows which instructions make layers with size, and which are free: CMD, EXPOSE, USER and ENV are metadata and add 0 bytes.
The image has 9 layers; the bad one had 13.
The sizes above come from docker images on my machine, which shows disk use after unpacking. What you download from a registry is smaller, because layers are compressed (Docker's own image page makes the same point: the listed size is uncompressed). Node's 22-alpine is about 61 MB compressed here, while 238 MB is on disk.
What hadolint says about the good file
$ hadolint Dockerfile.goodDockerfile.good:22 DL3066 info: Non-numeric user-id may not be resolvable by host systemOne finding, at the lowest "info" level. It points at USER node. The idea: when you give a name, tools outside the image that only see numbers (like some Kubernetes security settings) cannot be sure which user it is. The fix is USER 1000, or in Kubernetes setting runAsUser. We will see this again in lesson 4's YAML. The other four rules (pinned base, npm ci, JSON CMD, non-root) pass. hadolint exits with code 1 even for info messages, which is something to know before you put it in CI: you can choose a failure level with --failure-threshold warning.
A linter finds shape, not meaning. hadolint will not tell you that a missing .dockerignore leaks .env, or that your app ignores SIGTERM. It is a helpful second pair of eyes, not a test.
Build secrets done right
What if the build really needs a secret, such as a private npm token? Do not use ARG or ENV. BuildKit has a secret mount: the secret appears as a file only during one RUN and is never written to a layer. I tested it:
# syntax=docker/dockerfile:1FROM alpine:3.20RUN --mount=type=secret,id=npm_token sh -c 'echo token length: $(wc -c < /run/secrets/npm_token)'docker build --secret id=npm_token,src=tok.txt .#7 0.169 token length: 12The step could read the 12-byte token. Then I checked the finished image for traces. docker history showed only the command text, ls /run/secrets in the running image found nothing, and a search through docker save for the secret string found zero matches. That is the safe way. (Note that this protects the secret from the image. It does not protect it from a build script that prints it. Treat build scripts as trusted code.)
Build commands you will use
docker build -t notes-api:1.0 . # build, tag it, use ./Dockerfiledocker build -f Dockerfile.good -t notes-api:1.0 . # pick a different filedocker build --no-cache -t notes-api:1.0 . # ignore the cache (clean build)docker build --pull -t notes-api:1.0 . # check for a newer base image firstdocker build --progress=plain . # show full step output (helpful for debugging)docker run --rm -p 3000:3000 notes-api:1.0 # try itdocker history notes-api:1.0 # see the layers and their sizesDocker also has docker build --target build . to stop at one stage, which is handy for running tests in the build stage.
A short checklist for every Dockerfile
- Pin the base image (tag at least).
- Prefer small bases (
slim,alpine) and keep build tools in a build stage. - Copy dependency files first, install, then copy the source.
- Use
npm ci(or your tool's locked install). - Use a
.dockerignore(node_modules,.git,.env, logs). - Run as a non-root
USER. - Use exec form for
CMDand handleSIGTERMin the app. - Clean up in the same
RUNthat made the mess. - No secrets in
ARG,ENVorCOPY. Use--mount=type=secret. - Run
hadolintand read what it says.
Try it
- Write a one-line Dockerfile
FROM alpine:3.20and a second lineRUN echo hi > /a.txt. Build it twice. What does the second build print for theRUNstep, and why? - Your
DockerfilehasCOPY . .on line 3 andRUN npm cion line 4. You change a README line. What re-runs? How would you fix the order? - You write
RUN apt-get updateon one line andRUN apt-get install -y curlon the next. Why is this a trap, and what should you write? - Your container takes 10 seconds to stop. Give two things to check.
- A teammate says "I put the API key in an
ARGso it is not in the image". Is that right? How can you check? - What is the difference between
ENVandARG? - Your multi-stage build copies
node_modulesfrom the build stage, but the final image is still big. What did they probably forget?
Answers
- The second build prints
CACHEDfor theRUNstep, because the command and the layer below are the same, so Docker reuses the stored result. - Both lines re-run, because
COPY . .sees changed file contents and everything after it is invalid. Fix: copypackage.jsonandpackage-lock.jsonfirst, runnpm ci, then copy the rest. Also put the README in.dockerignoreif the image does not need it. - The
updatelayer can be cached and become old, so theinstallstep may use stale package lists and fail or install old packages. Put them in oneRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*. (hadolint also flags missing pins and cleanup.) - Check that
CMDis in exec form so the app is PID 1, and check that the app handlesSIGTERMand exits. Also look for a wrapper likenpm startor a shell script that does not pass signals. - No.
ARGvalues are saved in the image history. Check withdocker history --no-trunc IMAGE. Use a BuildKit secret mount instead. ARGexists only while building.ENVis saved in the image and is present when the container runs.- They copied the dev dependencies too (they should install with
--omit=devin a separate stage), or they used a big base image such as the fullnode:22for the last stage, or they copied the whole build folder instead of only the results.
Interview questions
What is the difference between an image and a Dockerfile? (Must know)
A Dockerfile is a text recipe. An image is the built result: a stack of read-only layers plus config. docker build reads the Dockerfile and produces the image. Many people version the Dockerfile in Git, and tag the image for a registry.
How does Docker's build cache work, and how do you use it well? (Must know)
For each instruction Docker checks whether the same instruction with the same inputs ran before. For COPY the inputs are the file contents. If so it reuses the layer. As soon as one layer changes, all later layers rebuild. So I put the least changing steps first. For a Node app I copy package.json and the lock file, run npm ci, and only then copy the source. In my test, an edit to one source file rebuilt in about 1.6 seconds with that order, compared with about 7 seconds when COPY . came first.
What is a multi-stage build and why use it? (Must know)
It is a Dockerfile with several FROM lines. Early stages build things with all the tools they need, and the last stage copies only the results. Only the last stage ships. For a TypeScript Node app I compile in one stage, install production dependencies in another, and copy dist and node_modules into a clean final stage. In my test this gave a 243 MB image instead of 1.69 GB, with no compiler in it, and it runs as a non-root user.
What is the difference between CMD and ENTRYPOINT? (Must know)
ENTRYPOINT is the fixed program, and CMD is the default arguments or the default command, which can be replaced when you run the container. If both exist, CMD is passed to ENTRYPOINT as arguments. In a test with ENTRYPOINT ["echo","hello"] and CMD ["world"] I got hello world, and docker run img docker gave hello docker. For a simple service I use just CMD ["node","dist/server.js"].
Shell form or exec form for CMD? Why? (Trap)
Exec form, the JSON array. Shell form runs the command through /bin/sh -c, so the shell is PID 1 and may not pass SIGTERM to the app. With npm start it was worse: four nested processes, and docker stop took 10.2 seconds because the app never got the signal. With ["node","dist/server.js"] Node was PID 1 and stopped in 0.17 seconds, once the app handled SIGTERM.
Why can't you shrink an image by deleting a file in a later layer? (Senior signal)
Layers are additive. A delete in a later layer only adds a "whiteout" marker, and the data stays in the earlier layer, so it still downloads and is still readable by anyone who pulls the image. I measured it: a 50 MB file created and removed in two RUN steps gave a 64.6 MB image, while the same in one RUN gave 12.1 MB. So I clean up in the same step, or use a multi-stage build and copy only what I need.
How do you keep secrets out of an image? (Must know)
Never put them in ARG, ENV or a copied file, because all of those end up in the layers or the history. I proved with docker history --no-trunc that a --build-arg token is visible. I use a .dockerignore for local files like .env, a BuildKit --mount=type=secret for secrets needed during the build, and runtime secrets from the platform (Docker secrets, or Kubernetes Secrets) and not from the image.
What does .dockerignore do, and why does it matter? (Must know)
It lists files that are left out of the build context. It keeps secrets and junk out of COPY . ., avoids copying a host node_modules over a Linux build, and keeps the context small. In a test, COPY . /app put a .env file with a password into the image, and a one-line .dockerignore removed it.
How do you choose a base image for a Node app? (Senior signal)
I weigh size, compatibility and security. Alpine is the smallest (238 MB on disk for Node 22 against 1.64 GB for the full image) but uses musl, which can break native modules. Slim Debian (326 MB) is a safe middle. I pin at least the major version, rebuild regularly to get security fixes, and use a full image only in a build stage. Fewer packages also means a smaller attack surface.
EXPOSE is in my Dockerfile, but I can't reach the app from my laptop. Why? (Trap)
EXPOSE is documentation only. It does not publish a port. I need docker run -p 3000:3000 or ports: in Compose. Another common cause is the app listening on 127.0.0.1 inside the container. It should listen on 0.0.0.0, because localhost inside the container is not reachable from outside it.
How would you check a Dockerfile before pushing? (Senior signal)
I run a linter such as hadolint, which flags unpinned tags, latest, shell-form CMD, ADD misuse and root users, and I read the output. In my test it found three issues in the bad file and one info message in the good file. I know its limits: it cannot see a missing .dockerignore or a missing signal handler. So I also build with --progress=plain, check docker history for size surprises, and run a vulnerability scanner in CI.
Why npm ci and not npm install in a Dockerfile? (Senior signal)
npm ci installs exactly the versions in the lock file and fails if package.json and the lock file disagree. It never edits the lock. So two builds of the same commit produce the same dependencies. npm install can update the lock or pick newer versions, which makes builds drift. With --omit=dev it also leaves out dev dependencies in the production stage.
Key takeaways
- A Dockerfile is a list of instructions.
RUN,COPYandADDmake layers with data.CMD,EXPOSE,ENVandUSERonly add metadata. - Order for the cache: things that change least go first. Copy
package*.json, runnpm ci, then copy source. I measured 1.6 s against 7.1 s. - Layers only add. Delete files in the same
RUNthat created them (64.6 MB against 12.1 MB in my test). - Use multi-stage builds, small pinned bases,
.dockerignore, a non-rootUSERand exec-formCMD. My result: 243 MB against 1.69 GB. - Make Node PID 1 and handle
SIGTERM: a stop took 0.17 s instead of 10.2 s. ARGandENVare visible in the image history. UseRUN --mount=type=secretfor build secrets.EXPOSEdocuments a port, it does not publish it.- Run hadolint, and remember what a linter cannot see.
What's next
Next: Volumes, Networks and Running Containers Safely, Lesson 2. You now have a small, safe image. In the next lesson you will run it. You will see how a database keeps its data after the container is deleted (a volume), why container names work as host names on some networks but not others, how a published port can accidentally be open to your whole network, and which flags (--cap-drop, --read-only, --user) shrink the damage if the app is broken into. Practise now: write a Dockerfile for one of your own apps, run hadolint on it, and compare the image size with and without a multi-stage build.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Cost, Latency and Caching for LLM Features · AI Engineering
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- “The UI Is Fast but the API Takes Too Long”: How to Solve It · Scenario Debugging
Part of the study guide
Related series