How to Containerize a Node.js App with Docker for Production
๐ Want the best deal? Check current prices and availability.
Compare Prices โWhen you buy through links on our site, we may earn a commission.
How to Containerize a Node.js App with Docker for Production
Most Node.js Docker tutorials stop at "it runs." You copy a Dockerfile from a blog, run docker build, and pat yourself on the back when the container starts. Then you push it to production and discover the image is 1.2 GB, the container ignores shutdown signals, and your logs are a mess of EACCES errors.
This containerization guide walks through the version that actually holds up โ the one you'd ship to a real server handling real traffic. We'll build a production-grade Dockerfile for a Node.js app step by step, explain why each line exists, and then compare the registries and hosting platforms you'll need to actually deploy the thing.
Let's get to it.
What You'll Need
- Node.js 20 LTS or newer installed locally
- Docker Engine 24+ (Docker Desktop works fine on macOS and Windows)
- A Node.js app to containerize. If you don't have one, a simple Express server works.
- Basic familiarity with the command line
For the examples below I'll assume a typical setup: a TypeScript or modern JS app that compiles to a dist/ folder, uses package.json and package-lock.json, and runs a web server on port 3000. If yours differs, the concepts still map cleanly.
The Dockerfile Everyone Writes First
Here's what a beginner tutorial gives you:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
It works. It's also about 1 GB in size, includes your .git folder, your node_modules, your test files, and your .env secrets. It rebuilds from scratch on every code change, and it runs as root. Every one of those is a problem in production. Let's fix them one at a time.
Step 1: Switch to a Multi-Stage Build
A multi-stage build lets you use one image to compile and a smaller, cleaner image to run. Build tools, dev dependencies, and source files never Make it into the final artifact.
# ---- Stage 1: install all deps and build ----
FROM node:20.11-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
---- Stage 2: production dependencies only ----
FROM node:20.11-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
---- Stage 3: the actual runtime ----
FROM node:20.11-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Three stages might look like overkill. It isn't. Splitting prod-deps from builder means your final image carries production dependencies only โ no TypeScript compiler, no test runners, no build tooling. On a typical Express + TypeScript app that's the difference between a 180 MB image and a 900 MB one.
The --chown=node:node flags matter too. Without them, files land in the image owned by root, and your non-root process can't read or write them. That's the source of about half the permission errors people hit on their first production deploy.
Step 2: Pick the Right Base Image
The base image is your single biggest lever on image size and attack surface. There's no universally correct answer โ it depends on what your app needs.
| Base image | Compressed size | libc | Shell | Non-root by default | Best for |
|---|---|---|---|---|---|
node:20 (bookworm) | ~350 MB | glibc | Yes | No | Native modules needing full toolchain |
node:20-slim | ~75 MB | glibc | Yes | No | Balanced default for most apps |
node:20-alpine | ~50 MB | musl | Yes | No | Small images, simple apps |
gcr.io/distroless/nodejs20-debian12 | ~40 MB | glibc | No | Yes (uid 1000) | Security-hardened workloads |
A few things worth knowing before you pick:
Alpine uses musl, not glibc. Most pure-JS packages don't care. But native modules like sharp, bcrypt, or anything with prebuilt binaries can behave differently or fail to install. If you hit weird runtime crashes with Alpine, switching to -slim usually fixes it in five minutes. I've burned an afternoon on this more than once.
Distroless has no shell. That's the point โ it strips out sh, package managers, and everything else an attacker could use. But it also means you can't docker exec into the container to debug. For teams without solid observability, that trade-off can be painful.
For most solo devs and small teams, node:20-slim is the honest default. It's small enough, glibc-compatible with everything, and debuggable. Reach for Alpine when size genuinely matters (edge deployments, tight image registries) and distroless when you have a compliance reason.
Step 3: Lock Dependencies with npm ci
Notice the Dockerfile uses npm ci, not npm install. This matters more than people realize.
npm ci installs exactly what's in package-lock.json, deletes node_modules first, and fails loudly if the lockfile and package.json disagree. npm install will happily resolve new minor versions and rewrite your lockfile. In a Docker build, that means two builds from the same commit can produce different images. Not great when you're trying to debug a production issue at 2 AM.
Also, copy package.json and package-lock.json before copying the rest of your source. Docker caches layers, so as long as your dependencies haven't changed, the npm ci step is reused even when you edit application code. This turns a 90-second rebuild into a 3-second one. It's the single biggest quality-of-life improvement in this whole tutorial.
For production dependencies, use npm ci --omit=dev. The older --production flag is deprecated and behaves inconsistently across npm versions.
Step 4: Run as a Non-Root User
By default, containers run as root. If someone escapes your app, they're root inside the container, which is a much shorter path to the host than it should be. The node base images ship with a node user (uid 1000) precisely
๐ Want the best deal? Check current prices and availability.
Compare Prices โ