Writing a Dockerfile
So far you have run images that other people built. Now it is time to build your own. A Dockerfile is a plain text file with a list of instructions that tells Docker how to assemble an image (a frozen, ready-to-run snapshot of your app and everything it needs). You write the recipe once, run docker build, and you get an image you can run anywhere — your laptop, a teammate’s machine, or a production server — with identical results.
What a Dockerfile is
A Dockerfile is just a text file named exactly Dockerfile (no file extension) that lives in the root of your project. Each line is an instruction written in UPPERCASE, followed by its arguments. Docker reads the instructions top to bottom and builds the image one step at a time.
The file is the source of truth for your image. Instead of clicking around or running commands by hand and hoping you remember them, you commit the Dockerfile to Git alongside your code. Anyone can rebuild the exact same image from it.
When to use this: any time you want to package your own application — a Node.js API, a Python script, a static site — into a container. When NOT to: if an official image already does the job (for example running a plain postgres or nginx container with no custom code), you do not need to write a Dockerfile at all; just run the official image.
The core instructions
Here are the instructions you will use in almost every Dockerfile.
| Instruction | What it does | Example |
|---|---|---|
FROM | Sets the base image you build on top of | FROM node:20-slim |
WORKDIR | Sets the working directory inside the image (and creates it) | WORKDIR /app |
COPY | Copies files from your machine into the image | COPY package.json ./ |
RUN | Runs a shell command at build time (installs, compiles) | RUN npm ci |
EXPOSE | Documents which port the app listens on | EXPOSE 3000 |
CMD | The default command to run when the container starts | CMD ["node", "server.js"] |
ENTRYPOINT | A fixed command that always runs (CMD becomes its arguments) | ENTRYPOINT ["node"] |
A key distinction: RUN happens once while the image is being built. CMD and ENTRYPOINT happen later, every time you start a container from the image.
CMD vs ENTRYPOINT — when to use which
Both define what runs when the container starts, but they behave differently when someone overrides them at docker run.
CMD | ENTRYPOINT | |
|---|---|---|
| Purpose | The default command, easily replaced | A fixed command that always runs |
| Overriding | Anything after docker run image ... replaces it entirely | Args after docker run image ... are passed to it |
| Best for | Most apps — “run the server by default” | Turning an image into a single-purpose tool/CLI |
For a normal web app, plain CMD is what you want. Use ENTRYPOINT when the image is really a wrapper around one program (like a custom ffmpeg or backup tool).
A real Dockerfile for a Node app
Imagine a small Node.js (a JavaScript runtime that runs outside the browser) Express API. Create a file called Dockerfile in your project root:
# Start from an official Node 20 image. "-slim" is a smaller Ubuntu/Debian base.
FROM node:20-slim
# Everything below happens inside /app in the image.
WORKDIR /app
# Copy only the dependency manifests first (explained below under caching).
COPY package.json package-lock.json ./
# Install exactly the locked dependencies. "ci" = clean, reproducible install.
RUN npm ci --omit=dev
# Now copy the rest of the application source code.
COPY . .
# Tell readers (and tools) the app listens on port 3000.
EXPOSE 3000
# The default command when the container starts.
CMD ["node", "server.js"]
Notice we copy package.json before the rest of the code. That ordering is deliberate, and it is the single most important performance trick in Docker.
Layers and build caching
Every instruction in a Dockerfile creates a layer — a saved, read-only snapshot of the filesystem changes that instruction made. Docker stacks these layers to form the final image, and it caches each one.
When you rebuild, Docker walks the layers top to bottom and reuses any cached layer whose instruction and inputs have not changed. The moment one layer changes, that layer and every layer after it must be rebuilt.
This is why we copy package.json and run npm ci before copying the rest of the code. Your dependencies change rarely, but your source code changes constantly. By installing dependencies in an earlier layer, Docker reuses that slow npm ci layer on every rebuild where only your code changed — turning a 60-second build into a 2-second one.
Order your Dockerfile from least-frequently-changed to most-frequently-changed. Putting
COPY . .near the top would invalidate the cache on every tiny code edit and force a full dependency reinstall every time.
Ignoring files with .dockerignore
When Docker builds, it sends your whole project folder (the build context) to the Docker engine. You do not want to ship node_modules, secrets, or the .git history into your image — they bloat the image and can break caching.
Create a file called .dockerignore next to your Dockerfile:
node_modules
npm-debug.log
.git
.gitignore
.env
Dockerfile
.dockerignore
*.md
This works just like a .gitignore. Anything listed is skipped, making builds faster and images smaller.
Building the image
From your project root on Ubuntu, build the image and tag it (give it a name) with -t:
sudo docker build -t my-node-app:1.0 .
The trailing . means “use the current directory as the build context.” The tag my-node-app:1.0 is name:version.
Output:
[+] Building 8.3s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [1/5] FROM docker.io/library/node:20-slim 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev 6.2s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.3s
=> => naming to docker.io/library/my-node-app:1.0 0.0s
Now run a container from your new image, mapping host port 3000 to the container’s port 3000:
sudo docker run -d -p 3000:3000 --name myapp my-node-app:1.0
Confirm it is running:
sudo docker ps
Output:
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
a1b2c3d4e5f6 my-node-app:1.0 "node server.js" Up 4 seconds 0.0.0.0:3000->3000/tcp myapp
Visit http://localhost:3000 and your app responds.
Going further: multi-stage builds
For compiled apps or front-end builds, you often want one stage to build and a tiny final stage to run — so build tools never ship in the final image. That technique is called a multi-stage build, and it is the standard way to make production images small and secure. It is worth a dedicated read once you are comfortable here; see the official Docker multi-stage build docs.
Best Practices
- Pin your base image to a specific version (
node:20-slim), never justnode:latest, so builds are reproducible. - Copy dependency files and install dependencies before copying source code to maximize layer cache reuse.
- Always add a
.dockerignoreto keepnode_modules,.git, and.envsecrets out of the image. - Prefer small base images (
-slim,-alpine) to reduce size and attack surface, but test that your app still runs on them. - Never bake secrets (passwords, API keys) into the image with
COPYorENV— pass them at runtime with-eor a secrets manager. - Use
CMDfor normal apps; reach forENTRYPOINTonly when the image is a single-purpose tool. - Run as a non-root user in production by adding a
USERinstruction once your basics work.