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
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.
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:
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.yamlThe workspace file tells pnpm where the packages live:
packages:
- apps/*
- packages/*And apps/api/package.json depends on the shared package through the workspace protocol:
{
"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:
pnpm install --frozen-lockfile
pnpm --filter "api..." buildpnpm 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:
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:
cd out/api
node dist/index.jsIf 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:
npx turbo prune api --dockerWith --docker, the output in out/ is split in two so installs can be cached separately from source changes:
out/json/holds only thepackage.jsonfiles.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:
- Path filters in CI. A GitHub Actions workflow can trigger only when files under
apps/api/**orpackages/shared/**change. - 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:
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/apiRemember 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 withpnpm 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
--prodinflates the upload and the memory the app uses. - Stale lockfile. Use
--frozen-lockfilein 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
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.
More in Deploy
See all 33 articles- What are environment variables?Environment variables are key-value pairs the operating system hands to a process at startup. Learn to read them in Node.js and Python and keep tokens out of code.
- What is a deployment?A deployment takes a version of your code to a server, prepares it and starts it. See the stages, the methods and how to roll back when it goes wrong.
- How much RAM and CPU does my app need?Size RAM and CPU by measuring your app's real peak and adding about 30% headroom. Learn how to measure in Node.js, Python and Java and what to do if short.
- Automatic deploys with GitHub ActionsAutomatic deploys with GitHub Actions ship your code to the server on every push. See the workflow step by step, secrets, tests before deploy and common errors.