A monorepo is the shape most teams converge on: apps/api, apps/web, apps/admin, and a packages/ folder of shared code. The Blueprint primitives that make this manageable are rootDir (where each service lives) and buildFilter (which file changes should redeploy each service).
Together they give you the property you actually want: apps/web redeploys when apps/web/** or packages/ui/** changes - and stays asleep when apps/api/** changes.
A canonical layout
my-monorepo/├── apps/│ ├── api/ # backend - type: web, runtime: node│ ├── web/ # frontend - type: web, runtime: static│ └── jobs/ # worker - type: worker, runtime: node├── packages/│ ├── shared-types/ # used by api + jobs│ ├── ui/ # used by web only│ └── eslint-config/ # dev-only, never affects builds├── render.yaml # one Blueprint for all services└── pnpm-workspace.yamlOne Blueprint at the root, one rootDir per service, one buildFilter per service that knows about its own dependencies.
rootDir - where the service’s code lives
services: - type: web name: api runtime: node plan: starter rootDir: apps/api buildCommand: pnpm install && pnpm --filter api build startCommand: pnpm --filter api startRender runs the build with apps/api as the working directory. Relative paths (like the start command’s node dist/index.js) resolve from there. Crucially, your build still has access to the whole repo - pnpm install from a workspace root works fine because the repo lives at ...
Pick rootDir to be the deepest directory that still contains everything your build needs to reference. For pnpm/npm workspaces that’s almost always the root of the workspace, not the app folder - so most monorepos either omit rootDir and use filtered build commands, or set rootDir: apps/api and use the whole-repo install plus --filter.
buildFilter - only build when files I care about change
By default, every commit to the deploy branch triggers a build for every service in the Blueprint. In a busy monorepo that’s a lot of wasted minutes (and a lot of wasted preview environments).
services: - type: web name: api runtime: node plan: starter rootDir: apps/api buildFilter: paths: - apps/api/** - packages/shared-types/** - pnpm-lock.yaml ignoredPaths: - apps/api/**/*.test.ts - apps/api/**/*.md buildCommand: pnpm install && pnpm --filter api build startCommand: pnpm --filter api startWalk it:
pathsis the allowlist. A commit triggers a build only if it changes at least one file matching one of these globs.ignoredPathsis the denylist, applied afterpaths. A commit that only changes ignored files doesn’t trigger a build, even if it would otherwise matchpaths.
So a commit changing apps/api/src/index.ts builds the api. A commit changing apps/api/src/index.test.ts doesn’t. A commit changing apps/web/index.html doesn’t either - because nothing under apps/web matches paths.
Don’t forget the lockfile
buildFilter: paths: - apps/api/** - packages/shared-types/** - pnpm-lock.yamlThe pnpm-lock.yaml (or package-lock.json, or yarn.lock) line is easy to miss and easy to feel for. Without it, dependency upgrades won’t trigger rebuilds - your service stays on stale pinned versions until something else in apps/api/** happens to change.
Per-service filters across the whole Blueprint
services: - type: web name: api runtime: node plan: starter rootDir: apps/api buildCommand: pnpm install && pnpm --filter api build startCommand: pnpm --filter api start buildFilter: paths: - apps/api/** - packages/shared-types/** - pnpm-lock.yaml
- type: web name: web runtime: static rootDir: apps/web buildCommand: pnpm install && pnpm --filter web build staticPublishPath: apps/web/dist buildFilter: paths: - apps/web/** - packages/ui/** - pnpm-lock.yaml
- type: worker name: jobs runtime: node plan: starter rootDir: apps/jobs buildCommand: pnpm install && pnpm --filter jobs build startCommand: pnpm --filter jobs start buildFilter: paths: - apps/jobs/** - packages/shared-types/** - pnpm-lock.yamlEach service knows its own dependency footprint. A typo fix in apps/web/components/Button.tsx rebuilds only web. A change in packages/shared-types rebuilds api and jobs but not web. A pnpm-lock.yaml bump rebuilds all three.
How buildFilter interacts with autoDeployTrigger
flowchart LR
commit["Git push"]
trigger{"autoDeployTrigger:<br/>commit / checksPass / off"}
filter{"buildFilter matches?"}
deploy["Build + deploy"]
skip["Skip"]
commit --> trigger
trigger -->|"on / checksPass"| filter
trigger -->|"off"| skip
filter -->|"yes"| deploy
filter -->|"no"| skip
The buildFilter sync footgun
The fix is mechanical: when you edit a buildFilter, edit the whole block, not just the line you care about. Diff-readers and render blueprints validate won’t catch this - it’s only visible when builds start happening that shouldn’t.
When rootDir and buildFilter aren’t enough
Two scenarios that need more than the Blueprint:
- Per-package versioning. If
packages/shared-typesis published independently, you want it to deploy from a different branch than the apps that consume it. That’s a CI concern, not arender.yamlconcern. - Build caching across services. Render builds each service independently - the api and the worker each run
pnpm installfrom scratch. Tools like Turborepo’s remote cache or Nx’s distributed cache can help, but they’re outside the Blueprint surface.
For most teams, rootDir plus per-service buildFilter plus a fast pnpm install is enough. Reach for the heavier tools when you measure deploy times in tens of minutes.
What you learned
- `rootDir` sets the working directory for build/start commands; the build still sees the rest of the repo
- `buildFilter.paths` is the allowlist; `ignoredPaths` filters out matched files. Always include the lockfile in `paths`
- Per-service `buildFilter` lets each service rebuild only when its own code or shared dependencies change
- `buildFilter` interacts with `autoDeployTrigger`: the trigger decides whether to react, the filter decides which services react
- Always send the full `paths` and `ignoredPaths` arrays on sync - omitting them clears the existing filters