Skip to content

DevOps

Dockerizing a MERN App for Production Without the 1.2 GB Image

Multi-stage builds, non-root users, real health checks and the layer-caching trick that takes rebuilds from four minutes to twenty seconds.

3 min read0 views
  • #docker
  • #devops
  • #nodejs
  • #deployment
  • #mern

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:20 is Debian-based and ~1 GB before your code. node:20-alpine is ~130 MB.
  • COPY . . before npm install means any file change invalidates the dependency layer. Every build reinstalls everything.
  • npm install can resolve different versions than your lockfile. npm ci can't.
  • Dev dependencies, source files and build tooling all ship to production.

Multi-stage: build in one image, run in another

Dockerfile
# ── 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:

next.config.ts
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 nextjs

One 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.

.dockerignore
node_modules
.next
.git
.env*
npm-debug.log
Dockerfile*
docker-compose*
README.md
coverage
.vscode

Note .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:

app/api/health/route.ts
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

docker-compose.yml
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 ci for layer caching
  • npm ci, never npm install
  • .dockerignore covering node_modules, .git, .env*
  • Non-root USER
  • Health check that actually verifies dependencies
  • CMD ["node", …], not npm
  • SIGTERM handler 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.