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
| Mistake | Fix |
|---|---|
COPY . . before npm install | Copy package*.json first, install, then copy source |
No .dockerignore | Add one — node_modules and .git especially |
| Running as root | Add a non-root user and USER nodeuser |
Using npm install instead of npm ci | npm ci is reproducible; npm install isn’t |
| No health check | Add HEALTHCHECK so orchestrators know your state |
| One giant stage | Use multi-stage builds to separate build from runtime |
| Hardcoded secrets | Use --env-file at runtime, never ENV SECRET=value in Dockerfile |
Related: Docker Cheat Sheet | How to Set Up a .env File