on this page
- What we’re packaging
- The Dockerfile, one instruction at a time
- FROM: the floor you build on
- WORKDIR: pick a room to work in
- COPY (round one): just the package files
- RUN: install the dependencies (correctly)
- COPY (round two): now the rest of the code
- EXPOSE: write down the port
- CMD: what runs when the container starts
- The complete Dockerfile
- .dockerignore, and why COPY . . needs adult supervision
- The node_modules trap (the one that actually breaks things)
- Common mistakes and how to fix them
- So what does this get you
You read Docker without the buzzwords, the images-versus-containers thing finally clicked, and now you’re staring at an empty file called Dockerfile with no extension and no idea what goes in it. The cursor blinks. You feel watched.
Here’s the good news: a Dockerfile for a normal Node app is short. Like, six instructions short. The bad news is that roughly half of those six have a way to bite you that nobody warns you about until your image is 1.2 gigabytes or your app crashes on a server with an error about a .node binary you’ve never heard of.
So let’s write a real one together, line by line, and I’ll point out where the floor gives way before you step on it.
What we’re packaging
A boring Express app. The kind everyone’s first backend looks like. We’ll assume a layout like this:
my-api/
package.json
package-lock.json
server.js
src/
node_modules/ (this one is about to cause us grief)
And server.js is just enough to prove it’s alive:
// server.js
const express = require("express");
const app = express();
app.get("/", (req, res) => res.send("Hello from inside a container"));
// Listen on 0.0.0.0, not localhost. More on this near the end.
app.listen(3000, "0.0.0.0", () => console.log("up on :3000"));
That’s the whole app. Now we teach Docker how to build it into an image.
The Dockerfile, one instruction at a time
A reminder from the last guide, because it explains everything that follows: a Dockerfile is a script that builds an image, and every instruction adds a layer on top of the one before it. Layers get cached. Hold onto that, it’s the reason the ordering below isn’t arbitrary.
FROM: the floor you build on
FROM node:20-alpine
FROM picks your base image, the starting filesystem you build on top of instead of assembling a Linux system by hand. node:20-alpine means “a minimal Linux that already has Node 20 installed.” You didn’t install Node, you didn’t install Linux, you inherited both.
The -alpine part is the bit worth understanding. node:20 image is Debian-based and lands around 1GB once Node is in there. node:20-alpine is closer to 130MB. Same Node, a fraction of the size, because Alpine throws out everything you don’t need to run a server.
There’s a tradeoff, and I’d rather you hear it from me than from a forum at 2am. Alpine uses a different C library (musl instead of glibc), so a handful of packages with heavy native code (sharp for image processing, some database drivers, anything compiling C++ under the hood) occasionally misbehave on it. If that happens to you, node:20-slim is the escape hatch: bigger than Alpine, much smaller than the full image, and glibc-based so it behaves like a normal Linux box. Start on alpine, fall back to slim only if something actually breaks.
WORKDIR: pick a room to work in
WORKDIR /app
WORKDIR sets the working directory inside the image, the folder every command after it runs in. We chose /app. From here on, a COPY lands in /app, and node server.js runs from /app.
You could skip it and write absolute paths everywhere, the way you could technically cook without ever putting anything on the counter. WORKDIR /app is the counter. It also creates the directory if it doesn’t exist, so there’s no reason not to.
COPY (round one): just the package files
This is the instruction people get wrong, so read this part twice.
COPY package*.json ./
COPY copies files from your project (on your machine) into the image. The obvious move is to copy everything right now with COPY . . and get on with your life. Don’t. We copy only package.json and package-lock.json first, on purpose, and the reason is layer caching.
Docker caches each layer and reuses it on the next build as long as that layer’s inputs haven’t changed. Installing dependencies is the slow part of any build. If we install deps right after copying the whole project, then every time you tweak one line in server.js, Docker sees “the copied files changed” and reinstalls every dependency from scratch. Tediously. Every time.
By copying just the package files first, then installing, the install layer only busts when your dependencies actually change. Edit your source all day and the dependency install stays cached. We’ll copy the rest of the code in a moment, after the install.
RUN: install the dependencies (correctly)
RUN npm ci
RUN executes a command at build time, while the image is being assembled, and bakes the result into a layer. Here it installs your dependencies.
Note npm ci, not npm install. npm ci (“clean install”) wipes any existing node_modules and installs the exact versions pinned in your package-lock.json, no negotiating, no quietly bumping a sub-dependency to a newer patch. That’s precisely what you want in a build: the image gets the same dependency tree every time, the one your lockfile describes. npm install is allowed to update the lockfile and resolve fresh versions, which is handy at your desk and a liability in a build you want to be reproducible. (npm ci does need a package-lock.json to exist. If you don’t have one, run npm install locally once to generate it, then commit it.)
COPY (round two): now the rest of the code
COPY . .
Dependencies are installed and cached, so now we copy everything else: server.js, your src/ folder, the lot. COPY . . means “copy everything from the build context into the current WORKDIR.”
And this is exactly where your image gets fat and weird, unless you’ve set up the next piece. Hold that thought for one more instruction.
EXPOSE: write down the port
EXPOSE 3000
EXPOSE documents which port the app listens on. Read that carefully: it documents. It does not open the port, publish it, or do anything mechanical on its own. It’s a note in the image’s metadata saying “this thing talks on 3000,” for the benefit of you, your teammates, and tools like Docker Compose that read it.
The actual opening of a port still happens when you run the container with -p 3000:3000 (covered in the buzzwords guide). So why bother with EXPOSE at all? Because three weeks from now, “which port does this listen on” should be answerable by glancing at the Dockerfile, not by reading the source.
CMD: what runs when the container starts
CMD ["node", "server.js"]
CMD sets the default command that runs when someone starts a container from this image. Everything above was build-time setup; CMD is run-time. When you do docker run, this is the thing that actually fires: node server.js, the app boots, you’re live.
The square-bracket form (["node", "server.js"]) is the one to use. It runs your command directly instead of wrapping it in a shell, which means signals like Ctrl+C and the stop signal Docker sends actually reach your Node process so it can shut down cleanly. The plain-string form (CMD node server.js) tucks a shell in between and can swallow those signals, leaving you with containers that take ten seconds to die. Use the brackets.
The complete Dockerfile
Here’s everything stitched together. This is copy-pasteable, it works, and the comments are the same explanations from above so future-you isn’t lost:
# Start from a small Linux image that already has Node 20.
FROM node:20-alpine
# Do all our work inside /app.
WORKDIR /app
# Copy ONLY the package files first, so the install below
# stays cached when only source code changes.
COPY package*.json ./
# Install the exact versions from package-lock.json.
RUN npm ci
# Now copy the rest of the source into the image.
COPY . .
# Document the port the app listens on (this does not open it).
EXPOSE 3000
# The default command when a container starts.
CMD ["node", "server.js"]
Build and run it:
# Build an image and tag it "my-api". The "." is the build context,
# meaning "use this folder as the source of files to copy".
docker build -t my-api .
# Run it, mapping your machine's port 3000 to the container's 3000.
docker run -p 3000:3000 my-api
Open http://localhost:3000 and you should see your greeting from inside the container.
.dockerignore, and why COPY . . needs adult supervision
Remember the warning hanging over COPY . .? Here it is.
COPY . . copies everything in your project folder, and “everything” includes a lot of junk you never meant to ship: your local node_modules, the .git history, .env files with your actual secrets in them, logs, .DS_Store, editor cruft. All of it, into the image, bloating its size and occasionally leaking things you’d rather not leak.
A .dockerignore file fixes this. It sits next to your Dockerfile and lists what COPY should skip, the same idea as .gitignore but for image builds. Here’s a sane one for a Node app:
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
dist
coverage
.DS_Store
With that in place, COPY . . copies your source and skips the landfill.
The node_modules trap (the one that actually breaks things)
Of everything on that ignore list, node_modules is the line that matters most, and its size is the least of it. Skipping it prevents a genuinely confusing class of breakage, so let me spell it out.
Some npm packages contain native binaries: compiled C or C++ code, not JavaScript. When you run npm install on your laptop, those packages compile themselves for your laptop, your operating system and your CPU architecture. A binary built for macOS on an Apple M-series chip (arm64) is a different file from one built for Linux on x86. They are not interchangeable.
Now picture copying your Mac’s node_modules straight into a Linux container. The JavaScript travels fine. The compiled binaries do not. They were built for macOS, and now they’re being asked to run on Linux, and they refuse, usually with a cryptic error about an invalid ELF header or a missing .node file. You did nothing wrong in your code. The dependencies are simply the wrong shape for the box they landed in.
The fix is the structure we already built, and now you can see why it’s shaped this way:
node_modulesgoes in.dockerignore, so your host’s copy never enters the image.RUN npm cirebuildsnode_modulesinside the container, where everything compiles for Linux, the platform it’ll actually run on.
The container installs its own dependencies, freshly built for itself. That’s the whole trick.
Common mistakes and how to fix them
Every one of these is something a real person has spent a real evening on. Skim the table now so you recognize them later.
| Mistake | What you’ll see | The fix |
|---|---|---|
Copying host node_modules into the image | Cryptic native-binary crash on start (invalid ELF header, missing .node) | Add node_modules to .dockerignore, let npm ci build them inside |
No .dockerignore at all | Huge image, slow builds, secrets and .git baked in | Add a .dockerignore (see above) |
COPY . . before installing deps | Full dependency reinstall on every code change | Copy package*.json first, RUN npm ci, then COPY . . |
npm install instead of npm ci | Builds drift from your lockfile, “works locally” returns | Use npm ci in the Dockerfile |
Forgetting EXPOSE | Port is a mystery to teammates and Compose | Add EXPOSE 3000 even though it’s only documentation |
Forgetting CMD | Container starts and immediately exits, doing nothing | Add CMD ["node", "server.js"] |
Using node:20 instead of -alpine or -slim | A 1GB+ image for a tiny app | Start from node:20-alpine (or -slim if a native dep needs glibc) |
App listens on localhost inside the container | -p mapping set correctly, connection still refused | Listen on 0.0.0.0, not 127.0.0.1 |
That last one earns a word, because it’s sneaky and it isn’t really a Dockerfile bug. Inside a container, binding to localhost (127.0.0.1) means “only accept connections from inside this container,” so traffic from your -p mapping gets refused at the door even though your Dockerfile is flawless. Bind to 0.0.0.0 (“accept on every interface”) and the door opens. That’s why server.js up top calls app.listen(3000, "0.0.0.0").
So what does this get you
Think of a Dockerfile as a passport for your code. Once your app builds into an image like this one, the platforms that ship real projects stop caring what’s on your laptop and start caring only about the image. Push to
From here, the natural next step is wiring this app to a Postgres or Redis container so it has something to talk to, which is the job Docker without the buzzwords hands to Docker Compose. You’ve already got the hard part: a real image, built on purpose, that runs the same everywhere. No demons were summoned. Go ship something.