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.
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.
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.
FROM base AS deps
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.
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.
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.
FROM base AS 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
.
FROM base
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:
node_modules
from our deps
stagedist
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.