My first production Dockerfile was five lines, produced a 1.2 GB image, ran as root, and rebuilt everything from scratch when I changed one line of CSS.
It worked. That's the trap — it works, so you don't fix it until the deploy pipeline is taking six minutes and someone asks why the container has a full compiler toolchain in it.
Here's what I ship now, and why each piece is there.
The problem with the naive version
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]Four things wrong:
node:20is Debian-based and ~1 GB before your code.node:20-alpineis ~130 MB.COPY . .beforenpm installmeans any file change invalidates the dependency layer. Every build reinstalls everything.npm installcan resolve different versions than your lockfile.npm cican't.- Dev dependencies, source files and build tooling all ship to production.
Multi-stage: build in one image, run in another
# ── deps ────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ── build ───────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ── runtime ─────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]The final image contains a Node runtime and the compiled output. No source, no dev dependencies, no build tools. Typically ~150 MB instead of 1.2 GB.
The caching detail that matters most
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Docker caches each layer and invalidates everything after the first change. Copying only the manifests before installing means the npm ci layer is reused on every build where dependencies haven't changed.
Edit a component: rebuild takes ~20 seconds. Add a package: it reinstalls, once. Reverse those two lines and every build is four minutes.
Standalone output
That Dockerfile assumes Next.js standalone mode. Enable it:
const nextConfig = {
output: 'standalone',
};Next.js traces which files are actually reachable and emits a minimal server bundle with only the node_modules it needs. It's the difference between copying a 400 MB node_modules and a 40 MB one.
For a plain Express API there's no standalone equivalent — install production deps only in the runtime stage:
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Don't run as root
The default user in a Node image is root. A container escape or an RCE in your app then starts from root.
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
USER nextjsOne line of defence-in-depth, no runtime cost, and some platforms now reject root containers outright.
.dockerignore is not optional
Without it, COPY . . sends your entire working directory — including node_modules, .git and .env.local — into the build context. That's slow, and it puts secrets in image layers.
node_modules
.next
.git
.env*
npm-debug.log
Dockerfile*
docker-compose*
README.md
coverage
.vscodeNote .env*. Secrets belong in runtime environment variables, never baked into an image — anyone who can pull the image can read the layers.
Health checks that check health
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"And make the endpoint mean something. A route that returns 200 OK unconditionally will happily report healthy while the database is unreachable:
import mongoose from 'mongoose';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
// 1 === connected
const dbUp = mongoose.connection.readyState === 1;
return NextResponse.json(
{ status: dbUp ? 'ok' : 'degraded', db: dbUp, uptime: process.uptime() },
{ status: dbUp ? 200 : 503 }
);
}--start-period matters: without it, an app that takes 15 seconds to boot gets killed by the orchestrator before it ever becomes ready.
Compose for local development
services:
mongo:
image: mongo:7
restart: unless-stopped
ports: ['27017:27017']
volumes: ['mongo-data:/data/db']
healthcheck:
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
interval: 10s
retries: 5
app:
build:
context: .
target: build # dev stage, keeps source and dev deps
command: npm run dev
ports: ['3000:3000']
environment:
MONGODB_URI: mongodb://mongo:27017/portfolio
NODE_ENV: development
volumes:
- .:/app
- /app/node_modules # ← keeps the container's node_modules
- /app/.next
depends_on:
mongo:
condition: service_healthy
volumes:
mongo-data:Two details worth calling out.
The anonymous volumes on node_modules and .next stop your host directory from shadowing the container's — otherwise a Linux container gets your Windows or macOS binaries and native modules fail in confusing ways.
condition: service_healthy waits for Mongo to actually accept connections. Plain depends_on only waits for the container to start, which is why apps race their database on docker compose up.
Signals, and why Node exits badly in Docker
Node doesn't handle SIGTERM by default. docker stop waits 10 seconds, then SIGKILLs — dropping in-flight requests every deploy.
const server = app.listen(PORT);
const shutdown = async (signal) => {
console.log(`${signal} received, draining connections…`);
server.close(async () => {
await mongoose.connection.close();
process.exit(0);
});
// Don't hang forever on a stuck connection
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Also avoid CMD ["npm", "start"]. npm doesn't forward signals to the child process, so your handler never runs. Call node directly — as the Dockerfile above does.
The checklist
- Alpine base
- Multi-stage build; final image has no source or dev deps
- Manifests copied before
npm cifor layer caching -
npm ci, nevernpm install -
.dockerignorecoveringnode_modules,.git,.env* - Non-root
USER - Health check that actually verifies dependencies
-
CMD ["node", …], not npm -
SIGTERMhandler that drains connections - No secrets in the image
Five lines to about forty. In exchange: an image eight times smaller, rebuilds twelve times faster, and deploys that don't drop requests.