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 CI/CD pipelines. Things work well, I would say too well. Simply grant them access to the Git repository, and voila, your application is up and running.
To accomplish this, Railway developed Nixpacks and Railpack build systems. Essentially, these tools generate a Dockerfile from the contents of a 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 Motivation
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 Trick
My project uses a pnpm monorepo (workspaces), and it is setup with the following config:
packages:
- apps/*
- packages/*
linkWorkspacePackages: trueA naive approach to building would utilize the following 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 of the setup layer:
./
├── apps
│ ├── dashboard
│ │ └── package.json
│ └── worker
│ └── package.json
├── package.json
├── packages
│ ├── common
│ │ └── package.json
│ └── database
│ └── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yamlFun 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 sufficient to isolate dependencies during the build. Even though this command copies all files and then deletes irrelevant ones, the hash of the container filesystem at the end of the setup layer will be the same when dependencies haven’t changed. Application code modifications will not invalidate the setup layer.
The Final Result
Putting all this knowledge together with other best practices, we end up with the following 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 every container start
RUN corepack install
# Start the application
CMD ["sh", "-c", "pnpm predeploy && pnpm start:dashboard"]The final version also does the following:
- Sets up a non-root user to run the application
- Caches the individual npm packages. Even if dependencies change, the re-download is quick
- Corepack will use the pnpm version specified by
packageManagerin the workspace rootpackage.json
Feel free to specify the exact version of NodeJS when building. Separating the application into build and run layers can be beneficial for a smaller container size. In my use case, this is not needed as I migrate the database on application startup. This guide will also work for package managers other than pnpm.
Have fun building and deploying!