all posts

Deploying one service out of a monorepo

Ajay Kumar··8 min read

A monorepo optimises for one thing: making cross-cutting changes easy. One commit updates the shared types, the API that produces them and the web app that consumes them, and CI checks all three together. That's genuinely valuable and it's why people adopt them.

Deployment is where the bill arrives. The same structure that makes local development coherent means a single service can't simply be checked out and built, because it's not self-contained and never was.

Why the naive approach fails

The instinct is to point a deploy at `apps/api` and treat that directory as the project. Four things go wrong, in a fairly reliable order.

  • The lockfile isn't there. It's at the repository root, and it's the only file that pins the entire dependency graph. Installing from `apps/api/package.json` alone resolves fresh versions and you get a build that differs from everyone's laptop.
  • Workspace dependencies don't resolve. `"@acme/shared": "workspace:*"` is meaningless to a package manager that can't see the workspace root, and npm cannot fetch a version literally called `workspace:*` from a registry.
  • Shared packages aren't built. `@acme/shared` is TypeScript source, and `apps/api` imports it expecting compiled output. Build the app alone and you get module-not-found errors for code sitting right there in the repo.
  • The build tool wants the root. Turborepo, Nx and friends read their configuration and dependency graph from the root. Run them from a subdirectory and they either fail or silently skip the graph they exist to manage.

The shape that works

Install from the root, build from the root, run from the subdirectory. Every working monorepo deploy is a variation on that sentence.

# install: from the root, so the lockfile and workspace links are honoured
pnpm install --frozen-lockfile

# build: from the root, so shared packages build before their consumers
pnpm --filter @acme/api... build

# start: from the app directory
node apps/api/dist/server.js

The `...` suffix in the filter is the important character. `--filter @acme/api` builds only that package; `--filter @acme/api...` builds it and everything it depends on, in dependency order. Nearly every 'cannot find module @acme/shared' report is a missing three dots.

# Equivalents in the other toolchains
npm ci && npm run build --workspace=@acme/api   # npm workspaces
yarn install --immutable && yarn workspace @acme/api build
npx turbo run build --filter=@acme/api          # turbo walks deps itself
npx nx build api

On PandaStack this is configured per app: install and build commands run from the repository root, and the start command points into the subdirectory. Two apps in the same repo — an API and a web frontend — are two app records with the same git URL and different commands, which also means they deploy and scale independently.

pandastack apps create --name acme-api --git-url https://github.com/acme/platform \
  --install-cmd 'pnpm install --frozen-lockfile' \
  --build-cmd   'pnpm --filter @acme/api... build' \
  --start-cmd   'node apps/api/dist/server.js'

pandastack apps create --name acme-web --git-url https://github.com/acme/platform \
  --install-cmd 'pnpm install --frozen-lockfile' \
  --build-cmd   'pnpm --filter @acme/web... build' \
  --start-cmd   'pnpm --filter @acme/web start'

Package manager details that bite

Two specifics worth knowing before they cost you an evening.

Pin the package manager version

Lockfile formats change between major versions. A pnpm 9 lockfile handed to pnpm 10, or vice versa, produces either an error or a silent re-resolution — and the silent case is worse, because you get a build that differs from CI for no visible reason.

{
  "packageManager": "pnpm@9.12.0",
  "engines": { "node": ">=22" }
}

The `packageManager` field is respected by Corepack and by most platforms' runtime detection. Alongside a `.nvmrc` or `.tool-versions` for the Node version, it makes the build environment reproducible rather than incidental — our deploy pipeline reads those files through mise, so pinning them is the difference between 'works on the machine that happened to have Node 22' and 'works'.

pnpm blocks build scripts by default

Recent pnpm versions refuse to run postinstall scripts for dependencies unless explicitly approved. That is a sensible security default and it breaks packages with native components — esbuild, sharp, better-sqlite3, Prisma — which need their install script to fetch or compile a binary. The failure surfaces later as a cryptic missing-binary error rather than at install time, which is what makes it hard to trace.

# pnpm-workspace.yaml — approve specific packages rather than all of them
onlyBuiltDependencies:
  - esbuild
  - sharp
  - '@prisma/engines'
Approving the specific packages is much better than globally allowing all build scripts. A postinstall script is arbitrary code execution at install time, and the allowlist is the entire point of the feature.

Deploying only what changed

The other monorepo question: a push touching only `apps/web` shouldn't redeploy the API. Without filtering, every service redeploys on every commit — usually harmless, but wasteful and it makes deploy history useless for debugging.

The honest version of this is harder than a path prefix check, because `apps/api` also changes meaningfully when `packages/shared` changes. What you want is the dependency-aware answer, which the build tools already compute.

# Which packages are affected by this diff, including via shared deps?
npx turbo run build --filter='...[HEAD^1]' --dry-run=json | jq -r '.tasks[].package'

# pnpm's equivalent
pnpm --filter '...[HEAD^1]' list --depth -1 --json | jq -r '.[].name'

Wire that into your CI step so a deploy is triggered only for affected services. The path-prefix shortcut is fine as a first version, but be aware it will eventually skip a deploy that should have happened, and that bug is unpleasant to find — the symptom is a service running against a shared package version it was never built with.

The Docker context problem, and skipping it

Teams containerising a monorepo hit a specific wall: the Docker build context must include the root lockfile and the shared packages, so it's effectively the whole repository. Now every image build ships hundreds of megabytes of context, cache invalidation is terrible because any file change busts the layer, and people end up maintaining `pnpm deploy` prune steps or multi-stage builds that hoist just the right subset.

It's solvable. It's also a lot of machinery to arrange for the privilege of running `node dist/server.js`. Building from source on the target — clone the repo, install from the root, build the filtered target, start the entrypoint — sidesteps the whole context question, because there is no context: the repository is simply present. That's the model our app hosting uses, and monorepos are the case where the difference is most noticeable.

The checklist

  • Install from the repository root with a frozen lockfile. Never from the app subdirectory.
  • Build with a filter that includes dependencies — the `...` matters.
  • Start from the app's output directory.
  • Pin the package manager with `packageManager`, and the runtime with `.nvmrc` or `.tool-versions`.
  • Allowlist the dependencies that genuinely need install scripts, individually.
  • Trigger deploys from the dependency-aware affected set, not a path prefix, once you have more than a couple of services.

None of it is conceptually hard. It's just that the error messages point at the wrong thing — 'cannot find module @acme/shared' sounds like a missing dependency rather than a missing build step three directories up — and that mismatch is what turns a five-minute configuration into an afternoon.

Frequently asked questions

Why does my monorepo app fail with 'cannot find module @acme/shared'?

Because the shared package was never built. It exists in the repo as TypeScript source, and your app imports its compiled output, so building only the app leaves nothing to import. The fix is a build filter that includes dependencies: pnpm --filter @acme/api... build with the three dots, rather than --filter @acme/api. Turborepo and Nx walk the dependency graph automatically. This is the single most common monorepo deploy failure and the message points at a missing dependency rather than the missing build step that actually caused it.

Should I install dependencies from the repo root or the app subdirectory?

Always from the root. The lockfile lives there and is the only thing pinning your complete dependency graph, and workspace protocol references such as "@acme/shared": "workspace:*" are meaningless to a package manager that cannot see the workspace root — npm will try to fetch a version literally called workspace:* from the registry and fail. Install from the root with a frozen lockfile, build from the root with a filter, and only your start command should point into the subdirectory.

How do I deploy only the services affected by a commit?

Use your build tool's affected-package query rather than a path prefix check, because apps/api is genuinely affected when packages/shared changes even though no file under apps/api was touched. Turborepo exposes this as --filter='...[HEAD^1]' and pnpm has an equivalent filter syntax; both walk the dependency graph. A path prefix is a reasonable first version, but it will eventually skip a deploy that should have happened, producing a service running against a shared package version it was never built with.

Why does pnpm skip build scripts and break packages like sharp or Prisma?

Recent pnpm versions refuse to run dependency postinstall scripts unless explicitly approved, since a postinstall is arbitrary code execution at install time. Packages with native components — esbuild, sharp, better-sqlite3, Prisma engines — rely on those scripts to fetch or compile a binary, so they install cleanly and then fail later with a missing-binary error that gives no hint about the cause. Add the specific packages to onlyBuiltDependencies in pnpm-workspace.yaml. Approving individual packages rather than globally allowing all scripts is the whole point of the feature.

Do I need Docker to deploy a monorepo service?

No, and monorepos are the case where avoiding it is most worthwhile. A Docker build context for one service must include the root lockfile and every shared package it depends on, which in practice means the whole repository — so image builds ship large contexts, layer caching invalidates on any file change, and teams end up maintaining prune steps or elaborate multi-stage builds. Building from source on the target instead removes the context question entirely, because the repository is simply present: clone, install from the root, build the filtered target, run the entrypoint.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.