Writing your first Dockerfile
Every Dockerfile instruction explained line by line, building a real Node.js app image from scratch.
12 min readUpdated 2026-09-03dockerdockerfile
What a Dockerfile actually is
ADockerfile is a plain text file containing a sequence of instructions describing how to build an image, step by step. docker build reads it top to bottom and executes each instruction, and — as covered in the previous module — each instruction produces its own layer. This isn't just an implementation detail; it directly affects how you should order instructions, as you'll see shortly.
Let's build a real example: a small Node.js Express app.
text
my-app/
├── Dockerfile
├── package.json
├── package-lock.json
└── index.js
The Dockerfile, instruction by instruction
dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
FROM node:20-alpine — every Dockerfile starts with a base image to build on top of. You almost never build an image completely from scratch (though FROM scratch exists for rare, fully-static cases); instead you start from an existing image that already has what you need. Here, node:20-alpine is an official Node.js image built on Alpine Linux, a minimal distribution chosen specifically because it produces much smaller images than a full Ubuntu-based one — a recurring theme in Docker: smaller images build faster, pull faster, and have a smaller attack surface.
WORKDIR /app — sets the working directory inside the image for every instruction that follows. If /app doesn't exist yet, Docker creates it. Every subsequent COPY, RUN, and CMD operates relative to this path, and it's also the directory you land in if you docker exec -it into a running container. Without this, you'd be working out of /, which gets messy fast.
COPY package*.json ./ — copies just the dependency manifest files (package.json and package-lock.json) into the image first, before copying the rest of the application code. This ordering is deliberate — explained in the next section.
RUN npm install --production — executes a command during the build and bakes its result into the image layer. This is different from CMD, which only defines what runs when a container starts. RUN happens once, at build time; CMD happens every time a container launches from the image.
COPY . . — copies everything else (the actual application source) from your build context into the image's working directory.
EXPOSE 3000 — documents that the container listens on port 3000. This is purely informational/self-documenting — it does not actually publish the port. You still need -p 3000:3000 on docker run to make it reachable from outside. A common early mistake is assuming EXPOSE alone opens a port; it doesn't.
CMD ["node", "index.js"] — the default command run when a container starts from this image. Written in the "exec form" (a JSON array) rather than a plain string — exec form runs the command directly rather than through a shell, which handles signals (like Ctrl+C / SIGTERM) correctly, so the container actually shuts down cleanly instead of ignoring the signal.
Why COPY package*.json happens before COPY . .
This ordering exists entirely because of layer caching. Docker caches each layer, and reuses a cached layer instead of re-running it as long as its inputs haven't changed. If we copied everything (COPY . .) before running npm install, then any code change — even editing a single comment in index.js — would invalidate that layer, forcing npm install to redownload every dependency from scratch on every single build.
By copying only package.json/package-lock.json first, npm install only re-runs when dependencies actually change. Everyday code edits only invalidate the fast COPY . . layer, and the slow dependency install step gets skipped via cache almost every time. This one pattern is probably the single biggest practical speedup you can make to a Dockerfile, and it generalizes to any language: copy dependency manifests, install, then copy source.
Building the image
bash
docker build -t my-app:1.0 .
-t my-app:1.0tags the resulting image with a name and version.- The trailing
.is the build context — the directory Docker sends to the daemon forCOPY/ADDinstructions to pull files from. This is why you rundocker buildfrom your project root, and why a Dockerfile can'tCOPYfiles from outside that context directory.
Run it exactly like any pulled image:
bash
docker run -d -p 3000:3000 my-app:1.0
Keeping the build context small with .dockerignore
Docker sends the entire build context directory to the daemon before building even starts, so a stray node_modules/ or .git/ folder in your project can make every build slow, and can even accidentally leak files (like a .env with secrets) into an image layer. A .dockerignore file, syntactically similar to .gitignore, excludes paths from the build context entirely:
text
node_modules
.git
.env
*.log
npm-debug.log
Add this to every project with a Dockerfile — it's cheap insurance against slow builds and accidental secret leaks.
What's next
Right now, our image includes build tools and dev dependencies that the running app never actually needs. The next lesson covers multi-stage builds — the standard technique for keeping final production images lean.