Proper Dockerfile Setup for JavaScript Monorepos

Published on

For some time now, I have been using Railway to deploy my projects. They offer an amazing platform to painlessly deploy applications without having to deal with the complexities of setting up a CI/CD pipeline. Things work well, I would say too well. Simply grant them access to the Git repository, and voila, it’s all set up and running.

To accomplish this, Railway developed Nixpacks and Railpack build systems. Essentially, these tools generate a Dockerfile from whatever files are in the git repo. These tools detect programming language, package managers, and even application frameworks to achieve supposedly optimal builds. However, this “magical” approach has its downsides…

The Issue

It is a normal, calm coding day for a JavaScript™ developer. While keeping up with the forever-updating npm dependencies, I have encountered an issue: updating the project to use Prisma 7 breaks the CI/CD, even though it builds on my machine.

The culprit of the issue is Nixpacks. Turns out, it cannot use a specific version of Node, only the major versions can be specified. The .nvmrc is not respected. In my case, v22.11.0 was chosen by Nixpacks, while the latest LTS version is v22.21.1!

The build error is confusing, a classic module import-require whackamole. Nothing new here, just an ordinary JavaScript™ development experience.

Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/.pnpm/zeptomatch@2.0.2/node_modules/zeptomatch/dist/index.js from /app/node_modules/.pnpm/@prisma+dev@0.15.0_typescript@5.9.3/node_modules/@prisma/dev/dist/index.cjs not supported.
Instead change the require of index.js in /app/node_modules/.pnpm/@prisma+dev@0.15.0_typescript@5.9.3/node_modules/@prisma/dev/dist/index.cjs to a dynamic import() which is available in all CommonJS modules.

Switching to Railpack fixed the Node versioning issue. The application is built successfully, but slowly. Building with Railpack introduced significant performance degradation. My low-spec VPS took about 10 minutes, consuming 2GB of RAM and 10GB of disk in the process… And the caching did not seem to work either: any small change to the application code requires fetching thousands of npm packages.

We can do better, let’s go back to the source and artisanally craft a Dockerfile that will build optimally every time!

The Implementation

My project uses a pnpm monorepo (workspaces), and it is setup with the following config.

pnpm-workspace.yaml
packages:
- apps/*
- packages/*
linkWorkspacePackages: true

A naive approach to building would utilize this Dockerfile.

FROM node:22-alpine
WORKDIR /app
COPY . .
RUN corepack enable pnpm
RUN pnpm install --frozen-lockfile
RUN pnpm build:dashboard
EXPOSE 3000
CMD ["sh", "-c", "pnpm predeploy && pnpm start:dashboard"]

The catch is that running COPY . . invalidates the cache. Any tiny change will require a full package reinstall. To avoid cache busting, we need to copy only relevant files for the pnpm install. These are mostly made up from package.json in the root of each package.

Unfortunately, there is no way to glob copy in Docker while preserving folder structure. Things need to be done manually. Here is the trick:

FROM node:22-alpine AS setup
WORKDIR /app
# Copy all packages
COPY apps apps
COPY packages packages
# Find and remove not package.json files
RUN find . \
-mindepth 3 -maxdepth 3 \
\! -name "package.json" \
-exec rm -rf {} +
# Copy other files required to build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN tree ./

A nice splash of bash spells gets us the following folder structure in the setup layer:

./
├── apps
│ ├── dashboard
│ │ └── package.json
│ └── worker
│ └── package.json
├── package.json
├── packages
│ ├── common
│ │ └── package.json
│ └── database
│ └── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

Fun fact: the tree command in Alpine Linux comes from BusyBox. It does not accept any arguments, so it is impossible to show hidden files!

These files are enough to isolate the dependency layer in the Dockerfile. Even though this command copies all files and then deletes irrelevant ones, the hash of the container filesystem at the end of this “setup” layer will be the same when no packages dependencies change. Changing application code will not invalidate this layer.

Putting all this knowledge together with other best practices, we get the following final Dockerfile:

FROM node:22-alpine AS setup
WORKDIR /app
# Copy all packages
COPY apps apps
COPY packages packages
# Find and remove non-package.json files
RUN find . \
-mindepth 3 -maxdepth 3 \
\! -name "package.json" \
-exec rm -rf {} +
# Copy other files required to build
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
FROM node:22-alpine
WORKDIR /app
# Hardcoded build environment variables
ARG NODE_ENV=production
ENV NODE_ENV=production
# Mock environment variables during build
ARG DATABASE_URL="http://prisma-nextjs-build-placeholder"
# Create a non-root user to run the application
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S -G appgroup appuser
# Copy only necessary files for pnpm install
COPY --from=setup --chown=appuser:appgroup /app .
# Force pnpm location for consistency
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable pnpm
# Install packages with cache mount for pnpm store
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile --prefer-offline
# Copy all files
COPY --chown=appuser:appgroup . .
RUN pnpm build:dashboard
EXPOSE 3000
USER appuser
# Install the correct version of corepack for this user
# so that corepack does not download on container start
RUN corepack install
# Start the application
CMD ["sh", "-c", "pnpm predeploy && pnpm start:dashboard"]

In addition to efficiently caching the Docker layers, this Dockerfile also does the following:

Feel free to specify the exact version of NodeJS when building. I use the previous latest LTS and have had no issues. Spearating the application into build and run layers can be beneficial for a smaller container image size. In my use case, this is not needed as I migrate the database on application startup. Lastly, this guide will also work for package managers other than pnpm.

Happy building and deploying!