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 runningdocker 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
instructspnpm
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
instructspnpm
to copy packages directly innode_modules/
instead of symlinking to store. Otherwisepnpm
would create symlinks fromnode_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 ofnpm install
forpnpm
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.