Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Deploying a Single Package with pnpm deploy

Deploying one application from a pnpm monorepo raises an awkward question: how do you ship that application with exactly its production dependencies — including internal workspace packages — without copying the whole repository and its symlinked node_modules into a container? pnpm deploy answers it. It takes one workspace package, resolves its dependency closure from the lockfile, and writes a self-contained folder with real files instead of workspace symlinks, ready to copy into a Docker image or upload to a serverless platform. This guide shows how it works, the pnpm 10 behaviour change around injected dependencies, and a production Dockerfile built on it.

The problem with copying a workspace

Workspace packages link to each other through symlinks, and pnpm's node_modules is a tree of symlinks into a content-addressed store. Copy apps/api into a container on its own and its dependencies either point outside the copied folder or are missing entirely:

Error: Cannot find module '@acme/db'
Require stack:
- /app/dist/server.js

# or, after copying node_modules along with it
Error: ENOENT: no such file or directory, stat '/repo/node_modules/.pnpm/zod@3.24.1/node_modules/zod'

Copying the entire repository works but bloats the image with every other application's dependencies and source. How pnpm lays out workspaces is covered in Workspace Symlinks vs Hard Links.

What pnpm deploy produces

pnpm deploy selects one package (with --filter), installs only its production dependencies into a target directory, and copies workspace dependencies in as real packages — their published file set, as if installed from a registry.

pnpm deploy from workspace to deployable folder The filter selects the api package, pnpm resolves its production dependency closure from the lockfile, packs workspace dependencies as real packages, and writes a self-contained output folder. --filter @acme/api select the package to deploy resolve closure prod deps from pnpm-lock.yaml inject workspace deps @acme/db copied, not symlinked /out/api package.json, dist, node_modules
The output folder contains no workspace symlinks, so it can be copied anywhere on its own.

Running it

# Build first — deploy copies files, it does not build them
pnpm --filter @acme/api... run build

# pnpm 10: deploy requires injected workspace packages
pnpm --filter @acme/api deploy --prod /out/api

ls /out/api
# dist/  node_modules/  package.json

In pnpm 10, pnpm deploy expects the workspace to use injected workspace packages, so that the lockfile already describes the copied layout. Enable it in the workspace configuration:

# pnpm-workspace.yaml
injectWorkspacePackages: true

or run with --legacy to use the pnpm 9 behaviour, which computes the layout at deploy time:

pnpm --filter @acme/api deploy --prod --legacy /out/api

Injected packages change local development slightly: workspace dependencies are hard-linked copies rather than symlinks, so changes to @acme/db are propagated by pnpm on install (or pnpm install after a build) rather than being visible instantly. Many teams accept --legacy for deploy steps rather than change the development layout; evaluate both.

Ways to ship one package from a pnpm monorepo Compares copying the whole repo, pnpm deploy, turbo prune and bundling into a single file on image size, workspace dependency handling and effort. image size workspace deps effort copy whole repo largest work as-is none pnpm deploy prod closure only copied in one command turbo prune --docker pruned workspace kept as workspace build in image bundle to one file smallest inlined native deps are hard
pnpm deploy produces the smallest runnable folder without a bundler; turbo prune suits builds that happen inside Docker.

A production Dockerfile

Build in one stage, deploy into a folder, and copy only that folder into the runtime image:

FROM node:22-slim AS build
WORKDIR /repo
RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
RUN pnpm fetch                                   # download all packages using only the lockfile
COPY . .
RUN pnpm install --offline --frozen-lockfile
RUN pnpm --filter @acme/api... run build
RUN pnpm --filter @acme/api deploy --prod --legacy /out/api

FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /out/api ./
USER node
CMD ["node", "dist/server.js"]

pnpm fetch downloads packages into the store using only the lockfile, so that layer is cached until dependencies change — code changes do not invalidate it. The runtime stage contains only the deployed folder: no source, no dev dependencies, no other applications.

What gets copied for workspace dependencies

Injected workspace dependencies are copied using their files field and publish rules, exactly as if they were packed. That has two consequences:

  1. Workspace packages must be built before deploying, and their exports must point at built output, not source. A workspace package whose main is src/index.ts will be copied with src/, and the runtime will fail to load TypeScript.
  2. The files allowlist matters for internal packages too. If @acme/db excludes its migrations/ folder from files, the deployed API will not have its migrations. See Choosing Between the files Field and .npmignore.

Native dependencies and the target platform

pnpm deploy installs dependencies for the platform it runs on. If you deploy on a macOS laptop and copy the folder into a Linux container, native packages — sharp, bcrypt, @prisma/client engines, esbuild — contain macOS binaries and fail at runtime with errors about invalid ELF headers or missing platform packages. Always run pnpm deploy inside the same OS, CPU architecture and C library as production, which the multi-stage Dockerfile above does naturally. For Alpine-based runtime images, use an Alpine build stage too, so musl builds are selected.

When deploying from CI to a different platform is unavoidable — for example, building an arm64 serverless bundle on an x64 runner — use pnpm's supportedArchitectures setting to install the target platform's optional packages, as described in Fixing Missing Platform Binaries in optionalDependencies, and verify the result on the target platform before release.

Serverless and other upload targets

The deployed folder is equally useful outside Docker. Serverless platforms that accept a zip of a Node.js project can take /out/api directly, and because it contains only production dependencies, it stays well under typical package size limits. Add a .zip step after pnpm deploy, and keep the handler path relative to the folder root (dist/handler.js). For platforms with strict size limits, combine pnpm deploy with pruning of files the runtime never reads — test directories, TypeScript sources, documentation — shipped by third-party packages. Tools exist for this, but a conservative find /out/api/node_modules -name "*.md" -delete style step already saves a surprising amount; measure before and after, and run the smoke test on the pruned output.

Debugging a deployed folder

When the deployed application fails at startup, inspect the folder directly rather than the workspace. Three checks cover most problems:

# Are workspace dependencies real directories, not symlinks into the repo?
find /out/api/node_modules/@acme -maxdepth 1 -type l

# Do internal packages contain built output?
ls /out/api/node_modules/@acme/db/dist || echo "@acme/db has no dist/"

# Does the app start with nothing but the folder?
cd /out/api && NODE_ENV=production node dist/server.js --check

Any symlink found by the first command points outside the folder and will break in the container; it usually means an old pnpm version or a link: dependency that deploy cannot inject. Missing dist/ directories mean the build step did not cover every workspace dependency — build with the ... suffix (--filter @acme/api...) so dependencies are built first.

Worked example: shrinking an API image

A team's API image is 1.4 GB because the Dockerfile copies the whole repository, including three front-end applications and their dependencies, then runs pnpm install. Switching to the two-stage build above with pnpm deploy produces a 210 MB image containing only the API's production dependencies and two internal packages. The first attempt fails at runtime with Cannot find module '/app/node_modules/@acme/config/src/index.ts': @acme/config pointed its exports at source. Adding a build step and dist exports to that package fixes it, and a smoke test that starts the container and hits a health endpoint is added to CI.

API image size by packaging approach Example image sizes for copying the whole repository, pruning with turbo prune, and deploying with pnpm deploy. copy whole repo + install 1400 MB turbo prune + prod install 390 MB pnpm deploy --prod 210 MB
Deploying only the production closure cuts the image to a fraction of a whole-repository copy.

Prevention and CI/CD guardrails

  • Build before deploy, including every workspace dependency of the deployed package.
  • Point internal packages' exports at built output if they are deployed as runtime dependencies.
  • Smoke-test the deployed folder by starting it with plain node in CI before building the image.
  • Cache pnpm fetch as its own Docker layer keyed on the lockfile.

Frequently Asked Questions

Does pnpm deploy run the build? No. It copies files. Run the build for the package and its workspace dependencies first.

Can I deploy several packages at once? One target folder per package. Run pnpm deploy once for each application you ship.

Why does pnpm 10 require injected workspace packages? So that the deployed layout is already described by the lockfile, making deploys reproducible from the lockfile alone. --legacy keeps the older behaviour if you prefer symlinked workspace packages during development.

Does the deployed folder include the lockfile? Recent pnpm versions write a pnpm-lock.yaml for the deployed package into the target folder when injected workspace packages are enabled, which lets you run a frozen install there if needed. With --legacy, the folder contains the installed node_modules only.

Can I use pnpm deploy for packages that are published to npm? You can, but publishing is a separate path: pnpm publish packs the package for the registry, while pnpm deploy produces a runnable folder with dependencies. Use deploy for applications and services, publish for libraries.

What about development dependencies needed at runtime? If a package needs something at runtime, it belongs in dependencies. --prod omits devDependencies, so a runtime import of a dev dependency fails in the deployed folder — which is the smoke test doing its job.

Related

pnpm Workspace Filtering