Back to Blog
Tutorial10 min read2026-07-31

Deploying a Fastify API from a Monorepo Subdirectory

Deploy apps/api from a monorepo without extracting it to a separate repo. The rootDir field, workspace dependencies, and build context isolation in one walkthrough.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Most deployment platforms assume your app lives at the repo root — package.json in the top directory, npm install runs there, done. Monorepos break that assumption. Your Fastify API lives in apps/api/, shares packages from packages/shared/, and has a root-level package.json that defines the workspace. Deploying just the API requires telling the platform where the app actually is, which dependencies are shared, and where the build context starts.

PandaStack handles this with a single field: rootDir (or root-directory in the deploy button URL). Point it at your app's subdirectory, and the build runs from there. This guide deploys a Fastify API from a monorepo, shows how workspace dependencies resolve, and explains what breaks when the build context is wrong.

The monorepo structure

You have a repo like this:

monorepo/
├── package.json          # workspace root
├── apps/
│   └── api/
│       ├── package.json
│       ├── server.js
│       └── routes/
└── packages/
    └── shared/
        ├── package.json
        └── index.js

Root package.json:

{
  "name": "monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}

apps/api/package.json:

{
  "name": "api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "fastify": "^4.26.0",
    "@monorepo/shared": "workspace:*"
  }
}

packages/shared/package.json:

{
  "name": "@monorepo/shared",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js"
}

packages/shared/index.js:

export const getTimestamp = () => new Date().toISOString()

apps/api/server.js:

import Fastify from 'fastify'
import { getTimestamp } from '@monorepo/shared'

const fastify = Fastify({ logger: true })

fastify.get('/health', async () => ({ status: 'ok' }))

fastify.get('/time', async () => ({
  timestamp: getTimestamp()
}))

const start = async () => {
  try {
    const port = parseInt(process.env.PORT || '3000', 10)
    await fastify.listen({ port, host: '0.0.0.0' })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

start()

The API imports @monorepo/shared, which is a workspace package. When you run npm install at the root, npm creates symlinks so apps/api/node_modules/@monorepo/shared points to packages/shared/. That works locally, but deployments need the same setup.

Deploy with rootDir in pandastack.json

Create pandastack.json at the repo root:

{
  "type": "container",
  "name": "fastify-api",
  "rootDir": "apps/api",
  "language": "nodejs",
  "startCommand": "npm start",
  "healthCheckPath": "/health"
}

The rootDir field tells PandaStack:

  1. 1Install dependencies from the root package.json first (to resolve workspaces)
  2. 2Change into apps/api/ before running the build and start commands
  3. 3Set the container's working directory to apps/api/

Push to GitHub and deploy via the dashboard:

git add .
git commit -m "Add Fastify API with workspace dependency"
git push

Go to https://dashboard.pandastack.io/deploy?repo=yourname/monorepo. The deploy screen reads the pandastack.json from the repo root, sees rootDir: "apps/api", and pre-fills the field. Click Deploy.

The build log shows:

Installing dependencies at workspace root...
npm install
Changing to apps/api/...
Running start command: npm start

The workspace install resolves @monorepo/shared correctly, the app starts from apps/api/, and the /time endpoint works because the import resolves.

Deploy via the API with baseDir

The API endpoint uses baseDir instead of rootDir (same meaning, different field name for historical reasons):

curl -X POST https://api.pandastack.io/v1/projects \
  -H "Authorization: Bearer psk_live_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "container",
    "name": "fastify-api",
    "repositoryName": "yourname/monorepo",
    "branch": "main",
    "baseDir": "apps/api",
    "startCommand": "npm start",
    "autoDeploy": true
  }'

The baseDir field does the same thing as rootDir in pandastack.json. The build runs at the root to install workspaces, then changes into apps/api/ before starting the app.

Deploy button with root-directory

For a README button, use the rootDir or root-directory query param (both work):

[![Deploy to PandaStack](https://dashboard.pandastack.io/deploy-button.svg)](https://dashboard.pandastack.io/deploy?repo=yourname/monorepo&rootDir=apps/api&type=container)

The user clicks the button, the form pre-fills with apps/api, and the deploy works. If you have multiple apps in the monorepo (say apps/api and apps/worker), you'd create two buttons with different rootDir values.

What breaks without rootDir

If you omit rootDir and deploy the repo as-is, PandaStack runs npm install at the root, sees a package.json with "private": true and no start script, and fails. The build log shows:

Error: No start command found

Even if you manually set startCommand: "node apps/api/server.js", the app crashes at runtime because @monorepo/shared isn't in the module resolution path. Node.js looks for @monorepo/shared in node_modules/ at the root, doesn't find it (because it's a symlink to packages/shared/), and throws MODULE_NOT_FOUND.

The rootDir field fixes this by running npm install at the root (which creates the workspace symlinks) and then starting the app from apps/api/ (which puts those symlinks in scope).

Dockerfile monorepos (different pattern)

If your monorepo has a Dockerfile, the approach changes. Docker builds don't support rootDir because the Dockerfile defines the build context. Instead, put the Dockerfile in the app's subdirectory and use a multi-stage build:

# apps/api/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY ../../package*.json ./
COPY ../../packages ./packages
COPY apps/api ./apps/api
RUN npm install --workspace=apps/api

FROM node:20-alpine
WORKDIR /app/apps/api
COPY --from=builder /app .
CMD ["npm", "start"]

In pandastack.json:

{
  "type": "container",
  "language": "docker",
  "dockerfilePath": "apps/api/Dockerfile",
  "rootDir": "apps/api"
}

The rootDir sets the build context, so COPY ../../packages resolves correctly. This pattern works for Go, Python, or any language where you control the Dockerfile.

Turborepo and pnpm workspaces

The same rootDir pattern works for Turborepo and pnpm. Turborepo example:

{
  "type": "container",
  "name": "fastify-api",
  "rootDir": "apps/api",
  "buildCommand": "npx turbo run build --filter=api",
  "startCommand": "npm start"
}

Turborepo's --filter=api builds only the API and its dependencies. PandaStack runs that at the workspace root (to resolve the turbo.json config), then starts the app from apps/api/.

For pnpm, no changes needed — the workspace install at the root creates the same symlinks as npm, and rootDir works identically.

When to split the monorepo

Monorepo deploys add complexity: workspace installs are slower, shared package changes trigger rebuilds of all dependent apps, and debugging build failures requires understanding the symlink structure. If your API and frontend share zero code, deploying from separate repos is simpler.

But if you share TypeScript types, validation schemas, or utility functions, the monorepo pays off. One commit updates the shared package and all apps that use it, and rootDir makes deployment as simple as pointing at the subdirectory.

Next steps

You've deployed a Fastify API from a monorepo subdirectory using rootDir in three different ways: the config file, the API, and the deploy button. The same approach works for any monorepo app — Next.js in apps/web, a Python worker in apps/tasks, a database migration script in packages/db-migrate. Just set rootDir to the app's path and let PandaStack handle the workspace resolution.

For production monorepos, consider adding a root-level pandastack.json for each app (e.g., apps/api/pandastack.json) so each app's config is self-contained. The deploy button can still read from the root and merge both configs.

References

  • [Fastify deployment guide](https://fastify.dev/docs/latest/Guides/Deployment/)
  • [npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces)
  • [PandaStack monorepo guide](https://docs.pandastack.io/projects/monorepo)
  • [Turborepo documentation](https://turbo.build/repo/docs)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also