We use cookies to understand how people use Depot.
Languages

Best practice Dockerfile for Node.js with pnpm

Below is an example Dockerfile that we use and recommend at Depot when we are building Docker images for Node applications that use pnpm as their package manager.

FROM node:20 AS base
 
FROM base AS deps
 
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod
 
FROM base AS build
 
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
 
FROM base
 
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
ENV NODE_ENV production
CMD ["node", "./dist/index.js"]
 

Explanation of the Dockerfile

There are several things in this example Dockerfile that are worth calling out. Most notably, we use a multi-stage build to separate the installation of dependencies from the actual build of the application. This allows us to take advantage of Docker's layer caching to speed up our builds.

Stage 1: FROM node:20 AS base

FROM node:20 AS base

Here we use the node:20 base image and set the stage name to be reused in stages that follow. If we had common dependencies that we wanted to be accessible in any stages using this base stage, we could install them here.

Stage 2: FROM base AS deps

FROM base AS deps
 
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --prod

Next up is installing our dependencies via pnpm.

First, we enable corepack in the Node base image. Corepack allows us to use pnpm right out of the box without having to install it ourselves. It's a nice convenience but watch out that you have the expected pnpm version.

We then create our working directory, app, and copy in just our package.json and pnpm-lock.yaml files. Note that we do not copy in our entire codebase. We only care about installing our production dependencies at this stage. This is a best practice that allows us to take advantage of Docker's layer caching.

Finally, we get to installing our packages via pnpm. This is broken into two RUN statements that are making use of BuildKit cache mounts.

  1. pnpm fetch --frozen-lockfile is a pnpm feature that is designed to improve building a Docker image. It fetches packages from the pnpm-lock.yaml file and stores them in the pnpm virtual store. Ignoring the package.json manifest. It's a nice optimization that avoids having to reinstall all packages in the package.json when a change occurs there that isn't related to the actual dependencies.

  2. pnpm install --frozen-lockfile --prod is the actual installation of our dependencies. We use the --frozen-lockfile flag to ensure that we are installing the exact same versions of our dependencies that are in our package.json. We also use the --prod flag to ensure that we are not installing any devDependencies.

This is a best practice that allows us to take advantage of Docker's layer caching.

We use the --frozen-lockfile flag to ensure that we are installing the exact same versions of our dependencies that we have installed locally. We also use the --prod flag to ensure that we are only installing our production dependencies. This is a best practice that allows us to take advantage of Docker's layer caching.

Stage 3: FROM base AS build

FROM base AS build
 
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

It's time to build our application. We start by enabling corepack again so that we have pnpm in this stage. We then configure our working directory to be app and copy in the package.json and pnpm-lock.yaml files as we saw in the deps stage.

We then run pnpm fetch --frozen-lockfile and pnpm install --frozen-lockfile again. However, we omit the --prod flag as we want to install all dependencies as we may need some dev ones to build our final application. If that wasn't the case, we could copy in our dependencies from our earlier deps stage.

Once our dependencies have been installed into this stage, we then copy in our source code via the COPY statement. We then run our build command, pnpm build.

Stage 4: FROM base

FROM base
 
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
ENV NODE_ENV production
CMD ["node", "./dist/index.js"]

Our final stage is copying all of the files from our earlier stages into our final image. We start by setting our working directory to app, then have several COPY statements:

  1. We copy in our node_modules from our deps stage
  2. We then copy in the dist directory from our build stage containing the outputs from our pnpm build

Finally, we set our NODE_ENV to production for any dependencies that have an optimized production mode. We then set our CMD to run our application.