Dockerizing a Node.js Application
A container packages your application together with its exact Node.js runtime, dependencies, and operating-system libraries into one immutable image that runs identically on a laptop, a CI runner, and a production cluster. The goal of a good Dockerfile is to produce a small, secure image quickly by reusing cached layers and shipping only what the process needs to run. This page walks through choosing a base image, writing a multi-stage build, ordering layers for caching, dropping root privileges, and trimming the build context with .dockerignore.
Choosing a base image
The base image is the foundation your app sits on, and it dictates both image size and attack surface. The official node images come in several variants; pick the smallest one that still includes the system libraries your dependencies need.
| Tag | Base OS | Approx. size | When to use |
|---|---|---|---|
node:22 | Debian (full) | ~1.1 GB | Native modules needing many build tools |
node:22-slim | Debian (minimal) | ~250 MB | Most apps; broad glibc compatibility |
node:22-alpine | Alpine (musl) | ~140 MB | Smallest images, pure-JS dependencies |
Alpine uses musl libc instead of glibc, so a handful of native addons (or prebuilt binaries) may misbehave. When in doubt, start with slim for compatibility and switch to alpine once you confirm everything builds. Always pin a specific major version rather than latest so rebuilds stay reproducible.
Pin by digest (
node:22-slim@sha256:...) for fully deterministic builds in regulated environments. The floatinglatesttag can change the Node.js major version under you and break production without warning.
A multi-stage Dockerfile
A multi-stage build uses one stage to install dependencies and compile your code, then copies only the resulting artifacts into a clean final stage. The build toolchain, dev dependencies, and source files never reach the shipped image, which keeps it small and reduces what an attacker can exploit.
# syntax=docker/dockerfile:1
# --- Stage 1: install dependencies ---
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# --- Stage 2: build the application ---
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Stage 3: minimal runtime image ---
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]
The deps stage installs only production dependencies, the build stage installs everything and compiles (for example TypeScript via npm run build), and the lean runtime stage assembles the final image from those outputs. Because node_modules comes from the production-only deps stage, dev tooling like compilers and linters is excluded automatically.
Build and run it like any other image:
docker build -t my-api:1.0 .
docker run --rm -p 8080:8080 --env-file .env my-api:1.0
Output:
[+] Building 9.4s (16/16) FINISHED
=> [deps 3/3] RUN npm ci --omit=dev 4.1s
=> [build 4/4] RUN npm run build 2.8s
=> exporting to image 0.6s
=> => naming to docker.io/library/my-api:1.0
Server listening on http://0.0.0.0:8080 (production)
Layer caching for node_modules
Docker caches each instruction as a layer and reuses it until an input changes. The classic mistake is copying your whole project before installing dependencies, which busts the dependency cache on every source edit. Copy the lockfile first, install, and only then copy the rest of the source:
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
With this ordering, the slow npm ci layer is rebuilt only when package.json or package-lock.json actually change. Editing application code reuses the cached dependency layer, turning a multi-minute install into an instant cache hit. Always use npm ci (not npm install) in images: it installs exactly what the lockfile specifies, deterministically and faster.
For an even bigger speedup, mount a persistent cache so the package manager reuses downloaded tarballs across builds:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
Run as a non-root user
By default containers run as root, so a process escape inherits root inside the container — a needless risk. The official Node.js images ship a pre-created unprivileged node user (UID 1000). Switch to it with USER node before the CMD, and make sure any files the app writes are owned by that user:
WORKDIR /app
COPY --chown=node:node --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Use the JSON-array (exec) form of CMD so Node becomes PID 1 and receives SIGTERM directly for graceful shutdown — the shell form would swallow signals. Never run npm start as PID 1; invoke node so termination signals reach your process.
Shrink the build context with .dockerignore
Everything in the build directory is sent to the Docker daemon as the build context. A .dockerignore file excludes files that bloat the context, slow builds, and risk leaking secrets or a stale local node_modules into the image:
node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
Dockerfile
.dockerignore
*.md
Excluding node_modules is especially important: you want the modules installed inside the image for the target platform, not your host’s copy (which may contain OS-specific native binaries). Excluding .env prevents secrets from being baked into image layers.
Best Practices
- Pin a specific LTS tag (
node:22-slimornode:22-alpine); never shiplatest. - Use multi-stage builds so compilers and dev dependencies stay out of the runtime image.
- Copy the lockfile and run
npm cibefore copying source to maximize layer-cache reuse. - Set
ENV NODE_ENV=productionand install with--omit=devto keep the image lean and fast. - Drop privileges with
USER nodeand use the exec-formCMDso signals reach PID 1. - Maintain a thorough
.dockerignoreto shrink the context and avoid leaking.envand.git. - Add a
HEALTHCHECKor an app-level/healthroute so orchestrators can detect unhealthy containers.