How to Ship a pnpm Monorepo to Docker Without a 30-Minute Build
The Docker-build strategy for a pnpm monorepo that keeps cold builds under five minutes and incremental builds under thirty seconds. The workspace-aware Dockerfile, the lockfile pinned install, and the deploy trap that bit us on day one.
The first deploy of a pnpm monorepo to production is when the team learns what a "short Dockerfile" actually means. A naive Dockerfile rebuilds the entire lockfile on every CI run. We have watched a 10-minute appleboy/ssh-action default timeout get eaten by a cold build. Here is the pattern we settled on, and the trap that bit us on day one.
The workspace-aware Dockerfile
Two non-negotiable design rules for a monorepo Dockerfile:
- Context is the monorepo root. The build context must be the workspace root, not
apps/backendorapps/web. The packages reference each other across the workspace; the install has to see all the lockfile and workspace declarations. - The lockfile is the contract. The same
pnpm-lock.yamlthat the developer ran locally is the one the CI installs. We pin tofrozen-lockfile=truein Docker and in CI for that reason.
The backend Dockerfile pattern:
- A builder stage that uses the full
node:20-alpineimage and runspnpm install --frozen-lockfileonce. - A runner stage that copies only
apps/backend/dist,node_modulesfor the runtime, andapps/backend/package.json. No source, no test fixtures, no build tooling.
The web Dockerfile pattern (separate concerns, different stage):
- A builder stage that compiles Next.js with
output: "standalone"and that standalone output already includes anode_moduleswith only the runtime dependencies. - A runner stage that copies the standalone output and the public assets, then runs
node server.js.
The deploy trap that bit us on day one
Our first deploy attempt was the classic mistake: each app has its own Dockerfile, each Compose file uses the build context for the app in question, and the production Compose file points at a different project name than the dev one. Two processes ended up bound to the same host port.
The fix:
- Single build context per app. The Dockerfile build context is always the monorepo root. The apps use
apps/web/Dockerfileandapps/backend/Dockerfilefiles, but the context is. - Explicit Compose project name. Production Compose uses
-p 2runbe-monorepoto namespace the containers away from any other apps that might be on the same host. Without this, two Compose files can claim the same port silently. - The Compose external network. Production runs the backend and web on a shared
fenix-shared-servicesnetwork alongside other apps that share the same MongoDB and Redis instances. Local dev runs its own compose on theappnetwork with the--profile fullprofile booting its own MongoDB and Redis.
Lockfile drift detection
Two ways lockfile drift catches you:
- A developer updates
pnpm-lock.yaml, the CI runs against the old Dockerfile that copies it from a fixed location, and the install fails because the lockfile is newer than the dependency tree. - A second developer runs
pnpm installlocally without the frozen lockfile and gets a slightly different lockfile than the CI.
We lock both ends:
- Pre-commit hook runs
pnpm install --frozen-lockfileand refuses to commit if it fails. - CI uses frozen lockfile as well. If the developer bypassed the hook, the CI catches it before the deploy.
Cold vs incremental builds
With the workspace-aware Dockerfile and pinned lockfile, the cold build times are:
- Backend. ~3 minutes on a 4-core runner. Most of the time is
pnpm installover the full workspace. - Web. ~4 minutes. Next.js builds dominate once
pnpm installis cached.
The incremental build times (cache hit on pnpm install):
- Backend. ~25 seconds for a single dependency upgrade, ~40 seconds for a NestJS source change.
- Web. ~30 seconds for a single source change, ~90 seconds for a dependency upgrade.
The CI cache strategy that made this work:
- Cache
pnpm storekeyed on the full lockfile hash. A change to any package bumps the cache key, but unrelated changes hit the cache. - Cache
node_moduleskeyed per-app on the lockfile plus the workspace package.json. Two apps with the same lockfile share node_modules when the dependency tree allows.
Bumping the CI command timeout
The appleboy/ssh-action default command timeout is 10 minutes. A backend cold build with a fresh lockfile takes ~10 minutes on a slow runner, which is right at the failure threshold. Bump the timeout to 30 minutes for any deploy step that involves a Docker build. The deploy script that timed out at 9 minutes 50 seconds is a deploy script you have to debug at 11pm.
