Dockerizing a Node.js application with a Dockerfile
Dockerizing a Node.js application with a Dockerfile

Dockerizing a Node.js App: A Step-by-Step Guide

“It works on my machine” is the oldest joke in software, and containers are the punchline that finally landed. If you have a Node.js app and you want it to run the same way everywhere, learning to dockerize a Node.js app is one of the highest-leverage skills you can pick up. Here is a practical, no-fluff walkthrough.

The Dockerfile, line by line

A Dockerfile is a recipe for building an image. For a typical Node app, a solid starting point looks like this:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Each line earns its place. node:20-alpine is a small, current base image. WORKDIR sets the working directory. Then — and this is the important bit — we copy package*.json and install before copying the rest of the code.

Why copy package.json first?

Docker caches each layer. If you copy everything at once, changing a single line of source invalidates the cache and reinstalls all your dependencies on every build. By copying just the manifest and running npm ci first, Docker reuses the cached dependency layer whenever your dependencies have not changed. Your builds go from minutes to seconds. This one ordering trick is the difference between a Dockerfile that annoys you and one that does not.

Keep the image lean with .dockerignore

Add a .dockerignore so you do not bloat the image or bust the cache with junk:

node_modules
npm-debug.log
.git
.env
Dockerfile

Copying your host node_modules into the image is a classic mistake — it may contain platform-specific binaries that break inside the container. Let the container build its own.

Build and run

# Build the image
docker build -t my-node-app .

# Run it, mapping the port
docker run -p 3000:3000 my-node-app

Your app is now reachable at localhost:3000, running in an environment identical to what you will ship to production.

One step further: multi-stage builds

If your app has a build step (TypeScript, a bundler), use a multi-stage build: compile in one stage, then copy only the built output into a clean final image. You ship the result without the toolchain, and your image shrinks dramatically. It is the natural next move once the basics feel comfortable.

A multi-stage build, concretely

Since multi-stage builds are the natural next step, here’s what one actually looks like for a TypeScript app:

# Stage 1: build with the full toolchain
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: ship only what's needed to run
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

The first stage installs everything (including dev dependencies like the TypeScript compiler) and builds. The second starts from a clean image and copies in only the compiled output plus production dependencies. The compiler, test frameworks, and source files never reach the final image — commonly cutting it in half or better, and shrinking your attack surface along with the download time.

The two runtime details that bite in production

Two things work fine on your laptop and then misbehave in a real deployment. First, run as a non-root user. By default your app runs as root inside the container — an unnecessary risk. The node base images ship a ready-made node user; add USER node after your COPY lines (and make sure the files are readable by it, e.g. COPY --chown=node:node).

Second, signal handling. When your platform stops a container, it sends SIGTERM and waits briefly before killing it. If your server doesn’t listen for it, every deploy hard-kills in-flight requests. The fix is a few lines:

const server = app.listen(3000);
process.on('SIGTERM', () => {
  server.close(() => process.exit(0));  // finish requests, then exit
});

Relatedly, prefer CMD ["node", "server.js"] over CMD npm start — npm wraps your process and doesn’t forward signals reliably, which is a classic cause of “my graceful shutdown code never runs.”

Local development with volumes

Rebuilding the image on every code change would be miserable, and nobody does it. For development, mount your source into the container and run your normal dev server inside it — typically via docker-compose:

services:
  app:
    build: .
    command: npm run dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules   # keep the container's own modules

The first volume overlays your live source over the image’s copy, so nodemon or your bundler’s watcher picks up edits instantly. The second line is the non-obvious trick: it prevents your host’s node_modules (or its absence) from shadowing the ones installed inside the container — the source of the infamous “binary was compiled for a different platform” errors on Mac and Windows. Production images stay immutable; development gets hot reload. Both worlds, one Dockerfile.

Frequently asked questions

Why npm ci instead of npm install? ci installs exactly what the lockfile specifies — no resolution, no lockfile drift, faster and perfectly reproducible. In an image build, where you want the same result every time, it’s strictly better. install is for when you’re intentionally changing dependencies.

How do I pass configuration like database URLs? Environment variables at run time — docker run -e DATABASE_URL=... or the environment: block in compose. Never bake secrets into the image with ENV in the Dockerfile; images get pushed to registries and shared, and anything in a layer is readable by anyone who pulls it.

My image is huge — what’s the usual culprit? In order of likelihood: missing .dockerignore (you copied .git and local node_modules in), using the full node:20 image instead of -alpine or -slim, and shipping dev dependencies because the build runs plain npm install. Fix those three and a bloated 1.5 GB image routinely drops under 200 MB.

That is the whole core loop. Once you can dockerize a Node.js app confidently, deploying to virtually any cloud becomes a matter of “run this image” — and the machine-specific gremlins that used to eat your afternoons simply stop showing up.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *