Why a single root migrate script breaks down in an Nx monorepo
Most workspaces start with one migrate script in the root package.json that loops over every schema, or calls a single tool against a shared migrations folder. It works while there is one service. Once there are several, it hides how the system is actually built.
The pattern this article covers is simple. A database migration is an explicit Nx target, and the project that owns the schema owns that target. If orders-api owns the orders schema, the migrations and the target that applies them live in orders-api. Nx then treats them like any other task. They appear in the project graph, they can be ordered with dependsOn, and they can be filtered by what changed.
The root script fails on three counts:
- Ownership is invisible. A root script makes no claim about which service owns which tables. Reviewers can't tell whether a migration belongs with the code that reads the schema, and CODEOWNERS rules can't be scoped to it.
- Affected runs stop working. Nx can only skip work it can attribute to a project. A root-level script sits outside the graph. A change to one service's schema therefore runs every service's migrations, or none of them.
- Unrelated deploys get coupled. If one service has a broken migration, the shared script blocks the deploy of every other service, even services whose schemas are untouched.
This approach suits backend and platform teams with several services in one Nx workspace, where each service owns its own schema.
Defining per-service migration targets in project.json
Each service owns its schema, so its migration commands should live in that service's project.json. The nx:run-commands executor is enough here. Each target either sets cwd to the service root, such as apps/orders, or passes --schema with an explicit path to that service's schema.prisma. Use one approach consistently. Without either, Prisma resolves the schema relative to wherever the command runs, and a target invoked from the workspace root can pick up the wrong file or none at all.
A typical service defines four targets:
| Target | Command | Database touched | Runs in CI/deploy |
|---|---|---|---|
migrate-dev |
prisma migrate dev |
Local developer database | No |
migrate-deploy |
prisma migrate deploy |
Staging or production | Yes |
generate |
prisma generate |
None | Yes, as a build input |
validate |
prisma validate |
None | Yes, as a check |
The split between migrate-dev and migrate-deploy is the important part. migrate dev is interactive and can generate new migration files from schema changes. When it detects drift, it may offer to reset the database. Those behaviours suit a disposable local database and are dangerous anywhere else. migrate deploy only applies migrations already committed under the service's migrations directory. It never creates new ones and never resets the database. That makes it predictable in a pipeline, and it is the only migration target CI and release jobs should call.
Connection strings stay out of project.json entirely. The Prisma schema reads them through env("DATABASE_URL"). Each environment supplies its own value: a local .env file excluded from git for development, and the secret store of the CI or deploy system for staging and production.
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "orders",
"projectType": "application",
"sourceRoot": "apps/orders/src",
"targets": {
"migrate-dev": {
"executor": "nx:run-commands",
"options": {
"command": "npx prisma migrate dev",
"cwd": "apps/orders"
}
},
"migrate-deploy": {
"executor": "nx:run-commands",
"options": {
"command": "npx prisma migrate deploy",
"cwd": "apps/orders"
}
},
"generate": {
"executor": "nx:run-commands",
"options": {
"command": "npx prisma generate",
"cwd": "apps/orders"
}
},
"validate": {
"executor": "nx:run-commands",
"options": {
"command": "npx prisma validate",
"cwd": "apps/orders"
}
}
}
}
An orders service project.json in which every Prisma target sets cwd to apps/orders, so Prisma resolves apps/orders/prisma/schema.prisma, and no connection string appears because the schema reads DATABASE_URL through env() from each environment.
How ordering works: dependsOn within a project and across projects
Inside a single project, dependsOn records what must exist before a target can do useful work. build and typecheck both depend on generate, because the Prisma client types come from prisma generate. Compiling against a stale client either fails, or it passes against an outdated schema and hides the mismatch until runtime. An integration-test target depends on migrate-deploy, because tests that hit a real database are meaningless until the schema exists. Nx resolves these edges on every invocation, so nx integration-test orders applies pending migrations first without anyone having to remember.
Across projects, a ^-prefixed entry such as ^migrate-deploy runs that target in every project the current one depends on. Use it only when the dependency is real: two services share a database, one service's tables hold foreign keys into another's, or a platform project creates extensions and roles that service migrations assume exist. Imports rarely reveal a schema relationship, so declare the edge explicitly with implicitDependencies. Services with separate databases get no edge, which lets Nx run them in parallel.
Coupled (shared database, platform owns extensions/roles):
platform:migrate-deploy
├──> billing:migrate-deploy ──> billing:integration-test
└──> orders:migrate-deploy ──> orders:integration-test
Independent (separate databases, no edge):
billing:migrate-deploy ──> billing:integration-test
orders:migrate-deploy ──> orders:integration-test
(both chains run in parallel)
Execution order controls only when each migration runs. It does not make a migration safe while old application instances are still serving traffic. Destructive changes still need expand-and-contract across separate deploys.
{
"name": "orders",
"projectType": "application",
"implicitDependencies": ["platform"],
"targets": {
"generate": {
"executor": "nx:run-commands",
"options": { "command": "prisma generate --schema=apps/orders/prisma/schema.prisma" }
},
"typecheck": {
"executor": "nx:run-commands",
"dependsOn": ["generate"],
"options": { "command": "tsc --noEmit -p apps/orders/tsconfig.app.json" }
},
"build": {
"executor": "nx:run-commands",
"dependsOn": ["generate"],
"options": { "command": "tsc -p apps/orders/tsconfig.app.json" }
},
"migrate-deploy": {
"executor": "nx:run-commands",
"dependsOn": ["^migrate-deploy"],
"options": { "command": "prisma migrate deploy --schema=apps/orders/prisma/schema.prisma" }
},
"integration-test": {
"executor": "nx:run-commands",
"dependsOn": ["generate", "migrate-deploy"],
"options": { "command": "vitest run --dir apps/orders/test/integration" }
}
}
}
An orders project.json where build and typecheck depend on generate, integration-test depends on migrate-deploy, and implicitDependencies plus ^migrate-deploy run the platform project's migrations first because the database is shared.
Caching rules and drift validation in nx.json
Migration-apply targets need different caching rules from the targets that surround them. Nx decides whether to replay a cached result by hashing the target's declared inputs. The state of the remote database is never part of that hash. If a migrate-deploy target is cacheable, a second environment with an identical commit gets a cache hit: Nx prints the stored terminal output and reports success, and no SQL reaches the database. Set cache: false for apply targets in targetDefaults so every project inherits that rule and no individual project.json can quietly opt back in.
Generate and validate targets are deterministic functions of files in the repository, so they should be cached. A cache entry is only correct if the inputs cover everything the command reads. Suppose the inputs list only the schema file. Adding a new migration directory then leaves the hash unchanged, and a drift check replays yesterday's pass. The same problem occurs when migrations live outside the project root, because the default inputs cover only files inside it. Declare outputs too, such as the generated client's directory. Otherwise a cache hit restores the log but not the artifacts.
| Target | Cache | Inputs | Outputs |
|---|---|---|---|
migrate-deploy |
false |
n/a | none |
prisma-generate |
true |
schema file | generated client path |
migrate-validate |
true |
schema file, migrations directory | none |
The validate target should do two things. First, run prisma validate to catch syntax errors in the schema. Second, run prisma migrate diff to compare the committed migration history with the schema, and fail if the diff is not empty. That diff catches the most common drift: someone edits the schema and forgets to generate a migration. Because the target runs in CI before any deploy job, the drift fails the pull request and never reaches production.
{
"targetDefaults": {
"migrate-deploy": {
"cache": false,
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "prisma migrate deploy"
}
},
"prisma-generate": {
"cache": true,
"inputs": ["{projectRoot}/prisma/schema.prisma"],
"outputs": ["{projectRoot}/src/generated/prisma"],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "prisma generate"
}
},
"migrate-validate": {
"cache": true,
"inputs": [
"{projectRoot}/prisma/schema.prisma",
"{projectRoot}/prisma/migrations/**/*"
],
"outputs": [],
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"parallel": false,
"commands": [
"prisma validate",
"prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url \"$SHADOW_DATABASE_URL\" --exit-code"
]
}
}
}
}
nx.json targetDefaults that make migrate-deploy non-cacheable, cache prisma-generate with its schema input and client output, and cache a drift check whose inputs include the migrations directory (flags shown are Prisma 5/6 syntax).
Running migrations in CI and deploy pipelines
On pull requests, let nx affected choose the scope. Running something like nx affected -t migrate-validate against the merge base checks only the services whose schema, migrations, or upstream dependencies changed. This keeps validation fast, and it is safe because these checks are pure functions of the files in the repository.
Deploy is different. At release time, run the deploy target (for example, one that wraps prisma migrate deploy) for every service being shipped, not only the affected ones. Affected detection compares commits. It knows nothing about which migrations a given database has applied. If staging skipped a release, or a hotfix went straight to production, that environment can have pending migrations for a service whose files have not changed since the last commit range. nx run-many -t migrate-deploy -p <shipped services> covers this. Each tool's deploy command is idempotent, so applying an already-current schema is a no-op.
Limit concurrency for this step with --parallel=1, or a small number if you have confirmed the services are isolated. Two services sharing a database, or migrations that take locks on shared tables, extensions, or enum types, can deadlock or time out when they run at the same time. Serial execution costs little here, because most deploy runs apply zero or one migration per service.
Keep migrations out of the build graph. Do not make build or deploy depend on migrate-deploy through dependsOn. Instead, call the Nx targets from a dedicated job that runs before rollout, with its own credentials, its own concurrency lock, and its own failure handling. If a migration fails, the rollout stops before any new code reaches the database.
#!/usr/bin/env bash
# ci/migrations.sh: called by CI, never from build/deploy dependsOn.
# Usage:
# ci/migrations.sh validate (pull requests)
# ci/migrations.sh deploy orders,billing (release, before rollout)
set -euo pipefail
mode="${1:?usage: migrations.sh validate|deploy [services]}"
case "$mode" in
validate)
# PRs: validation only depends on repo files, so affected scope is safe.
npx nx affected -t migrate-validate --base=origin/main --head=HEAD
;;
deploy)
# Release: every service being shipped, NOT just affected ones.
# Affected detection compares commits, not what each database has applied.
shipped="${2:?pass a comma-separated list of shipped services}"
# DATABASE_URL etc. come from this job's own deploy-scoped secrets.
: "${DATABASE_URL:?migration credentials not set for this job}"
# --parallel=1: services sharing a database or locking shared
# tables/enums/extensions must not migrate concurrently.
# --skip-nx-cache: a cached "success" says nothing about this
# environment's database, so always really run the command.
npx nx run-many -t migrate-deploy -p "$shipped" \
--parallel=1 --skip-nx-cache
;;
*)
echo "unknown mode: $mode" >&2
exit 2
;;
esac
# Nonzero exit (set -e) fails this job, and the rollout job that
# depends on it never starts, so new code never meets an old schema.
A CI helper script that uses nx affected to validate migrations on pull requests, then at release runs nx run-many serially and uncached for every shipped service in a dedicated pre-rollout job.
When to use this pattern and when to avoid it
This pattern fits best when every schema has exactly one owning project and that project deploys on its own schedule. In that case, the migration target sits beside the code that depends on the schema. The project graph shows who owns what, and a change to one service's tables never requires coordinating a release with another team. Review is also easier, because a pull request that touches services/billing and its migrations stays inside one boundary.
When several services share a schema
The model breaks down when several services read and write the same schema. Ownership becomes unclear, and one of two problems usually follows:
- Migrations land in the wrong project. A migration goes into whichever project the author happened to be working in, not the project that owns the table.
- History gets duplicated. Two projects each keep a migrations folder for the same database, and their histories drift apart.
The fix is to move the shared schema into its own library project that owns all migrations for it, and have the consuming services depend on that library. If you can't draw that boundary, the schema isn't really per-service, and a single migration project is the more honest choice.
Don't use cross-project dependsOn to force a global order
Chaining every service's migrate target through cross-project dependsOn can look like a tidy way to impose a global order. In practice it serializes the pipeline, ties unrelated deploys together, and brings back the monolithic-migration problem under a different name.
The cost of coupling schema changes to build tooling
Putting schema changes in build tooling has a price. Engineers now need to understand Nx caching and the project graph in order to reason about database state. Nx can also skip a target that it considers unaffected. For that reason, the deploy job should always ask the database what it has applied, and never infer it from the cache.
Alternatives and impact on cost and engineering time
Per-service targets are one of four common layouts. The others are not wrong; each assigns schema ownership differently.
| Approach | Who owns the schema | PR check scope | Main cost |
|---|---|---|---|
| Root migrate script | Nobody in particular | Everything, every PR | Slow checks, unclear ownership |
| Dedicated migration project | One team, shared schema | Any change to that project | Bottleneck on reviews |
| Deploy job or container outside Nx | Ops or platform | None before merge | Drift found at release |
| Per-service Nx targets | Each service team | Affected services only | Setup and target upkeep |
The setup cost is real. Each service needs its own target definitions, and each one needs correct dependsOn wiring and cache inputs. Someone has to keep them consistent as generators and tool versions change. A wrong inputs list can produce a stale cache hit on a validate step, and that bug is harder to spot than a slow script.
The payback comes from four places. The owning team reviews its own migrations. Cached generate and validate steps skip repeat work. PR checks are limited to affected projects. Drift is caught before merge rather than during a deploy.
Illustrative estimate (assumed numbers, not a benchmark):
The formula is: CI minutes saved per month = PRs × services skipped per PR × minutes per validate.
Assume:
- 200 PRs per month
- 6 services
- 1 service affected per PR, so 5 are skipped
- 3 minutes to validate each service
Result: 200 × 5 × 3 = 3,000 minutes, or about 50 CI hours per month.
Weigh that against the setup cost. If wiring the targets takes 2 hours per service, the one-time cost is 2 × 6 = 12 engineer-hours, plus ongoing maintenance.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx database migrations, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.