Learn how to write a Dockerfile to build custom images, understand layer caching, use .dockerignore, and tag and push your builds.
Writing a Dockerfile
A Dockerfile is a plain-text recipe that tells Docker how to assemble an image. Every official image on Docker Hub is built from a Dockerfile, and you will write your own any time you need to package an application. Once you understand the key instructions and the caching model, building images becomes fast and predictable.
The Basic Structure
A Dockerfile is a sequence of instructions, one per line. Docker executes them from top to bottom, and each instruction adds a new layer to the image.
Here is a minimal working example - a Node.js application:
Let us go through every instruction you will use.
Core Instructions
FROM
Every Dockerfile must start with FROM. It sets the base image - the starting point your image builds on top of.
Choosing the right base image matters:
node:20- Full Debian-based image (~1 GB). Everything installed.node:20-slim- Debian with only the essentials (~200 MB).node:20-alpine- Alpine Linux-based (~60 MB). Smaller, but usesmusllibc, which occasionally causes compatibility issues with native addons.
WORKDIR
Sets the working directory for subsequent RUN, COPY, ADD, CMD, and ENTRYPOINT instructions. If the directory does not exist, Docker creates it.
Prefer WORKDIR over RUN cd /app. It is explicit and applies to all later instructions.
COPY
Copies files from your build context (the directory you pass to docker build) into the image.
ADD
ADD is like COPY but with two extra capabilities: it can unpack local .tar archives and it can fetch URLs. In practice, COPY is preferred for clarity. Use ADD only when you need its extra features.
RUN
Executes a shell command during the image build and commits the result as a new layer.
The && chaining pattern is important for package managers: if you run apt-get update in one RUN and apt-get install in another, later rebuilds may use a stale cached update layer. Keeping both in a single RUN ensures the package index is always fresh when packages are installed. Always clean up cache at the end (rm -rf /var/lib/apt/lists/*) to keep the layer small.
EXPOSE
Documents which network ports the container listens on. It does not publish the port - that is done with -p at docker run time. It is metadata for tooling and humans.
ENV
Sets environment variables that are available both during the build and at runtime.
ARG
Defines build-time variables that you can pass with --build-arg. Unlike ENV, they are not available at runtime (they do not persist into the final image).
CMD
Provides the default command to run when a container starts. It can be overridden by anything you pass after the image name in docker run.
Always prefer the exec form (["executable", "arg1", "arg2"]) so that the process receives OS signals directly. With the shell form, signals are sent to the shell, not your application, which means docker stop may not shut down gracefully.
ENTRYPOINT
Sets the executable that always runs. Unlike CMD, it is not overridden by arguments passed to docker run - those arguments are passed to the entrypoint instead.
With this setup:
docker run my-image→ runsnode src/index.jsdocker run my-image src/other.js→ runsnode src/other.js
A common pattern is to use a shell script as the entrypoint for initialisation tasks:
USER
Switches the user for subsequent RUN, CMD, and ENTRYPOINT instructions. Always drop privileges before the final CMD - running as root inside a container is a security risk.
Many official images (like node) already create a non-root user for you:
Layer Caching
Docker caches each layer and reuses the cache on subsequent builds, as long as nothing above it in the Dockerfile has changed. Understanding the cache is the key to fast builds.
Ordering matters. Put instructions that change rarely at the top and instructions that change frequently near the bottom.
Slow (cache busted on every code change)
Fast (dependencies cached separately)
With this ordering, npm ci only re-runs when package.json or package-lock.json changes. A code-only change skips straight to the final COPY.
.dockerignore
The .dockerignore file tells Docker which files to exclude from the build context. A smaller build context means faster uploads to the daemon and avoids accidentally copying secrets or large directories.
Without .dockerignore, node_modules (often hundreds of megabytes) would be copied into the build context on every docker build, then immediately overwritten by npm ci - wasting time and bandwidth.
Building an Image
Tagging Images
A tag is a mutable pointer to a specific image digest. You can add multiple tags to the same image:
Complete Worked Example
Here is a production-ready Dockerfile for a Node.js API, incorporating all the practices above:
This uses multi-stage builds (covered in depth in Chapter 7), non-root users, separate dependency caching, and the exec form of CMD.
Common Dockerfile Mistakes
With a well-structured Dockerfile you can build images that are fast to iterate on, small in size, and safe to deploy. The next chapters cover how to persist data with volumes and connect containers together with networks.