Skip to main content

One Repo, Five Services: Path-Filtered Deploys on Render, Railway, and Vercel — and When to Do It Yourself With the Dependency Graph

11 min readDora NodaDora Noda
Share
On this page

Fix a typo in docs/README.md, push, and watch your platform rebuild five services. The git-push contract that makes a PaaS pleasant — one push, one build, one deploy — quietly assumes one repo means one service. The moment you adopt a monorepo, that assumption breaks: a five-service repo turns every push into five builds unless something decides which services a commit actually touched.

Every hosted platform grew a bespoke answer to that question, and they are not interchangeable. Here is the landscape in one table, before we take each mechanism apart:

Render build filtersRailway watch pathsVercel ignored build stepVercel skip-unaffected
Where config livesPlatform (dashboard / render.yaml)Platform (service settings)Platform (project settings, runs your script)Platform (automatic)
Pattern syntaxGlobs (**, [abc], [^abc])gitignore-style, ! negationAny command; exit code decidesNone — computed
Default (no config)Root directory scopes autodeploys; otherwise every push buildsEvery push buildsEvery push buildsOn by default for JS workspaces on GitHub
Precedence ruleIgnored paths beat included pathsNegations only apply after an including ruleExit 0 cancels, exit 1 buildsGlobal changes rebuild everything
Knows your dependency graph?NoNoOnly if your script doesYes (from package.json workspaces)
Characteristic failureShared library no filter listsSame, plus silent no-match skipsCanceled builds still consume quotaUndeclared edges are invisible

The short version: Render and Railway ask you to describe your dependency graph by hand, as glob patterns, in platform config. Vercel offers both a script escape hatch and the only genuinely graph-aware option of the three — with sharp boundaries. And build-graph tools like Turborepo and Nx argue the question should never have left the repo at all. Let's take them in order.

Render: Build Filters and the Ignore-Wins Rule

Render's unit of scoping is the root directory: set one per service and, per Render's docs, "Render only triggers an autodeploy if your changes affect files anywhere under that directory." Files outside it aren't even available at build or runtime. For an isolated monorepo — five services, five directories, no shared code — this alone solves the problem.

For everything else there are build filters, two lists of globs per service:

  • Included paths: changes matching an included path trigger an autodeploy — and once you specify any included path, everything that doesn't match is ignored.
  • Ignored paths: changes matching an ignored path never trigger an autodeploy, "even if those files also match an included path."

That last clause is the precedence rule to memorize: ignore beats include. Globs support ?, *, **, character classes like [abc], and negated ranges like [^abc].

Two gotchas hide in the fine print. Changes to a Blueprint file (render.yaml) are always processed regardless of filters — a sensible always-build carve-out for platform config. And build filters only govern autodeploys: a manual deploy from the dashboard bypasses them entirely, which is your escape hatch when a filter wrongly skipped a service.

Railway: Watch Paths, gitignore Style

Railway's answer is watch paths: "gitignore-style patterns that can be used to trigger a new deployment based on what file paths have changed." Set /packages/backend/** on the backend service and a frontend-only commit skips it; "any changes that don't match the patterns will skip creating a new deployment."

The syntax reads like a .gitignore, with one caveat that bites people: negations only work if you include files in a preceding rule. So !**/*.md on its own does nothing — you need an including pattern first, then the negation carves out of it:

text
/**
!**/*.md

Railway leans harder on automation at setup time: importing a JavaScript monorepo auto-detects workspaces (npm, yarn, pnpm, bun) and stages a service per deployable package, pre-filling watch paths along with build and start commands. It also distinguishes isolated monorepos (root directory per service, only that directory is pulled) from shared ones (workspaces building from the repo root). One sharp edge: railway.json/railway.toml paths don't follow the root directory — they're resolved from the repo root, so a service rooted at /backend needs /backend/railway.toml, not ./railway.toml.

Structurally, though, watch paths and build filters are the same mechanism with different syntax: a hand-written path predicate, stored in platform config, evaluated per service per commit.

Vercel: Two Mechanisms, One Exit Code — and the Only Graph-Aware One of the Three

Vercel splits the problem in two.

The older mechanism is the Ignored Build Step: a command that runs when a deployment enters the BUILDING state, executed in the project's root directory with access to system environment variables. The contract is a single exit code: exit 1 and the build continues; exit 0 and the build is aborted, with the deployment marked CANCELED. Presets cover the common cases — "only build if there are changes in a folder" is Render-build-filters-by-script — but the field accepts anything: a bash script, a Node script, npx nx-ignore my-app, or turbo query for Turborepo users.

The catch is in the accounting: "canceled builds are counted as full deployments as they execute a build command in the build step." A skipped-by-script build still occupies a concurrent build slot and counts against deployment quotas. On a five-service repo, one push can consume five build slots to produce one deploy.

That's why the newer mechanism matters: skipping unaffected projects. Vercel computes it for you, treating a project as changed if its source changed, any of its internal dependencies changed, or a lockfile change impacts its dependencies specifically. Skipped projects never enter the queue and "do not occupy concurrent build slots." This is real dependency-graph awareness — the only instance of it among the three platforms' built-in filters.

Its boundary is equally real, and worth quoting almost verbatim from the requirements: GitHub repositories only; npm/yarn/pnpm/bun workspaces following JavaScript conventions; every package with a unique name; and — the load-bearing one — "dependencies between packages in the monorepo must be explicitly stated in each package's package.json." Changes outside the workspace definition "will be considered global changes and deploy all applications in the repository."

Hold that thought; it's the hinge of the whole comparison.

Where Path Filters Break: the Shared Library and the Lockfile

Scope this section precisely: it applies to the static filters — Render build filters, Railway watch paths, and Vercel's folder-diff ignored-build-step presets. They all share two failure modes, one that under-builds and one that over-builds.

Failure mode 1: the shared library nobody's filter lists. Take the canonical five-service layout:

text
apps/web          → service 1  (filter: apps/web/**)
apps/api          → service 2  (filter: apps/api/**)
apps/worker       → service 3  (filter: apps/worker/**)
apps/admin        → service 4  (filter: apps/admin/**)
apps/docs-site    → service 5  (filter: apps/docs-site/**)
packages/ui       → imported by web and admin
packages/schema   → imported by api and worker

A commit that fixes a rendering bug in packages/ui matches no service's filter. All five services skip the deploy — silently, successfully, and wrongly. web and admin keep serving the bug until an unrelated commit rebuilds them, at which point the fix ships bundled with something else and nobody can say which deploy changed what. The failure is invisible precisely because skipping looks like the feature working.

The manual fix is to add packages/ui/** to web's and admin's filters — which means the platform config now duplicates the import graph, by hand, with no compiler checking it. Add one import { Button } from "@repo/ui" to worker and forget to update the dashboard, and the drift begins.

Failure mode 2: the lockfile. A root pnpm-lock.yaml changes on nearly every dependency bump. A static filter gives you exactly two options: leave the lockfile unmatched (a security patch to the api service's HTTP client rebuilds nothing — under-build), or add the lockfile to every service's filter (every devDependency bump in docs-site rebuilds all five services — over-build). The right answer — rebuild only the services whose resolved dependencies changed — requires reading the lockfile diff against the dependency graph, which a glob cannot do.

There's a third, quieter cost: the filters live in platform config, not the repo. They don't show up in the PR diff, nobody reviews them, and they can't be tested in CI. Your deploy topology is state, not code.

The carve-out — and its edge. Vercel's skip-unaffected genuinely dodges both failure modes inside its declared boundary: packages/ui is a workspace package with declared dependents, so a change to it rebuilds exactly web and admin; lockfile changes are analyzed per project. But the boundary is the declared JavaScript graph, full stop. A Go service reading a shared proto/ directory has no package.json edge to it. Generated code that a service consumes but doesn't declare is invisible. An e2e package that forgets to list package-core in its package.json won't rerun when core changes — Vercel's own docs call this requirement out explicitly. And anything outside the workspace definition falls back to "global change, rebuild everything," which is safe but puts you right back at five builds per push.

The general rule both halves of this section point to: static filters approximate the dependency graph by hand and drift from it; graph-aware skipping is exactly as good as the graph you declare — and no better.

Doing It Yourself: turbo-ignore and nx affected

Build-graph tools answer the same question — did this commit affect this service? — from inside the repo, where the graph actually lives.

Turborepo's turbo-ignore was built for exactly the Vercel exit-code contract: run npx turbo-ignore as the ignored build step, and it diffs against the parent commit (on Vercel, the previously deployed SHA when available), walks the workspace graph, and exits 1 (build) if the package or anything it depends on changed, 0 (skip) otherwise. Tellingly, turbo-ignore is now deprecated in favor of turbo query — the affected-detection logic got promoted from a Vercel-integration shim into a first-class, queryable API over the build graph, precise down to the task level.

Nx generalizes the same idea: nx affected -t build --base=origin/main --head=HEAD maps changed files to projects via the project graph, then transitively includes dependents. Its lockfile handling shows how mature the in-repo answer has become: by default "every project in the workspace is marked as affected when the lock file changes" (conservative, correct, expensive), but setting projectsAffectedByDependencyUpdates to "auto" parses the lockfile diff so "only projects whose dependencies actually changed" rebuild — the exact discrimination the glob couldn't make.

The pattern to steal is the division of labor: the repo owns the graph (checked in, code-reviewed, verified by the package manager on every install), and the platform owns a dumb contract — run this command, honor its exit code.

What a Self-Hosted, Render-Compatible PaaS Should Implement

If you run the platform yourself, five wasted builds per push don't show up as an invoice line — they show up as build-queue latency for the deploy that mattered, cache churn, and CPU-hours on hardware you pay for by the watt. Call a typical service build four minutes: at twenty pushes a working day, filtering five builds down to the one affected service is the difference between roughly 6.5 build-hours and 1.3 — per day, on your own machines. The economics are smaller than a SaaS bill but the physics are the same, so a self-hosted, Render-compatible platform — the category Bex sits in — needs the full ladder:

  1. Path filters as the compatible baseline. Per-service root directory plus included/ignored globs with Render's semantics — ignore beats include — so a render.yaml written for Render behaves identically. Filters belong in the checked-in config file, not a dashboard: your deploy topology should survive PR review.
  2. The exit-code escape hatch. A per-service check command with Vercel's contract (exit 0 skip, exit 1 build) so turbo query, nx affected, or ten lines of git diff --quiet can own the decision. This one mechanism subsumes every filter feature the platforms have shipped — but a skipped check must cost a shell invocation, not a build slot.
  3. Always-build carve-outs. Platform config changes (the render.yaml rule) and lockfile changes with no smarter handler configured must default to rebuilding — silently stale services are strictly worse than redundant builds.
  4. Skip loudly. Every skipped service should record which rule skipped it. The shared-library failure stayed invisible because skipping and succeeding looked identical; a deploy log line per skip is the cheapest possible fix.

Who Owns the Graph

Strip away the syntax differences and the three platforms are giving three answers to one question: who owns the dependency graph? Render and Railway say the platform does, expressed as globs you maintain by hand. Vercel says the platform can infer it — if you stay inside JavaScript workspaces on GitHub. Turborepo and Nx say the repo owns it, and the platform should just ask.

The repo is winning that argument, and it should: the graph is already there, in every package.json and lockfile, verified on every install. The platforms' path filters are hand-drawn maps of territory the repo surveys precisely. A git-push platform's job — hosted or self-hosted — is shrinking to a well-defined contract: honor an exit code, carve out the always-build cases, and say out loud what it skipped and why.


Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex