Docker for Node

A Tiny, Fast, Reproducible Image

Docker for Node

A minimal Dockerfile for a Node app, with multi-stage builds for size and security.

4 min read Level 2/5 #nodejs#docker#containers
What you'll learn
  • Write a Dockerfile
  • Use multi-stage builds
  • Run as a non-root user

A Docker image bundles Node + your code + your deps. Run anywhere that can run containers — same image dev → CI → prod.

A Working Dockerfile

# 1) Build stage: install all deps, build the app
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# 2) Runtime stage: only what we need to run
FROM node:22-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production

# install prod deps only
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# copy built output
COPY --from=build /app/dist ./dist

# non-root user (already exists in node images)
USER node

EXPOSE 3000
CMD ["node", "dist/index.js"]

What’s Going On

  • node:22-alpine — small (~50MB) base image
  • Multi-stage — dev deps (TypeScript, vitest, etc.) don’t ship to production
  • USER node — don’t run as root (security best practice)
  • npm ci — installs exactly what’s in package-lock.json
  • COPY package*.json ./ then npm ci — done BEFORE COPY . . so Docker can cache the npm ci layer when only source files change

Build + Run

docker build -t my-app .
docker run -p 3000:3000 -e DATABASE_URL=... my-app

.dockerignore

Just like .gitignore — exclude node_modules, .git, .env, build artifacts:

node_modules
.git
.env*
.DS_Store
dist
coverage

Otherwise COPY . . ships your local node_modules (wrong platform binaries) into the image. Slow build, broken runtime.

Distroless / Slim

For even smaller, more locked-down images:

  • node:22-slim — Debian-based, no shell shenanigans (~80MB)
  • gcr.io/distroless/nodejs22 — minimal, no shell at all (great for production, painful to debug)

Alpine has occasional musl-vs-glibc issues with native deps — slim/distroless avoid them.

Signal Handling

If your container can’t be killed cleanly, you forgot to handle SIGTERM:

process.on("SIGTERM", () => server.close(() => process.exit(0)));

Without it, container orchestrators (Kubernetes, ECS) wait 10s, then SIGKILL. Connections drop. Users notice.

Monitoring →