Optimize NodeJS Docker image build with BuildKit and pnpm

You just Dockerized your NodeJS application, yay ! Now you need to run docker build and it's awfully long as Docker needs to download the same dependencies again and again. BuildKit - built-in with Docker - can help you cache Node dependencies to reduce build time.

Typical NodeJS Dockerfile

Dockerfiles created following Docker build best practices and NodeJS official doc probably looks like this:

# Use alpine image for ligther images
FROM node:18.7.0-alpine3.15

# Install a few system dependencies
RUN apk update && apk add ...

WORKDIR /app

# Copy package json files and install dependencies before copying 
# remaining code to optimize use of cached layers 
COPY package*.json /app
RUN npm install --frozen-lockfile --only=prod

# Copy remaining files
COPY . /app

# Set ENTRYPOINT and/or CMD
ENTRYPOINT ["node", "index.js"]

Built with a command like

docker build .

Why does it take so long to build ?

Any change to package.json or package-lock.json will trigger a full re-build of the subsequent layer running npm install, resulting in all dependencies being downloaded & installed again via npm

COPY package*.json /app
RUN npm install --frozen-lockfile --only=prod

Even if you do not change dependencies in your package.json (such as just adding a run instruction), Docker will fully invalidate the layer downloading npm dependencies and it will have to be run again from scratch.

Cache Node packages with BuildKit cache mount and pnpm

Thanks to BuildKit cache features and pnpm we're gonna save precious time in our build.

pnpm is an alternative to npm with efficient caching features. It uses a global on-disk content-addressable store where all packages are kept. Project's node_modules are either symlinking or copying content of pnpm store, resulting in much faster installation of packages as most of it can usually be re-used without download time.

Update your Dockerfile COPY package[...] and RUN npm install [...] instructions so it looks like:

# Copy our package files as usual
# pnpm use pnpm-lock.json rather than package-lock.json
COPY package*.json pnpm-lock.yaml /app

# Install dependencies with pnpm and cache mount
RUN --mount=type=cache,id=pnmcache,target=/pnpm_store \
  pnpm config set store-dir /pnpm_store && \
  pnpm config set package-import-method copy && \
  pnpm install --prefer-offline --ignore-scripts --frozen-lockfile

Our new RUN instructions in details:

  • --mount=[...] will ensure a persistent cache volume is mounted at /pnpm_store. This is like running docker run -v /tmp/cache:/pnpm_store ... except that mount is only available at build time. Our resulting Docker image won't have this mount available after build.
  • pnpm config set store-dir /pnpm_store instructs pnpm to use /pnpm_store directory (mounted as cache volume) to save downloaded packages and re-use existing one if possible, saving lots of time.
  • pnpm config set package-import-method copy instructs pnpm to copy packages directly in node_modules/ instead of symlinking to store. Otherwise pnpm would create symlinks from node_modules to /pnpm_store which wouldn't exist after build time. By copying we ensure that packages are effectively saved in resulting Docker image.
  • pnpm install is the equivalent of npm install for pnpm

The following image outlines what happens during build:

Our Dockerfile now looks like

# Use alpine image for ligther images
FROM node:18.7.0-alpine3.15

# Install a few system dependencies
RUN apk update && apk add ...

WORKDIR /app

# Copy our package files as usual
# pnp use pnpm-lock.json rather than package-lock.json
COPY package*.json pnpm-lock.yaml /app

# Install dependencies with pnpm and cache mount
RUN --mount=type=cache,id=pnmcache,target=/pnpm_store \
  pnpm config set store-dir /pnpm_store && \
  pnpm config set package-import-method copy && \
  pnpm install --prefer-offline --ignore-scripts --frozen-lockfile

# Copy remaining files
COPY . /app

# Set ENTRYPOINT and/or CMD
ENTRYPOINT ["node", "index.js"]

Running on CI

BuildKit cache mount is persisted by Builder. If you run Docker builds on CI, you should use a persistent Docker builder on which will your Docker build will run, or configure a remotely accessible Docker builder such as an SSH Docker builder.

Full example

See Docker Examples GitHub repository for a full example.

Hi there! You went this far, maybe you'll want to subscribe?

Get mail notification when new posts are published. No spam, no ads, I promise.

Leave a Reply

Your email address will not be published. Required fields are marked *