Deploying a monorepo

To deploy a monorepo, build and package only the app you are shipping, together with the workspace packages it depends on. Tools like pnpm deploy or turbo prune produce that self-contained folder.

Updated 6 min read

Learning objectives

After reading this article you will be able to:

  • Understand why a monorepo cannot be deployed like a single-package project
  • Produce a self-contained folder for one app with pnpm deploy or turbo prune
  • Automate the build and deploy of each app on every push
On this page

A monorepo keeps several projects in one repository: an API, a website, a bot and the shared code between them. That is convenient while you develop, because a single change can touch a library and every app that uses it. At deploy time it gets awkward: a server wants one app, not the whole tree.

The core problem is that your app doesn't stand alone. It imports packages/shared, which exists only inside the repo. If you zip apps/api by itself, the import breaks. If you zip everything, you upload and install far more than the app needs. Deploying a monorepo means cutting out exactly the right slice.

my-repo/apps/apipackages/sharedapps/webpnpm-lock.yamlpnpm deployout/apiapishared+ productionnode_modulesout/webwebshared+ productionnode_modules
Each app becomes its own folder with its code and the workspace packages it imports, such as shared.

What is different about deploying a monorepo?#

In a single-package project, the folder you deploy is the repository. In a monorepo, three things change:

  • Local dependencies. Workspace packages are linked with the workspace: protocol, which pnpm resolves to your local folder and never to the npm registry. The deployed folder must contain them.
  • One lockfile at the root. Versions are pinned for the whole repo, not per app.
  • Many apps, one repository. A push can change one app, several or none, and you usually want to redeploy only those affected.

The goal is a self-contained folder per app: the app's code, its built workspace dependencies and its production node_modules. If you are new to the idea of shipping code to a server, read what a deployment is first.

What does the repo look like?#

This guide uses a common layout with pnpm workspaces:

text
my-repo/
  apps/
    api/            # the app we deploy
      package.json
      src/index.ts
  packages/
    shared/         # imported by api
      package.json
      src/index.ts
  package.json
  pnpm-workspace.yaml
  pnpm-lock.yaml

The workspace file tells pnpm where the packages live:

yaml
packages:
  - apps/*
  - packages/*

And apps/api/package.json depends on the shared package through the workspace protocol:

json
{
  "name": "api",
  "private": true,
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p .",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "shared": "workspace:*"
  }
}

How do you build only one app?#

Use pnpm's --filter flag. Appending ... to the name selects the package and everything it depends on, directly or indirectly:

bash
pnpm install --frozen-lockfile
pnpm --filter "api..." build

pnpm builds shared first and api after it, in dependency order. Packages the API never imports are skipped. With Turborepo, the equivalent is turbo run build --filter=api..., and it also caches the results between runs.

For this to work, shared needs its own build script and a main or exports field that points at its compiled output. A workspace package that exports raw TypeScript usually works in development but can fail in a plain Node.js runtime.

How do you produce a deployable folder with pnpm deploy?#

pnpm deploy copies one package and its production dependencies into a target directory, resolving workspace packages along the way:

bash
pnpm --filter api --prod deploy out/api

--prod skips devDependencies, which is what you want after the build. By default the command copies every file of the package, but it honors the files field first, then .npmignore, then .gitignore. If dist is in .gitignore and there is no files field, the build output is left behind, which is why both api and shared should declare "files": ["dist"]. The target folder must be empty, or pnpm fails with ERR_PNPM_DEPLOY_DIR_NOT_EMPTY unless you pass --force. Since pnpm v12.2.0 the command no longer requires the injectWorkspacePackages setting. If the deploy fails with ERR_PNPM_DEPLOY_AMBIGUOUS_PEER, enabling that setting or pinning the peer dependency with overrides fixes it.

Check the result before shipping it:

bash
cd out/api
node dist/index.js

If the app starts from that folder alone, the folder is self-contained.

How do you prune with Turborepo instead?#

turbo prune is built for Docker and CI. It copies the app, the workspace packages it needs and a matching lockfile:

bash
npx turbo prune api --docker

With --docker, the output in out/ is split in two so installs can be cached separately from source changes:

  • out/json/ holds only the package.json files.
  • out/full/ holds the source files.
  • The pruned lockfile sits at the root of out/.

A typical build copies out/json/ first, installs dependencies, then copies out/full/ and builds. Because the install layer only changes when a package.json changes, repeated builds stay fast. The same pattern applies if you want to dockerize the app.

How do you deploy only the apps that changed?#

Rebuilding every app on every push wastes time. Two common fixes:

  1. Path filters in CI. A GitHub Actions workflow can trigger only when files under apps/api/** or packages/shared/** change.
  2. Task-graph filtering. Turborepo can build only what a change affects, for example turbo run build --filter=...[HEAD^1] (packages changed since the previous commit, plus their dependents). The checkout needs enough Git history for that comparison.

Here is a workflow that deploys the API when its code or its shared package changes. It builds, packages the app with pnpm deploy and leaves the upload to your deploy command:

yaml
name: Deploy API

on:
  push:
    branches: [main]
    paths:
      - "apps/api/**"
      - "packages/shared/**"
      - "pnpm-lock.yaml"

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: pnpm/action-setup@v6 # reads the version from "packageManager" in package.json
      - uses: actions/setup-node@v7
        with:
          node-version: lts/*
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter "api..." build
      - run: pnpm --filter api --prod deploy out/api
      # Add your deploy command here, run from out/api

Remember to list the lockfile and shared packages in paths. Otherwise, a change in a library won't redeploy the apps that use it. For the full pattern of secrets and gated deploys, see automatic deploys with GitHub Actions.

What are the common mistakes?#

  • Missing workspace package. The app crashes with Cannot find module 'shared' because the package was not built or not copied. Build with the ... filter and deploy with pnpm deploy.
  • Running from the repo root. The start command must run from the packaged folder, not the root, or relative paths break.
  • Dev dependencies in production. Forgetting --prod inflates the upload and the memory the app uses.
  • Stale lockfile. Use --frozen-lockfile in CI so a mismatch fails the build instead of installing different versions.
  • Shared secrets. Keep environment variables per app. A secret placed at the repo root easily leaks into apps that don't need it. See what environment variables are.

How do you tell the deploy worked?#

Check three levels: the start command works inside the packaged folder, the build log shows both shared and api compiled, and the running app logs its startup line without module errors. After the first successful deploy, change a line in packages/shared, push, and confirm the app picks up the change. That proves the dependency graph is wired correctly, not just the app itself.

How to do this on Square Cloud#

On Square Cloud, you deploy with a ZIP upload in the dashboard, the CLI, GitHub (through the official GitHub Action or a webhook) or the VS Code extension. There is no root-directory option: whichever method you pick, treat each app in your monorepo as its own application and send the self-contained folder built above, after checking that the app starts from it. The platform has no build step and only installs dependencies on start, so run the build in CI or in the start command.

The squarecloud.app configuration file (name, main file, memory, version) is optional: the dashboard generates it at deploy time, or you can write it by hand at the project root. Environment variables are set per application in the dashboard, the API or the CLI, which fits the per-app secrets advice above. Your plan's RAM is a shared pool across applications, with a minimum of 256 MB for a background app such as a bot and 512 MB for a web app.

To automate it, the official GitHub Action runs a CLI command on every push, release or manual run, and only needs an API key stored as a secret. Start with the Square Cloud CLI.

Deploy

Push from GitHub, the CLI or a ZIP and go live in seconds

Learn more

FAQs

Do I have to deploy the whole monorepo?

No. You deploy one app at a time, and each app only needs its own code plus the workspace packages it imports. Pruning the repo down to that subset keeps uploads small and installs fast.

What does the workspace: protocol do?

It tells pnpm to resolve a dependency from the local workspace and never from the npm registry. Because the package only exists inside your repo, the deployed folder must include it.

What is the difference between pnpm deploy and turbo prune?

pnpm deploy copies one package and its production dependencies into a target folder. turbo prune copies the package, the workspace packages it needs and a matching lockfile, so you can install and build elsewhere.

Can I deploy two apps from the same repo?

Yes. Run the build and package steps once per app, and send each result to its own application on the server. Path filters in your CI keep a change in one app from redeploying the other.

Next articlePM2 and auto-restart: keeping your process aliveUse PM2 to restart your Node.js process when it crashes, runs out of memory or the server reboots. Covers the ecosystem file, restart limits and Docker.

More in Deploy

See all 33 articles