What the file actually is
The build cache is the whole point, and most people learn that the hard way.
You write a Dockerfile, run `docker build`, and wait ninety seconds while npm pulls half the internet. Then you change one line in a source file. You build again. Ninety more seconds. Nothing about your dependencies changed, but Docker has no idea, so it does the work all over again.
That is the moment the Dockerfile stops being a file you copied from a tutorial and becomes something you actually have to think about.
What the file actually is
A Dockerfile is a plain text file with a list of instructions. Docker reads it top to bottom and produces an image. Each instruction that touches the filesystem becomes a layer, and layers are immutable. You never edit a layer. You stack a new one on top.That immutability is not a detail. It is the entire reason the cache works, and it is the entire reason the cache breaks.
When you change one instruction, Docker discards that layer and every layer after it. Everything before the change stays cached. So the ordering of your instructions is not cosmetic. It is the difference between a two-second rebuild and a two-minute one.
FROM, and the choice you make there
Every Dockerfile starts with `FROM`. That line picks your base image, and it is the single biggest decision in the file.`FROM alpine:3.19` gives you roughly 7 MB. It is musl libc instead of glibc, which means some precompiled binaries will not run and you will spend an afternoon finding that out. `FROM debian:12-slim` gives you glibc and a familiar package manager at around 75 MB. `FROM node:18` gives you a working Node runtime and about a gigabyte of things you did not ask for.
Smaller is not automatically better. A 7 MB base image that costs you three days of debugging is not a win. A 900 MB image that builds your app without incident might be exactly right for a build stage.
Which brings up the thing worth knowing early: multi-stage builds. You can use a fat image to compile and a thin image to run.
```dockerfile FROM node:18 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build
FROM node:18-alpine WORKDIR /app COPY --from=build /app/dist ./dist COPY package*.json ./ RUN npm ci --omit=dev CMD ["node", "dist/server.js"] ```
Two images' worth of work, one image shipped. The build tools never reach production. This is more instructions to maintain, and you now have two `FROM` lines to keep in sync when Node 20 becomes the thing. That is the cost.
RUN, COPY, and the order that saves you
`RUN` executes during the build. `COPY` moves files from your build context into the image. Those two instructions are where your cache lives or dies.The rule: copy what changes least, first.
```dockerfile COPY package.json package-lock.json ./ RUN npm ci COPY . . ```
Now `npm ci` only reruns when `package.json` or `package-lock.json` changes. Edit `server.js` and the install layer comes straight from cache. That is the ninety seconds you get back, every single build, for the rest of the project's life.
`RUN` also creates a layer per invocation. Chain related commands with `&&` and clean up in the same layer:
```dockerfile RUN apt-get update && \ apt-get install -y --no-install-recommends python3 && \ rm -rf /var/lib/apt/lists/* ```
Split that into three `RUN` lines and the `rm` does nothing useful. The apt lists are already baked into an earlier layer, still on disk, still in the image. You removed them from a layer that sits on top. The bytes are still there.
ADD versus COPY
Use `COPY`. `ADD` does everything `COPY` does, plus it auto-extracts local tarballs and, historically, could fetch URLs. That second behavior is why the guidance exists: a Dockerfile that silently downloads from a URL is a Dockerfile that builds differently depending on what a remote server returns that day.`ADD` is not broken. If you genuinely need a `.tar.gz` unpacked in place, it saves you a `RUN tar` line. Just know that you traded a transparent instruction for a convenient one.
ENV, ARG, and the difference that bites people
`ENV NODE_ENV=production` sets a variable that exists at build time and at runtime. Your application sees it.`ARG VERSION=1.2.3` sets a variable that exists only during the build. It is gone the moment the container starts. It is also visible in `docker history`, so it is not a place for secrets. Neither is `ENV`, for that matter. Build arguments and environment variables are for configuration, not credentials.
If you need a secret during the build, use BuildKit's secret mounts. If you need one at runtime, inject it when you run the container.
CMD and ENTRYPOINT
`CMD` is the default command. It runs when the container starts, and it is easy to override. `docker run myimage echo hello` throws away your `CMD` and runs `echo hello` instead. Handy when you need a shell inside the image.`ENTRYPOINT` is the fixed part. It does not get replaced by arguments on the command line; arguments get appended to it. Combine them:
```dockerfile ENTRYPOINT ["node"] CMD ["app.js"] ```
Default behavior is `node app.js`. Run `docker run myimage other.js` and you get `node other.js`. The entrypoint holds, the argument flexes.
Use the exec form with brackets, not the shell form. `CMD node app.js` runs through `/bin/sh -c`, which means your Node process is not PID 1 and does not receive `SIGTERM` directly. Your container takes ten seconds to stop instead of one, and you wonder why your orchestrator keeps killing things.
The build context is not free
`docker build .` sends the entire directory to the daemon before it reads a single instruction. A stray `node_modules` folder or a `.git` directory turns a fast build into a slow one for no reason.Create a `.dockerignore`:
``` node_modules .git .env *.log dist ```
Same idea as `.gitignore`, different file. Every project needs one, and almost no project has one at the start.
Building outside your laptop
`docker build` works fine locally. The trouble starts when the build has to happen somewhere else, on every commit, without you watching.Since Docker Hub's Automated Builds wound down, the standard answer is a CI workflow. GitHub Actions or Bitbucket Pipelines reads your Dockerfile, builds the image, and pushes it to a registry. That needs an access token, and it needs the token stored as a CI secret rather than in the repository.
The upside is a reproducible artifact tied to a commit hash. The downside is that your build now depends on a runner's network, a runner's cache, and a registry's availability. When it fails at 2 a.m., nobody is going to fix it but you.
What you give up by not using a Dockerfile
There are other ways to make an image. `docker commit` snapshots a running container. You can shell in, install things by hand, and commit the result.It works. It is also unreproducible. Nobody can tell you what is in that image, including you in six months, and there is no diff to review. `docker import` does something similar for a tarball of a filesystem. Both are migration tools, not build tools.
Podman Desktop will build from a Dockerfile through a graphical interface, which is genuinely useful if you would rather not live in a terminal. The Dockerfile underneath is the same file. The format outlived the tool that named it.
Where this leaves you
A Dockerfile is a build script with a cache attached, and the cache rewards you for thinking about order. Put the stable things first. Chain your cleanup into the layer that made the mess. Use multi-stage builds when your build tools should not ship. Write a `.dockerignore` before you need one.None of that is complicated. All of it is easy to skip, and skipping it is invisible until the day your CI pipeline takes eleven minutes and you start reading the build log line by line.
Whether you need any of this depends on how often you build. A side project you deploy once a month can survive a sloppy Dockerfile. A service that builds on every pull request cannot, and you will find out which one you have at the worst possible moment.