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.

Lesson 0115 min read#docker#dockerfile#images#caching

On this page

  1. Words used in this lesson
  2. The app we will containerize
  3. A Dockerfile that works but is bad
  4. The instructions you need
  5. Layers and the build cache
  6. .dockerignore
  7. Choosing a base image
  8. Multi-stage builds: the biggest win
  9. Build secrets done right
  10. Build commands you will use
  11. A short checklist for every Dockerfile
  12. Try it
  13. Interview questions
  14. Key takeaways
  15. 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 FROM or RUN. 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.
  • .dockerignore is 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.

JavaScript
// 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));});
JavaScript
{  "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:

Shell
FROM node:latestCOPY . /appWORKDIR /appRUN npm installRUN npm run buildEXPOSE 3000CMD npm start

It 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:

TEXT
$ 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:

LineProblemWhy it hurts
FROM node:latestUnpinned tagA rebuild next month can give a different Node.
FROM node:latestHuge base imageThe default Node image includes compilers and many tools you do not need at runtime.
COPY . /app before npm installWrong orderAny change in any file invalidates the cache, so dependencies reinstall every time.
npm installNot npm ciinstall can change the lock file, so two builds can differ.
(no .dockerignore)Whole folder copiedSecrets such as .env and the heavy node_modules can enter the image.
(no USER)Runs as rootIf someone breaks into the app, they are root inside the container.
CMD npm startShell form + npmThe process tree is deeper, and signals may not reach Node.
Dev dependencies stayFat imageTypeScript and its types ship to production for nothing.

Let me build it and measure.

Shell
docker build -f Dockerfile.bad -t notes-api:bad .docker images notes-api:baddocker run --rm notes-api:bad id
TEXT
a-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:

TEXT
build step prints:   BUILD_ONLY=1                     RUNTIME=2in the running container (env | grep ...):                     RUNTIME=2
  • ARG exists only during the build. It is gone from the running container.
  • ENV is 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:

TEXT
RUN |1 NPM_TOKEN=s3cr3t-token /bin/sh -c echo "using token" > /dev/null # buildkitARG NPM_TOKEN=s3cr3t-token

Anyone 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:

  • CMD is the default command. docker run IMAGE other-command replaces it.
  • ENTRYPOINT is 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"]:

TEXT
$ docker run --rm imghello world$ docker run --rm img dockerhello docker$ docker run --rm --entrypoint echo img overrideoverride

So 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:

Shell
CMD npm start                     # shell form: runs as  /bin/sh -c "npm start"CMD ["node", "dist/server.js"]    # exec form: runs node directly

With 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:

TEXT
    PID COMMAND      1 /bin/sh -c npm start      9 npm start     20 sh -c node dist/server.js     21 node dist/server.js

Four processes in a chain, and Node is number 21, hidden behind three wrappers. Now the good image:

TEXT
    PID COMMAND      1 node dist/server.js

Node is PID 1. This matters when you stop a container. I timed docker stop for both:

TEXT
good (exec form, SIGTERM handler):   real 0.167s    logs: "got SIGTERM, closing"bad  (shell form, npm start):        real 10.180s   logs: nothing

The 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:

TEXT
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 ...:

TEXT
good rebuild: real 1.6 sbad  rebuild: real 7.1 s

Seven 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:

Shell
# 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.bin
TEXT
two 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:

TEXT
$ docker run --rm img ls -a /app.   ..   .env   Dockerfile   app.txt$ docker run --rm img cat /app/.envDB_PASSWORD=hunter2

The password is inside the image. If that image goes to a registry, everyone with pull access has it. Now add a .dockerignore:

TEXT
.envDockerfile
TEXT
$ docker run --rm img2 ls -a /app.   ..   .dockerignore   app.txt

The .env file is gone from the image. For our app, this is the .dockerignore:

TEXT
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):

ImageSize on diskNotes
node:221.64 GBFull Debian with compilers and many tools. Easy, huge.
node:22-slim326 MBDebian with only the basics. Uses glibc.
node:22-alpine238 MBAlpine 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, try slim.
  • 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:

Shell
# 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:

  1. build installs all dependencies (including TypeScript) and compiles. It exists only to make dist/.
  2. prod-deps installs only production dependencies, with npm ci --omit=dev. It exists only to make a clean node_modules.
  3. The last stage starts from a fresh Node image and copies in just the two results (node_modules and dist), plus package.json. It switches to the non-root node user and uses the exec form for CMD.

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

TEXT
$ docker imagesnotes-api:good  243MBnotes-api:bad   1.69GB

And what is inside:

TEXT
good:  /app = dist, node_modules, package.json    68 packages in node_modules   no typescriptbad:   70 packages in node_modules                                     typescript present

The 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:

TEXT
$ 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:

TEXT
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

TEXT
$ hadolint Dockerfile.goodDockerfile.good:22 DL3066 info: Non-numeric user-id may not be resolvable by host system

One 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:

Shell
# syntax=docker/dockerfile:1FROM alpine:3.20RUN --mount=type=secret,id=npm_token sh -c 'echo token length: $(wc -c < /run/secrets/npm_token)'
Shell
docker build --secret id=npm_token,src=tok.txt .
TEXT
#7 0.169 token length: 12

The 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

Shell
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 sizes

Docker 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

  1. Pin the base image (tag at least).
  2. Prefer small bases (slim, alpine) and keep build tools in a build stage.
  3. Copy dependency files first, install, then copy the source.
  4. Use npm ci (or your tool's locked install).
  5. Use a .dockerignore (node_modules, .git, .env, logs).
  6. Run as a non-root USER.
  7. Use exec form for CMD and handle SIGTERM in the app.
  8. Clean up in the same RUN that made the mess.
  9. No secrets in ARG, ENV or COPY. Use --mount=type=secret.
  10. Run hadolint and read what it says.

Try it

  1. Write a one-line Dockerfile FROM alpine:3.20 and a second line RUN echo hi > /a.txt. Build it twice. What does the second build print for the RUN step, and why?
  2. Your Dockerfile has COPY . . on line 3 and RUN npm ci on line 4. You change a README line. What re-runs? How would you fix the order?
  3. You write RUN apt-get update on one line and RUN apt-get install -y curl on the next. Why is this a trap, and what should you write?
  4. Your container takes 10 seconds to stop. Give two things to check.
  5. A teammate says "I put the API key in an ARG so it is not in the image". Is that right? How can you check?
  6. What is the difference between ENV and ARG?
  7. Your multi-stage build copies node_modules from the build stage, but the final image is still big. What did they probably forget?

Answers

  1. The second build prints CACHED for the RUN step, because the command and the layer below are the same, so Docker reuses the stored result.
  2. Both lines re-run, because COPY . . sees changed file contents and everything after it is invalid. Fix: copy package.json and package-lock.json first, run npm ci, then copy the rest. Also put the README in .dockerignore if the image does not need it.
  3. The update layer can be cached and become old, so the install step may use stale package lists and fail or install old packages. Put them in one RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*. (hadolint also flags missing pins and cleanup.)
  4. Check that CMD is in exec form so the app is PID 1, and check that the app handles SIGTERM and exits. Also look for a wrapper like npm start or a shell script that does not pass signals.
  5. No. ARG values are saved in the image history. Check with docker history --no-trunc IMAGE. Use a BuildKit secret mount instead.
  6. ARG exists only while building. ENV is saved in the image and is present when the container runs.
  7. They copied the dev dependencies too (they should install with --omit=dev in a separate stage), or they used a big base image such as the full node:22 for 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, COPY and ADD make layers with data. CMD, EXPOSE, ENV and USER only add metadata.
  • Order for the cache: things that change least go first. Copy package*.json, run npm ci, then copy source. I measured 1.6 s against 7.1 s.
  • Layers only add. Delete files in the same RUN that created them (64.6 MB against 12.1 MB in my test).
  • Use multi-stage builds, small pinned bases, .dockerignore, a non-root USER and exec-form CMD. 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.
  • ARG and ENV are visible in the image history. Use RUN --mount=type=secret for build secrets.
  • EXPOSE documents 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

Part of the study guide

Related series