M
MeshWorld.
HowTo Docker Node.js Dockerfile DevOps Containers Multi-stage Build Developer Tools 5 min read

How to Write a Production Dockerfile for Node.js

By Vishnu Damwala

Most Node.js Dockerfiles you find online are terrible for production. They install dev dependencies, copy node_modules from the host, run as root, and produce images that are 1GB when they should be 200MB.

This guide shows you how to do it properly — with layer caching, multi-stage builds, a non-root user, and a health check.


The minimal working Dockerfile

If you just need something running quickly:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

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

This works but has room for improvement. Let’s build up to the production version.


Layer caching — the most important optimization

Docker caches each layer. If a layer’s inputs haven’t changed, it reuses the cache and skips rebuilding. The order of your COPY and RUN commands determines how effective caching is.

Wrong order (cache-busting on every code change):

COPY . .              # copies everything, including source files
RUN npm ci            # runs every time ANY file changes

Right order (cache dependencies separately):

COPY package*.json ./  # only package files
RUN npm ci             # only runs when package.json changes

COPY . .               # now copy source code

Dependencies change rarely. Source code changes constantly. Put slow, stable steps first.


Multi-stage build — small production images

A multi-stage build uses two FROM statements. The first stage builds the app (with dev tools, TypeScript compiler, etc.). The second stage copies only what’s needed to run.

The final image only contains what the second stage copies — no build tools, no source TypeScript, no dev dependencies.

# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci                    # install ALL deps (including dev)

COPY . .
RUN npm run build             # compile TypeScript → dist/

# Stage 2: Production
FROM node:20-alpine AS production

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev         # production deps only

COPY --from=builder /app/dist ./dist

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

A typical TypeScript Node.js app goes from ~900MB → ~180MB with this approach.


The complete production Dockerfile

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Production runner
FROM node:20-alpine AS runner

# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nodeuser

WORKDIR /app

# Copy production dependencies
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copy built application
COPY --from=builder /app/dist ./dist

# Set ownership
RUN chown -R nodeuser:nodejs /app

# Switch to non-root user
USER nodeuser

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

# Start app
CMD ["node", "dist/index.js"]

The .dockerignore file — just as important

Without this, COPY . . sends your entire project — including node_modules, .git, .env — to Docker. Builds become slow and images become bloated.

node_modules
.git
.gitignore
.env
.env.*
*.log
dist
coverage
.nyc_output
.DS_Store
*.md
.dockerignore
docker-compose*.yml
Dockerfile*

Why non-root matters

By default, containers run as root. If your app has a vulnerability that allows command execution, an attacker running as root inside the container can do far more damage. Running as a non-root user limits the blast radius.

# Create group and user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nodeuser

# After copying files, change ownership
RUN chown -R nodeuser:nodejs /app

# Switch to the user
USER nodeuser

Health check — let orchestrators know your app is ready

Without a health check, Docker (and Kubernetes) assumes your container is healthy the moment the process starts — even if your app takes 10 seconds to initialize.

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

Add a /health endpoint to your app:

// Express
app.get('/health', (req, res) => {
  res.json({ status: 'ok', uptime: process.uptime() });
});

// Fastify
fastify.get('/health', async () => ({ status: 'ok' }));

Build and run

# Build the image
docker build -t myapp:latest .

# Build a specific stage (for debugging)
docker build --target builder -t myapp:debug .

# Run the container
docker run -d \
  --name myapp \
  -p 3000:3000 \
  --env-file .env \
  myapp:latest

# View logs
docker logs myapp -f

# Check health
docker inspect --format='{{.State.Health.Status}}' myapp

Build arguments for flexible images

FROM node:20-alpine

# Build-time argument with default
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV

WORKDIR /app
COPY package*.json ./
RUN npm ci $([ "$NODE_ENV" = "production" ] && echo "--omit=dev")
COPY . .
CMD ["node", "src/index.js"]
# Build for production (default)
docker build -t myapp .

# Build for development
docker build --build-arg NODE_ENV=development -t myapp:dev .

Common mistakes

MistakeFix
COPY . . before npm installCopy package*.json first, install, then copy source
No .dockerignoreAdd one — node_modules and .git especially
Running as rootAdd a non-root user and USER nodeuser
Using npm install instead of npm cinpm ci is reproducible; npm install isn’t
No health checkAdd HEALTHCHECK so orchestrators know your state
One giant stageUse multi-stage builds to separate build from runtime
Hardcoded secretsUse --env-file at runtime, never ENV SECRET=value in Dockerfile

Related: Docker Cheat Sheet | How to Set Up a .env File