TechEarl

How to Dockerize a Node.js App

A Dockerfile for a real Node.js app: multi-stage build, npm ci for deterministic dependencies, the node_modules-volume trick that makes bind-mounted source fast on Mac, and the non-root user that most tutorials skip.

Ishan KarunaratneIshan Karunaratne⏱️ 7 min readUpdated
Share thisCopied

Most Node.js Dockerfiles online are missing one or more of the things that matter at scale: a multi-stage build, deterministic dependency install, a non-root user, and the bind-mount-with-named-volume trick that keeps dev fast on Mac and Windows. The full working version of a Node.js Dockerfile is about 15 lines.

A working Dockerfile

dockerfile
# Build stage
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Build and run:

bash
docker build -t my-node-app .
docker run -d --name my-node-app -p 3000:3000 my-node-app

The rest of this article explains why each line is what it is.

Pick the right Node image

  • node:22 — Node 22 LTS, Debian-based, includes build tools. Use in the build stage.
  • node:22-alpine — Node 22 LTS, Alpine-based, much smaller (~50 MB vs ~150 MB). Use in the runtime stage if your dependencies don't pull in native modules that need glibc.
  • node:22-slim — Node 22 LTS, Debian-slim, glibc but trimmed. Use in runtime if Alpine breaks something.

Pin to the LTS major (22), not latestlatest moves with every release and breaks reproducibility.

Verify the Node LTS schedule before pinning. As of 2026, Node 22 is the current LTS; Node 20 enters maintenance mode; Node 24 enters Active LTS at some point in October each year.

Why multi-stage

The build stage installs all dependencies (including dev tools like TypeScript, webpack, esbuild) and runs the build. The runtime stage installs only production dependencies and copies the build output. The result:

  • Build stage image: ~500 MB to 1 GB. Includes TypeScript, dev dependencies, build tools.
  • Runtime image: ~80 MB. Just Node Alpine + production deps + built output.

Only the runtime image is what you ship and deploy. The build stage is discarded after docker build.

npm ci vs npm install

npm ci (ci = "clean install") reads package-lock.json directly and installs the exact versions in it. It refuses to run without a lockfile and overwrites any existing node_modules. That makes builds deterministic.

npm install reads package.json, resolves new versions if ^ or ~ ranges allow, and updates package-lock.json. Useful locally for adding a dependency; wrong for Docker builds.

Always npm ci in Dockerfiles. Same logic applies for yarn install --frozen-lockfile and pnpm install --frozen-lockfile.

The dependency-cache pattern

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Order matters. npm ci is the slow step — it downloads every dependency. Source code changes constantly. Copying the source after the install means the dependency layer stays cached: edit your source, rebuild, and only the source-copy and later layers re-run. Edit package.json, rebuild, and npm ci re-runs (correctly — the deps changed).

Reverse the order (COPY . . first, then npm ci) and every source edit busts the dependency layer cache. Builds take minutes instead of seconds.

Non-root user

The official Node image ships a non-root user named node (UID 1000). Switch to it for the runtime stage:

dockerfile
USER node

This is the cheapest container-security baseline you can do. The trade-off: anything USER node runs after this can't write to root-owned paths inside the image. Use --chown=node:node on the COPY instructions and chown files you need to write to before the USER switch.

dockerfile
COPY --chown=node:node --from=build /app/package.json /app/package-lock.json ./

.dockerignore

Without a .dockerignore, COPY . . ships node_modules, .git, .env, build artifacts, and everything else in the working directory. Add a .dockerignore:

code
node_modules
.git
.env
.env.local
*.log
coverage
dist
.next
.dockerignore
Dockerfile
README.md
.gitignore

The image gets smaller, builds get faster, and secrets stay out. See .dockerignore Best Practices for the full list.

Development: the node_modules-volume trick

Bind-mounting your project into a Node container for hot-reload dev is the standard pattern. On Mac and Windows, it's brutally slow on node_modules (Docker Desktop's virtualized filesystem chokes on large directories with many small files). The fix is to bind-mount the source but use a named volume for node_modules:

yaml
# docker-compose.dev.yml
services:
  app:
    image: node:22
    working_dir: /app
    volumes:
      - .:/app                  # bind-mount source for hot reload
      - app-node_modules:/app/node_modules   # named volume — fast
    ports:
      - "3000:3000"
    command: npm run dev

volumes:
  app-node_modules:

The bind mount of .:/app overlays your host source on top of the container's /app. The named volume of app-node_modules:/app/node_modules then overlays a Docker-managed location on top of that, so node_modules inside the container is in a fast, container-native location instead of the slow virtualized bind mount.

On Linux this matters less (bind mounts are native), but the pattern works there too and doesn't hurt anything.

Practical usage: a small Express app

server.js:

javascript
import express from 'express';
const app = express();
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.get('/', (req, res) => res.send('Hello from Docker'));
app.listen(3000, () => console.log('Listening on 3000'));

Dockerfile (single-stage for the simple case):

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

docker-compose.yml:

yaml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
    restart: unless-stopped
bash
docker compose up --build -d
curl http://localhost:3000/healthz
# {"ok":true}

Signal handling

A Node process that doesn't handle SIGTERM makes docker stop wait the full 10 seconds before SIGKILL. Add a shutdown hook for clean exits:

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

The other half of this: use the exec form of CMD in the Dockerfile (CMD ["node", "server.js"], not CMD node server.js). Shell form wraps your process in /bin/sh -c and SIGTERM never reaches Node.

Common pitfalls

  • Copying source before npm ci. Every source edit busts the dependency cache. Slow rebuilds.
  • npm install in the Dockerfile. Builds aren't reproducible. Use npm ci.
  • Shipping node_modules from the host. Without a .dockerignore, COPY . . overwrites the container's own installed node_modules with the host's, which may have native modules compiled for the wrong platform. Always ignore node_modules.
  • Running as root. Add USER node.
  • CMD in shell form. SIGTERM is eaten by the shell; graceful shutdown breaks. Use exec form: CMD ["node", "server.js"].
  • Bind-mounted node_modules slow on Mac. Use the named-volume trick above.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerNode.jsDockerfileDevOpsExpressMulti-stage

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

How to Dockerize a PHP / Laravel App

A Dockerfile for PHP-FPM + Nginx, the Composer build step, OPcache config, and the Laravel storage/cache permission flip that catches every PHP developer once.