Optimizing Docker builds for Next.js

It's quite common to see Next.js apps built with a Dockerfile that looks like this.

FROM node:lts-alpine
RUN corepack enable
USER node
WORKDIR /usr/app

COPY package.json  .
COPY node_modules ./node_modules
COPY next.config.ts .
COPY public ./public
COPY .next ./.next

RUN corepack prepare --activate
CMD [ "pnpm", "start" ]

The reality being this usually happens in CI after installing dependencies, running tests, building the app, etc. - and obviously this isn't ideal:

  • there's a requirement to pnpm i and pnpm run build before running docker build
  • the final image contains all dev and prod dependencies in node_modules
  • it's easy to forget to add a new COPY step when adding more config files

More subtly, the resulting image depends on Corepack, an experimental tool for managing package manager versions. It's great for development - simply define your PNPM version in package.json, and when PNPM is invoked, Corepack will intercept and first check the version of PNPM and switch to the correct one if it does not match, keeping all engineers in a project on the same tool.

Back in February, this actually caused us some downtime. The NPM registry made a mistake when they rotated their keys, causing a mismatch between the keys provided by NPM and the signatures Corepack was validating. When invoked Corepack, would throw an error due to its inability to verify the signature of the PNPM package against its trusted key list. In the above Docker image, the first call to PNPM (and therefore invoking Corepack) is at the entry point. Hello, KubePodCrashLooping!

We can fix the obvious pain points this by using a multi-stage Dockerfile:

FROM node:lts-alpine AS base

FROM base AS builder
RUN corepack enable
USER node
WORKDIR /usr/app

COPY package.json  .
COPY pnpm-lock.yaml  .

RUN pnpm install

COPY . .

RUN pnpm run build

FROM base AS runner
RUN corepack enable
USER node
WORKDIR /usr/app

COPY package.json  .
COPY pnpm-lock.yaml  .

RUN pnpm install --prod

COPY --from=builder /usr/app/public ./public
COPY --from=builder /usr/app/.next ./.next

RUN corepack prepare --activate
CMD [ "pnpm", "start" ]

In the builder stage, we copy over package.json and pnpm-lock.yaml, and install dependencies. These files are copied before our source (COPY . .) since they're less likely to change, and so this ordering means we're likely to benefit from Docker layer caching. Then we build the app.

In the runner stage, we copy over package.json and pnpm-lock.yaml, and install prod dependencies only. We then copy the built Next.js artefacts from the builder stage into the runner stage. Our entrypoint remains the same.

The image created when building a create-next-app stater with the first Dockerfile would be about 643MB in size. And the second one? It's actually larger, coming in around 1.15GB. One of the key features of PNPM is it creates a shared store for all dependencies used on a machine, and when we pnpm install in a Docker layer, this store becomes part of the final image. Since PNPM uses hard links from this global store to the project node_modules, which take the same amount of space as the original file, it looks like the image is roughly double the size, which is quite counterproductive.

But really nothing is going to reduce image size like standalone mode in Next.js. With output: standalone in our next.config.ts, any import, require and fs will be analyzed and all neccesary files will be included in the .next directory. This means we can avoid a node_modules at all in our final Docker image.

And so our final Docker image looks like this:

FROM node:lts-alpine AS base

FROM base AS builder
RUN corepack enable
USER node
WORKDIR /usr/app

COPY package.json  .
COPY pnpm-lock.yaml  .

RUN pnpm install

COPY . .

RUN pnpm run build

FROM base AS runner
USER node
WORKDIR /usr/app

COPY --from=builder /usr/app/public ./public
COPY --from=builder /usr/app/.next/standalone ./
COPY --from=builder /usr/app/.next/static ./.next/static

EXPOSE 3000

CMD HOSTNAME="0.0.0.0" node server.js

In the runner stage, we no longer copy anything dependency related over from builder; just the .next directory. Our Dockerfile entrypoint now becomes a simple CMD that just points Node.js at the generated Next.js server.js file. And just like that, our final image is ~200MB, with no runtime dependency on Corepack. Viola!