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
andpnpm run build
before runningdocker 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!