What Is a Dockerfile and Why Does It Matter?
One of the most common problems when moving an application to a server is that an application which runs fine in the development environment behaves differently in production: the Node.js version differs, system libraries are missing, dependencies were installed in a different order. Docker solves this by packaging the application together with its operating system, runtime and dependencies into a single image. The file that defines how that image is built is the Dockerfile: a simple list of instructions processed line by line, where each line creates its own layer.
A well-written Dockerfile does more than produce a working image; it keeps the build fast, strips the final image of unnecessary files and leaves a reasonable surface from a security standpoint in production. In this guide we cover the basic instructions, the layer caching logic, the multi-stage build technique and example Dockerfiles for three different project types.
Basic Dockerfile Instructions
Complicated as it may look, a Dockerfile is made up of different combinations of a handful of basic instructions. The table below summarises the most frequently used ones:
| Instruction | What it does |
|---|---|
| <code>FROM</code> | Sets the base of the image (e.g. <code>node:20-alpine</code>, <code>python:3.12-slim</code>). Every Dockerfile starts with at least one <code>FROM</code>. |
| <code>WORKDIR</code> | Sets the working directory inside the container; the following <code>COPY</code>, <code>RUN</code> and <code>CMD</code> instructions operate relative to this directory. |
| <code>COPY</code> | Copies files from the build context (usually the project folder) into the image. |
| <code>RUN</code> | Runs a shell command during the build, for example installing dependencies. Every <code>RUN</code> creates a new layer. |
| <code>EXPOSE</code> | Documents which port the container listens on; it does not do the port forwarding itself, that is mapped separately with <code>docker run -p</code>. |
| <code>CMD</code> / <code>ENTRYPOINT</code> | Defines the default command to run when the container starts. <code>CMD</code> is usually used on its own, while <code>ENTRYPOINT</code> is used for a fixed command with variable arguments. |
Layer Caching: The Detail That Decides Build Speed
Docker builds an image line by line; every RUN and COPY instruction creates a separate layer, and Docker reuses from cache the layers that have not changed since the previous build. That mechanism makes the order of lines in the Dockerfile critical for build speed. In a Node.js project, copying package.json and package-lock.json first and running npm ci, then copying the rest of the source code, is recommended for exactly this reason: the dependency installation layer only re-runs when package.json actually changes. When you make a small change in the code and rebuild, the dependency installation step comes from cache and the build finishes within seconds; the same logic applies to requirements.txt in Python.
The reverse is also true: if the COPY . . instruction is written before the dependency installation, every tiny change in the source code invalidates the cache and all the dependencies are reinstalled from scratch. In a small project that may look like a loss of a few seconds, but in a CI/CD pipeline that builds often it turns into unnecessary waiting that can last minutes.
Smaller Images with Multi-Stage Builds
The tools needed to build an application (compilers, build tools, development dependencies) are mostly not needed at all at runtime. A multi-stage build lets you separate the two by using more than one FROM instruction in a single Dockerfile. The first stage (for example FROM node:20-alpine AS build) installs all the dependencies and compiles the project. The second stage starts fresh from a minimal image (e.g. nginx:alpine) and, with the COPY --from=build instruction, copies only the output the first stage produced (the compiled files, the dist/ folder and so on). As a result, the final image contains no build tools, no source code and no development dependencies such as node_modules; only the minimum files needed at runtime. This both shrinks the image size considerably and reduces the attack surface of the production image.
The choice of base image also affects the size directly. Images tagged alpine (based on Alpine Linux) and those tagged slim (based on a minimal Debian) are much smaller than the full versions and are pulled and pushed faster. Because Alpine uses musl libc instead of glibc, it can occasionally be incompatible with some native dependencies; in such a case a -slim or full image is a reasonable alternative.
Examples for Node.js, Python and a Static Site
The three examples below show how the principles above are applied in different project types.
Node.js
Python
Static Site (Multi-Stage Build with Nginx)
The shared pattern is the same in all three examples: the dependency files are copied and installed first, then the source code is added. In the static site example the build stage and the runtime stage are also completely separated; the final image contains only Nginx and the compiled files, not the Node.js runtime.
Common Mistakes
- Writing the
COPY . .instruction before the dependency installation: this makes the layer cache useless and causes all dependencies to be reinstalled on every code change. - Not using a
.dockerignorefile: files and folders such asnode_modules,.gitand.envend up in the build context; this both slows the build down and creates the risk of sensitive files being copied into the image by accident. - Using a needlessly large base image: if
node:20-alpinecan be used instead ofnode:20, orpython:3.12-sliminstead ofpython:3.12, the image size and attack surface shrink considerably. - Running the container as the root user: many official images run as root by default; switching to an unprivileged user in production (e.g.
USER node) limits the impact of any security hole that may arise inside the container. - Leaving build tools in the final image by not using a multi-stage build: compilers, development dependencies and source code both take up space in the production image and create an unnecessary attack surface.
Rather than applying these principles by hand from scratch in every project, it is more practical to start from a base that generates the right ordering and a suitable base image automatically.