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
# 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:
docker build -t my-node-app .
docker run -d --name my-node-app -p 3000:3000 my-node-appThe 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 latest — latest 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
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:
USER nodeThis 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.
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:
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:
# 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:
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):
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:
services:
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
restart: unless-stoppeddocker 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:
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 installin the Dockerfile. Builds aren't reproducible. Usenpm ci.- Shipping
node_modulesfrom the host. Without a.dockerignore,COPY . .overwrites the container's own installednode_moduleswith the host's, which may have native modules compiled for the wrong platform. Always ignorenode_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_modulesslow on Mac. Use the named-volume trick above.
What to do next
- How to Write a Dockerfile — fundamentals.
- Docker Image Size Optimization — going further than this article's multi-stage pattern.
- How to Dockerize a Next.js App — the Next-specific recipe with
output: 'standalone'for tiny images. - Docker Compose: Getting Started — full Compose treatment.
FAQ
Sources
Authoritative references this article was fact-checked against.
- Official Node.js image — Docker Hubhub.docker.com
- Node.js release schedulenodejs.org

