Render Tutorials
Advanced render.yaml Blueprint patterns

Monorepos and buildFilter

⏱ 7 min

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.yaml

One 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

render.yaml
services:
- type: web
name: api
runtime: node
plan: starter
rootDir: apps/api
buildCommand: pnpm install && pnpm --filter api build
startCommand: pnpm --filter api start

Render 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).

render.yaml
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 start

Walk it:

  • paths is the allowlist. A commit triggers a build only if it changes at least one file matching one of these globs.
  • ignoredPaths is the denylist, applied after paths. A commit that only changes ignored files doesn’t trigger a build, even if it would otherwise match paths.

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

render.yaml - paths excerpt
buildFilter:
paths:
- apps/api/**
- packages/shared-types/**
- pnpm-lock.yaml

The 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

render.yaml
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.yaml

Each 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-types is published independently, you want it to deploy from a different branch than the apps that consume it. That’s a CI concern, not a render.yaml concern.
  • Build caching across services. Render builds each service independently - the api and the worker each run pnpm install from 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.

Your `render.yaml` has services `api`, `web`, and `jobs` with the per-service buildFilters from the example above. A teammate pushes a commit that changes only `packages/shared-types/src/User.ts`. Which services rebuild?

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