Skip to content
Back to Build Tutorials

Docker Starter

Write a multi-stage Dockerfile that produces a small, cacheable production image.

Why

A naive Dockerfile that copies node_modules into the image produces a 1.2 GB blob that takes 4 min to push. A multi-stage build separates the install (heavy, cacheable) from the runtime (light, deploys fast), cutting the image to 150 MB and the push to 20s. The layer cache means a code-only change rebuilds in 5s instead of 3min, because the dependency layer is reused. This matters at every deploy: a 4-min push × 10 deploys/day = 40 min of CI time wasted, and a 1.2 GB image costs 10x more to store and transfer on every registry pull.

When

When you need to deploy to a container runtime (k8s, ECS, Fly, Cloud Run). Takes 45-60 min. Skip for serverless/edge deploys that don't use containers.

How

1. Use a multi-stage build: deps → build → runtime
   - FROM node:20-slim AS deps (install with --frozen-lockfile)
   - FROM deps AS builder (copy source, run build)
   - FROM node:20-slim AS runner (copy only dist + node_modules/prod)

2. Order layers from least to most changing for cache hits
   - COPY package.json pnpm-lock.yaml → pnpm install (cached unless lockfile changes)
   - COPY . . → build (cache busted on any source change, but deps layer survives)

3. Use a .dockerignore to exclude node_modules, .next, .git, tests
   - Without this, the build context is 500 MB and every build sends it to the daemon

4. Run as a non-root user (USER node) and set a HEALTHCHECK
   - Most base images ship a 'node' user; switch to it before CMD
   - HEALTHCHECK CMD curl -f http://localhost:3000/api/health || exit 1

5. Build and test locally with buildx + a .env file
   - docker buildx build --target runner -t myapp:latest .
   - docker run --env-file .env -p 3000:3000 myapp:latest

Example

# Dockerfile (multi-stage, Next.js)
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod

FROM deps AS builder
COPY . .
RUN pnpm build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Common Problems

  • ⚠Copying the entire repo including .git and node_modules, making the build context 1 GB and the first layer take 2 min to send.
  • ⚠Using node:20 (full) instead of node:20-slim or alpine, shipping 400 MB of Debian packages the app never uses.
  • ⚠Running as root because the base image defaults to it — a container escape becomes root on the host.
  • ⚠Busting the deps cache on every code change by COPY . . before pnpm install.

Best Practices

  • ✓Pin the base image digest (FROM node:20-slim@sha256:abc...) for reproducible builds — tags move, digests don't.
  • ✓Use BuildKit cache mounts (RUN --mount=type=cache,target=/root/.pnpm-store) to share the package cache across builds.
  • ✓Scan the final image with Trivy or Grype in CI — a 150 MB image with 0 critical CVEs beats a 50 MB image with 12.
  • ✓Set a memory limit in the runtime (docker run --memory=512m) so a leak fails the container instead of the host.