How it works, in brief

Affected-only migrations combine three things you may already have: an Nx workspace with a project graph, a migration tool per service, and a CI pipeline that deploys to one or more environments. The mechanism works like this:

  1. Each service gets a migrate target. The target wraps the service's existing migration command, such as prisma migrate deploy, a Flyway or Alembic invocation, or a script. Caching is disabled and the inputs are declared explicitly.
  2. CI picks a base and a head commit. The base is the last commit that deployed successfully to the target environment. The head is the commit being deployed.
  3. Nx computes the affected set. nx affected -t migrate diffs base against head, maps changed files to projects, and walks the project graph to include dependents. It then runs migrate only for those projects, ordered by dependsOn where declared.
  4. Deploys follow migrations. nx affected -t deploy runs with the same base and head after the migrate step succeeds, so the same set of services is migrated and then deployed.
  5. A scheduled full run catches what the diff misses. nx run-many -t migrate runs periodically against every service. It repairs drift from failed pipelines, manual changes and restores, because affected detection reads git history and never inspects the database.

The rest of this article covers each step in detail.

How nx affected decides which services need migrating

Nx compares two commits, a base and a head. It lists the files that changed between them, maps each file to the project that owns it, and then walks the project graph to add every project that depends on a changed one. Only the migrate targets of those projects run.

Changed file Owning project Affected dependents Migrate targets selected
libs/billing-schema/... billing-schema billing-api, invoicing-worker billing-api:migrate, invoicing-worker:migrate
apps/orders-api/... orders-api none orders-api:migrate
docs/... none none none

The core limitation is that the graph only sees file changes. It knows nothing about database state. If a migration failed halfway, or someone applied a change by hand, Nx cannot detect it.

For this reason, the base should be the last commit that deployed successfully to each environment, not main. For tests, a base that is slightly wrong costs you a missed test run, and the next run catches it. For migrations, a wrong base can skip a schema change permanently. Suppose staging last deployed three merges ago. Diffing against the tip of main hides the first two merges, and their migrations never run.

The base commit is also fragile:

  • Shallow clones may not contain the base commit at all.
  • Force-pushes and rebased branches can rewrite history so the recorded commit no longer exists.
  • Squash merges collapse history, which changes which commits you can diff against.

To guard against these problems, fetch enough git history in CI to reach the base commit. Record the last successfully deployed commit for each environment somewhere durable, and fail loudly if that commit cannot be resolved.

+------------------+           +------------------+
| base commit      |           | head commit      |
| last successful  |           | commit being     |   DB state is
| deploy to env    |           | deployed         |   not an input
+--------+---------+           +--------+---------+
         |                              |
         +--------------+---------------+
                        v
+-----------------------------------------------------------+
| 1. CHANGED FILES (git diff base..head)                    |
| libs/billing-schema/...   apps/orders-api/...   docs/...  |
+-----------------------------------------------------------+
      v                          v                   v
+-----------------------------------------------------------+
| 2. OWNING PROJECTS                                        |
| billing-schema             orders-api           (none)    |
+-----------------------------------------------------------+
      v                          v
+-----------------------------------------------------------+
| 3. AFFECTED PROJECTS (walk graph to dependents)           |
| billing-api                orders-api                     |
| invoicing-worker                                          |
+-----------------------------------------------------------+
      v                          v
+-----------------------------------------------------------+
| 4. MIGRATE TARGETS SELECTED                               |
| billing-api:migrate        orders-api:migrate             |
| invoicing-worker:migrate                                  |
+-----------------------------------------------------------+

Nx diffs the environment's last successfully deployed commit against the head commit, maps changed files to their owning projects, walks the project graph to find affected dependents, and selects only their migrate targets; database state is never an input.

Defining a migrate target with scoped inputs

Each service already has a migration tool (Flyway, Alembic, Prisma Migrate, golang-migrate, or a script). Leave that tool in charge. The Nx target only invokes it. Add a migrate target to the service's project.json that uses the nx:run-commands executor, or calls a plain script, and passes through the same command an engineer would run locally. Migration ordering, locking and version tracking stay inside the tool, which already handles them well. Nx decides only whether and when the command runs.

Declare the target's inputs explicitly: the migration directory, the schema files, and the tool's config. Put that set in nx.json as a named input, for example migrationInputs, using {projectRoot}-relative globs, so every service can reference it instead of keeping its own copy. Set "cache": false on the target. The next section explains why this is non-negotiable.

Nx's affected calculation works at the project level, not per target. The inputs are therefore your declared contract for what a migration depends on. Anything that hashes or inspects the target relies on that contract. The hard part is choosing the inputs:

  • Too narrow: you miss real changes. Examples are a regenerated ORM client, a shared connection config, or an environment file that changes which schema the tool targets. Nothing appears to have changed, so the migration never runs.
  • Too broad: every README edit triggers a migrate step. The step should be a no-op, but it adds noise and CI time.

Err toward broad. A redundant run of an idempotent migration tool costs seconds. A missed migration ships code against a schema that doesn't exist yet.

// nx.json: one shared definition of what a migration depends on
{
  "namedInputs": {
    "migrationInputs": [
      "{projectRoot}/migrations/**/*",
      "{projectRoot}/prisma/schema.prisma",
      "{projectRoot}/prisma/**/*.config.*",
      // Deliberately broad: shared DB config can change the target schema
      "{workspaceRoot}/libs/db-config/**/*",
      // Env var that selects which schema the tool migrates
      { "env": "DATABASE_SCHEMA" }
    ]
  }
}

// services/orders/project.json: Nx only decides whether the tool runs
{
  "name": "orders-service",
  "targets": {
    "migrate": {
      "executor": "nx:run-commands",
      // Never replay a migration from cache; it must hit the real database
      "cache": false,
      "inputs": ["migrationInputs"],
      "options": {
        // Same command an engineer runs locally; ordering and locking stay in Prisma
        "command": "npx prisma migrate deploy",
        "cwd": "services/orders"
      }
    }
  }
}

A shared migrationInputs named input in nx.json and a per-service migrate target that wraps the service's own migration command, with caching disabled and inputs that lean broad.

Modeling shared schemas and dependencies in the project graph

Affected detection only helps if the project graph knows which services depend on which schema code. Nx infers most edges from imports. If orders-service imports @acme/db-models, any change to that library marks orders-service as affected. So put shared schema definitions, ORM entities and migration helpers in real library projects and have services import them. Do not copy them between service directories, because Nx cannot see that relationship.

Some coupling never shows up as an import. A service might read a view another service owns, depend on a SQL file loaded at runtime, or point its migration tool at a directory outside its project root. Declare these edges with implicitDependencies in the consuming project's project.json. Check the result with nx graph. A missing edge here becomes a missed migration later.

Ordering is a separate problem. If billing adds a foreign key to a table that accounts creates, set the migrate target's dependsOn to ^migrate. Nx will then run migrations for dependency projects first. This works only if the dependency edge already exists in the graph.

Shared databases break the assumption that each service owns its own schema. When two affected services migrate the same database, Nx sees two independent tasks. It may run them in parallel or in an order that has nothing to do with the schema. Handle this in one of these ways:

  • Add explicit dependsOn edges between the services.
  • Run migrations with --parallel=1.
  • Rely on the migration tool's own lock table or advisory lock so concurrent runs are serialized.

The cleanest option is to move the shared schema into a single project that owns its migrations.

cat > apps/billing/project.json <<'EOF'
{
  "name": "billing",
  "projectType": "application",
  "implicitDependencies": ["accounts"],
  "targets": {
    "migrate": {
      "executor": "nx:run-commands",
      "dependsOn": ["^migrate"],
      "options": {
        "command": "./scripts/migrate.sh",
        "cwd": "apps/billing"
      }
    }
  }
}
EOF

nx graph

nx affected -t migrate --parallel=1

A billing project.json declares an implicit dependency on accounts and uses dependsOn ^migrate so accounts migrates first; affected migrations then run serially for services that share a database.

Why migrate targets must never be cached

Nx caches a target by hashing its inputs and replaying the stored outputs and terminal log when the hash matches. For a build, that is the whole point: the same source produces the same artifact, so skipping the work is safe. A migration is different. Its real output is a change to an external database, and the input hash knows nothing about that database's current state. If the migration files are unchanged since the last run against staging, a cache hit on the production run would print the old "applied 3 migrations" log, exit zero, and leave production unmigrated. The deploy step that follows would then ship code expecting columns that don't exist.

Target Side effects Cacheable Safe to replay Effect of a false cache hit
build None outside the workspace Yes Yes None. The restored artifact is identical
test None if tests are isolated Yes Yes Low. A passing result is reused for identical inputs
migrate Writes to a remote database No Only if the tool is idempotent Severe. The step is skipped but reported as successful

Caching can be turned on without anyone intending it. A targetDefaults entry that sets cache: true for every target, or for a name pattern your migrate target happens to match, applies silently. A shared remote cache makes this worse: a hash recorded on a developer laptop pointed at a local database can satisfy a CI run against production.

Guard against both explicitly. Set "cache": false on every migrate target, even if the defaults already exclude it today. Add a CI check that fails the pipeline if any target named migrate resolves to cacheable. Pass --skip-nx-cache on the migration step as a second layer of protection.

#!/usr/bin/env bash
set -euo pipefail

projects=$(npx nx show projects --with-target migrate --json | jq -r '.[]')

failed=0
for project in $projects; do
  # Read the *resolved* config, after targetDefaults have been merged in,
  # so a broad default like cache: true is caught here.
  cache=$(npx nx show project "$project" --json | jq -r '.targets.migrate.cache')

  # Require an explicit false. A missing value is rejected too, so a
  # future change to targetDefaults cannot silently make this cacheable.
  if [ "$cache" != "false" ]; then
    echo "ERROR: $project:migrate resolves to cache=$cache (expected false)" >&2
    failed=1
  fi
done

if [ "$failed" -ne 0 ]; then
  echo "Set \"cache\": false on the migrate targets above before deploying." >&2
  exit 1
fi

npx nx affected -t migrate \
  --base="${NX_BASE:-origin/main}" \
  --head="${NX_HEAD:-HEAD}" \
  --skip-nx-cache

A CI script that fails the pipeline unless every resolved migrate target has cache explicitly set to false, then runs affected migrations with --skip-nx-cache as a second safeguard.

Running affected migrations and deploys in CI

Pipeline order matters. First compute the base: the commit of the last successful deploy to this environment, not origin/main~1. If a run is skipped or fails, the next run still covers everything since the last good state. Export it as NX_BASE, with NX_HEAD set to the current commit. Then run nx affected -t migrate, and only after that step succeeds run nx affected -t deploy against the same base and head, so both steps see the same set of projects.

When several services share one database server, parallel migrations compete for locks and connections. Pass --parallel=1 to the migrate step and leave deploys at a higher concurrency.

Every migration must be idempotent. Each migration tool keeps its own history table, so migrate should apply only pending entries and exit cleanly when none exist. When nx affected flags a service whose schema did not change, the step does nothing, and over-triggering costs only a database round trip.

Use expand-and-contract migrations. Add columns and tables first, ship code that uses them, and drop the old structures in a later release. The version currently running keeps working after the migration runs, so a migration can run before its deploy, and you do not need lockstep releases.

Partial failures happen. If the migration succeeds and the deploy fails, the old code keeps running against the expanded schema, so retry the deploy. Treat rollback as forward-only. Down migrations that reverse destructive changes cannot restore dropped data, so the fix is a new forward migration.

The full-run fallback for drift and missed runs

Affected detection compares commits. It has no knowledge of database state. Suppose a pipeline changes orders-service migrations and then fails before the migrate step, or someone cancels it, or a runner dies mid-deploy. The next merge to main moves the base commit forward. On the following run, those migration files sit inside the already-compared range, so orders-service no longer counts as affected unless something else touches it. The repository says the change shipped. The staging database says it didn't. Nothing in the affected workflow will notice the gap.

Drift also comes from outside the repo: a hotfix applied by hand during an incident, a restored snapshot that predates recent migrations, or a new environment provisioned from an old dump.

A scheduled reconciliation job

The fix is a separate job, run on a schedule and on demand, that ignores the project graph's change detection entirely. It uses nx run-many to run the migrate target for every project that defines one, once per environment, with the environment selected through the target's configuration. Run it nightly against each environment, and trigger it manually after restores or incidents. Give it the same credentials and concurrency guard as the deploy pipeline so the two never race on the same database.

This only works if every migrate target is idempotent. Each migration tool records applied migrations in a history table and skips anything already recorded. Against an up-to-date database, a full run does little more than open a connection and read that table per service. When something is behind, the same run applies only the missing migrations. Because that outcome is the correct one in both cases, you can run the job often without risk.

Who should use affected-only migrations

This approach fits a specific kind of team and codebase. It is a good match when most of the following are true:

  • You already use Nx for the services in question. The technique relies on the project graph, so the services and their schema code need to live in one Nx workspace. Adopting Nx only for this purpose is a large change for a modest gain.
  • You run many services, each owning its own schema or database. Affected detection narrows the work only when a typical commit touches a small fraction of services. If every commit touches a shared schema library that every service imports, almost everything is affected every time.
  • Your migration tools are idempotent and track their own history. Flyway, Alembic, Prisma Migrate and golang-migrate all record applied migrations. Hand-rolled scripts that re-run SQL blindly are not safe to over-trigger or to run from a full reconciliation job.
  • Your CI can record the last successful deploy per environment. Without a reliable base commit, affected detection for migrations is unsafe. You need somewhere durable to store that commit, such as a deploy tag, a CI variable, or a record in your deployment system.
  • Someone owns the pipeline. Platform or DevOps engineers need to maintain the base-commit tracking, the cache guard, the graph edges and the reconciliation job.

It is a poor match for small repositories with a handful of services, for systems where most services share one database and one schema, and for teams without a dependable way to track what each environment last deployed. In those cases, running every migration on every deploy is simpler and safer.

How it saves engineering time

The pipeline-time savings are the most visible effect, but most of the engineering time saved comes from smaller, more focused deploys:

  • Shorter waits on deploys. Engineers wait less between merge and a running service, because the migrate step touches only the databases whose schema could have changed. This matters most when deploys are frequent and engineers block on them, for example to verify a fix in staging.
  • Fewer unrelated failures to investigate. When every deploy connects to every database, a credential rotation, lock contention or network problem on one service's database can fail a deploy that never touched it. Someone then has to investigate a failure unrelated to their change. Limiting the step to affected services removes that class of interruption for most deploys.
  • Smaller, more readable logs. A migrate step that covers two services produces output for two services. When something does fail, the relevant output is not buried among dozens of "no pending migrations" lines.
  • Smaller blast radius when a migration fails. A failed run leaves at most the affected services in a partially migrated state, so there are fewer databases to check and fewer deploys to retry.
  • Reuse of existing tooling. Each team keeps its own migration tool and local workflow. The Nx target runs the same command engineers already use, so there is no new migration framework to learn or migrate to.

These gains come with costs, covered in the next section: tracking base commits, maintaining graph edges, guarding against caching, and running the reconciliation job. The net saving depends on how many services you have and how often you deploy.

When to use it, when to avoid it, and the alternatives

Affected-only migrations pay off for platform and backend teams running many services, each with its own schema. In that setup, most commits touch one or two services. Connecting to every database on every deploy adds pipeline time and creates more chances for unrelated failures, such as lock contention, credential rotation, or a network blip, with no benefit.

Avoid it in small repositories with a handful of services. If every migration is idempotent and a full run finishes quickly, running everything on every deploy is simpler and leaves less to break.

Approach Pipeline cost Failure modes Best fit
Run all migrations every deploy Grows with service count Unrelated DB outages block deploys Few services
Affected-only plus full-run fallback Scales with change size Wrong base commit, partial runs Many services, separate schemas
Migrate at service startup None in CI Replicas race for locks, slow boots Single-replica services
Per-service pipelines outside Nx Low per pipeline Shared-schema changes coordinated by hand Truly independent services

The savings are real but bounded. You pay for them in engineering time: maintaining base-commit tracking, debugging why a service was or wasn't marked affected, and handling deploys that migrated three of five databases before failing.

Illustrative estimate (assumed inputs): 30 services, 40 seconds per migration check, 3 affected services per deploy, 20 deploys per day. A full run takes 20 minutes and an affected run takes 2, which saves about 6 hours of pipeline time per day. If keeping the setup working costs a few engineer-hours each month, the trade is favourable. At 5 services, the full run takes just over 3 minutes and the extra complexity is rarely worth it.

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 affected 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.