{"schemaVersion":"1.0","type":"Article","types":["Article"],"slug":"production-dockerfiles-for-node-js-from-1-2gb-to-120mb-fgto4","url":"https://zyvop.com/production-dockerfiles-for-node-js-from-1-2gb-to-120mb-fgto4","title":"Production Dockerfiles for Node.js: From 1.2GB to 120MB","subtitle":"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.","tldr":"Most Node.js Docker images are unnecessarily large, insecure, and slow to deploy. This guide covers production-ready Docker practices including multi-stage builds, non-root containers, layer caching, secret management, health checks, multi-architecture builds, and CI vulnerability scanning with Trivy.","keywords":["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"],"entities":["ZyVOP","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"],"keyTakeaways":["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."],"headings":["The Size Problem Is a Security Problem","The Production Dockerfile","The .dockerignore File","Secrets: What Never Goes in a Dockerfile","Layer Cache Optimization","Multi-Architecture Builds","Scanning Images for Vulnerabilities","The Complete Build Script","The Checklist"],"outboundLinks":[],"contentText":"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 &amp;&amp; \\ 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 &amp;&amp; \\ 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\" &gt; .envThree safe approaches: 1. Runtime environment variables (simplest): docker run -e DATABASE_URL=\"postgresql://...\" -e JWT_SECRET=\"...\" myapp2. 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 cidocker 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 timeMulti-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 }}:latestScanning 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:latestAdd 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,CRITICALContainer-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","contentHash":"sha256:0bf6e89ce41c8af1a07299808df96afdd5de3bb008343321db10af5cdc50c0dd","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["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"],"audience":"Software engineers and developers building applications with Node.js Docker production 2026","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":6,"wordCount":1229,"faqs":null,"primaryTopic":"Node.js Docker production 2026","publishedAt":"2026-05-27T09:25:47.648Z","updatedAt":"2026-08-24T08:40:00.319Z","canonicalUrl":"https://zyvop.com/production-dockerfiles-for-node-js-from-1-2gb-to-120mb-fgto4"}