ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeProduction Dockerfiles for Node.js: From 1.2GB to 120MB

Production Dockerfiles for Node.js: From 1.2GB to 120MB

Build smaller, safer Node.js Docker images with multi-stage builds, non-root containers, secure secret handling, layer caching, health checks, and production-ready deployment practices.

ZyVOP
ZyVOP
Senior Developer
May 27, 2026
6 min read
Production Dockerfiles for Node.js: From 1.2GB to 120MB
#Node.js Docker production 2026#multi-stage Dockerfile Node.js#Docker secrets Node.js#Docker non-root user Node.js#Docker image size optimization#layer cache optimization Docker#Dockerfile best practices 2026#Trivy image scanning

The default Dockerfile most developers write for a Node.js app looks something like this:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]

It works. It also ships your TypeScript compiler, test frameworks, devDependencies, local .env files, and possibly your entire git history to production. The resulting image is often over 1GB. Every deploy pulls that over the network. Every container starts with a surface area that includes hundreds of packages your running app never touches.

Multi-stage builds that cut image sizes by 70%, running as non-root, handling secrets without leaking them into layers, health checks that actually work, and signal handling that prevents 30-second graceful shutdown failures โ€” this guide covers all of it.


The Size Problem Is a Security Problem

A bloated image is not just slow to pull. Many teams initially start with a single Dockerfile that bundles everything: compilers, development libraries, test frameworks, and the application itself โ€” leading to bloated images that are slow to pull, consume excessive storage, and expose a significantly larger attack surface.

Every tool in a production image is a potential attack vector. If an attacker gets code execution inside your container, a full Node.js dev image gives them npm, npx, build tools, and potentially debugging utilities. A minimal production image gives them almost nothing to work with.

The size comparison in practice:

Approach

Image Size

Single stage, node:20

~1.2 GB

Single stage, node:20-alpine

~350 MB

Multi-stage, node:20-alpine

~120โ€“180 MB

Multi-stage, distroless

~80โ€“100 MB


The Production Dockerfile

# syntax=docker/dockerfile:1.7
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Stage 1: Dependencies
# Separate stage so npm ci is cached when source changes
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FROM node:20-alpine AS deps

WORKDIR /app

# Copy only package files first โ€” Docker caches this layer
# npm ci only reruns when package.json or lockfile changes
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Stage 2: Build
# TypeScript compilation happens here โ€” tsc not in prod image
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FROM node:20-alpine AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build TypeScript โ†’ dist/
RUN npm run build

# Prune devDependencies โ€” only production deps in final image
RUN npm ci --frozen-lockfile --only=production && \
    npm cache clean --force

# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Stage 3: Production
# Minimal image โ€” only the compiled output and prod deps
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FROM node:20-alpine AS production

# Security: create non-root user
# Running as root = attacker gets root access if container is compromised
RUN addgroup --system --gid 1001 appgroup && \
    adduser  --system --uid 1001 --ingroup appgroup appuser

WORKDIR /app

# Copy only what the running app needs
COPY --from=builder --chown=appuser:appgroup /app/dist         ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./package.json

# Switch to non-root user before anything else
USER appuser

# Document the port โ€” does not actually publish it
EXPOSE 3000

# Health check โ€” Docker marks the container unhealthy if this fails
# Use wget not curl โ€” curl is not in alpine by default
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Use exec form, not shell form
# Shell form: CMD node dist/index.js  โ†’ runs as /bin/sh -c "node ..."
# Exec form: CMD ["node", "dist/index.js"] โ†’ node is PID 1, receives SIGTERM directly
# Without exec form, SIGTERM goes to the shell, not node โ€” graceful shutdown breaks
CMD ["node", "dist/index.js"]

The .dockerignore File

Without a .dockerignore, COPY . . copies everything including node_modules you just installed, making builds slow and images huge.

# .dockerignore
node_modules
npm-debug.log*
.npm

# Build output โ€” not needed, builder stage compiles fresh
dist/

# Environment files โ€” secrets must never bake into image layers
.env
.env.*
!.env.example

# Development and test files
coverage/
*.test.ts
*.spec.ts
__tests__/
vitest.config.*

# Version control
.git
.gitignore

# Editor
.vscode
.idea
*.swp

# OS
.DS_Store
Thumbs.db

# Docker
Dockerfile*
docker-compose*
.dockerignore

# Documentation
*.md
docs/

Secrets: What Never Goes in a Dockerfile

Secrets baked into image layers are accessible to anyone who can pull the image โ€” use Docker secrets or pass secrets at runtime, never in the Dockerfile.

# NEVER do this โ€” the secret is in the image layer permanently
# Even if you delete it in a later layer, it stays in the layer history
ENV DATABASE_URL=postgresql://user:password@host/db
RUN echo "SECRET_KEY=abc123" > .env

Three safe approaches:

1. Runtime environment variables (simplest):

docker run -e DATABASE_URL="postgresql://..." -e JWT_SECRET="..." myapp

2. Docker secrets (for Compose and Swarm):

# docker-compose.yml
secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  app:
    secrets: [db_password]
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
// Read secret from file at runtime
const dbPassword = process.env.DB_PASSWORD_FILE
  ? fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf-8').trim()
  : process.env.DB_PASSWORD;

3. Build-time secrets (for private npm registries, etc.):

# syntax=docker/dockerfile:1.7
# Mounts the secret temporarily during build โ€” NOT stored in any layer
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci
docker build --secret id=npm_token,src=$HOME/.npmrc .

Layer Cache Optimization

By copying package.json before your source code, Docker can cache the npm ci layer โ€” if source changes but dependencies don't, Docker reuses the cached install, saving 2โ€“3 minutes from build times.

The correct order for maximum cache efficiency:

# GOOD โ€” cache-friendly order
COPY package.json package-lock.json ./   # Only reruns npm ci when these change
RUN npm ci --frozen-lockfile
COPY tsconfig.json ./                    # Changes less often than src
COPY src/ ./src/                         # Changes most often โ€” last COPY
RUN npm run build
# BAD โ€” invalidates cache on every source change
COPY . .                                 # Any file change invalidates everything below
RUN npm ci --frozen-lockfile             # Reinstalls ALL dependencies every time

Multi-Architecture Builds

If your dev machine is Apple Silicon (ARM) and your server is x86, build for both:

# Build for both AMD64 and ARM64 and push to registry
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag ghcr.io/your-org/your-app:latest \
  --push \
  .

In GitHub Actions:

- name: Build and push multi-arch image
  uses: docker/build-push-action@v5
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ghcr.io/${{ github.repository }}:latest

Scanning Images for Vulnerabilities

Before pushing to production, scan the image:

# Trivy โ€” free, fast, comprehensive
docker run --rm aquasec/trivy:latest image your-app:latest

# Or install locally
brew install trivy
trivy image your-app:latest

# Fail CI if HIGH or CRITICAL vulnerabilities found
trivy image --exit-code 1 --severity HIGH,CRITICAL your-app:latest

Add to GitHub Actions:

- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
    format: table
    exit-code: 1
    severity: HIGH,CRITICAL

Container-related security incidents increased 47% year-over-year in 2025 โ€” scanning images in CI catches known CVEs before they reach production.


The Complete Build Script

#!/bin/bash
# scripts/docker-build.sh
set -e

IMAGE_NAME="ghcr.io/your-org/your-app"
GIT_SHA=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)

echo "Building $IMAGE_NAME:$GIT_SHA"

docker build \
  --target production \
  --tag "$IMAGE_NAME:$GIT_SHA" \
  --tag "$IMAGE_NAME:latest" \
  --label "git.sha=$GIT_SHA" \
  --label "git.branch=$BRANCH" \
  --label "build.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  .

echo "Image size:"
docker image inspect "$IMAGE_NAME:$GIT_SHA" \
  --format='{{.Size}}' | \
  numfmt --to=iec-i --suffix=B

echo "Scanning for vulnerabilities..."
trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMAGE_NAME:$GIT_SHA"

echo "Build complete: $IMAGE_NAME:$GIT_SHA"

The Checklist

โœ… Multi-stage build โ€” compiler/devDeps not in production image
โœ… node:20-alpine base โ€” not node:20 (saves ~850MB)
โœ… package.json COPY before src COPY โ€” layer cache works
โœ… npm ci --frozen-lockfile โ€” deterministic installs
โœ… Non-root user created and switched to before CMD
โœ… .dockerignore excludes node_modules, .env, dist, .git
โœ… CMD uses exec form ["node", ...] โ€” not shell form
โœ… HEALTHCHECK defined โ€” Docker knows when container is ready
โœ… No secrets in ENV, ARG, or RUN โ€” pass at runtime or via mounts
โœ… Image scanned with Trivy in CI pipeline
โœ… Labels added โ€” git SHA, branch, build date for traceability

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1