Using EF Core Migration Bundles in CI

The deploy runner is a minimal container with no .NET SDK, no source code and no dotnet-ef tool — just what it needs to talk to Kubernetes and the database. That rules out dotnet ef database update. An idempotent SQL script would work, but the service has migrations that rely on EF’s own execution semantics — suppressTransaction: true statements, provider-specific operations — and the team wants migrations applied exactly as EF applies them in development. EF Core’s migration bundles (introduced in EF Core 6) solve this: dotnet ef migrations bundle compiles the migrations and the EF runtime into a single executable that applies pending migrations to whatever database you point it at. This guide builds a bundle in CI, runs it as a one-shot deploy step, and covers the operational details that matter under load. It complements generating idempotent SQL scripts from EF Core within Entity Framework Core Migrations.

Build Once, Run Once CI with the .NET SDK builds the application image and a self-contained efbundle executable for linux-x64 from the same commit. The deploy pipeline runs the bundle once as a Kubernetes Job with the migration connection string from a secret. When the Job succeeds, the Deployment rolls out the new application image, which does not migrate. Build Once, Run Once CI (SDK) dotnet ef migrations bundle efbundle self-contained, linux-x64 Migration Job runs once, secret conn string App image same commit Deployment rollout after Job succeeds success
The bundle and the application image come from the same commit, and only the bundle ever changes the schema.

Symptom / Error Signatures

Teams reach for bundles after problems like these:

  • Deploy runners fail with Could not execute because the specified command or file was not found for dotnet-ef, or need the full SDK installed just to migrate.
  • dotnet ef database update in the pipeline builds the project again, making the migration step slow and occasionally producing a different build from the one being deployed.
  • An idempotent SQL script cannot express a migration that must run outside a transaction, and applying it fails with CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
  • A bundle was built for the wrong runtime and fails on the runner with exec format error or a missing libicu dependency.

Root Cause Analysis

A migration bundle is a small console application generated by EF Core tooling. It contains your compiled DbContext, the migrations assembly and the EF Core runtime, and it applies migrations with the same logic as dotnet ef database update: it reads __EFMigrationsHistory, runs each pending migration’s Up, honours suppressTransaction, and records each migration as it completes. Because the migration logic is compiled in, the bundle needs no SDK, no source and no dotnet-ef on the runner — with --self-contained and a runtime identifier, it does not even need the .NET runtime installed.

Option Effect Recommendation
--self-contained bundles the .NET runtime use for minimal runner images
-r linux-x64 (runtime identifier) targets the runner’s OS/CPU match the runner, not the build machine
--configuration Release builds the bundle in Release use the same configuration as the app
--force overwrites an existing bundle file useful in CI
--connection (at run time) overrides the connection string pass from a secret, not baked in

The bundle obtains its DbContext the same way the tools do — through the startup project’s host or an IDesignTimeDbContextFactory — so the connection string it uses by default comes from configuration. Pass the migration connection explicitly with --connection so the migration login, not the application login, runs the DDL.

What the Bundle Does When It Runs Five steps. Connect with the provided connection string; read __EFMigrationsHistory; for each pending migration run Up, inside a transaction unless suppressTransaction is set; insert the migration ID; exit zero when all are applied or non-zero on the first failure. What the Bundle Does When It Runs STEP 1 Connect --connection from secret STEP 2 Read history __ EFMigrationsHistory STEP 3 Run pending Up txn unless suppressTransactio n STEP 4 Record ID per migration STEP 5 Exit code 0 = all applied
The bundle stops at the first failed migration and exits non-zero, which is what lets the pipeline halt the rollout.

Immediate Mitigation

1. Build the bundle in CI for the runner’s platform.

# Shell · CI build job with the .NET SDK · produces artifacts/efbundle for linux-x64 runners
# WARNING: build from the same commit as the application image being deployed.
dotnet tool restore
dotnet ef migrations bundle \
  --project src/Shop.Data --startup-project src/Shop.Api \
  --configuration Release --self-contained -r linux-x64 \
  --output artifacts/efbundle --force

2. Run it once as a deploy step with an explicit connection string. In Kubernetes, a Job is the natural shape; in other pipelines, a single step on the runner.

# YAML · Kubernetes Job · runs the bundle once before the Deployment rolls out
# WARNING: backoffLimit 0 — a failed migration must stop the deploy, not retry blindly.
apiVersion: batch/v1
kind: Job
metadata:
  name: shop-migrate-20260918
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 1800
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/shop-migrate:20260918
          command: ["/app/efbundle", "--connection", "$(MIGRATION_CONNECTION)", "--verbose"]
          env:
            - name: MIGRATION_CONNECTION
              valueFrom: { secretKeyRef: { name: shop-db-migrator, key: connection } }

3. Bound lock waits. Include a lock timeout in the migration connection — for Npgsql, Options=-c lock_timeout=3000 in the connection string; for SQL Server, SET LOCK_TIMEOUT 3000 at the top of risky migrations via migrationBuilder.Sql — so a blocked statement fails quickly instead of freezing a table, per setting lock_timeout and retrying DDL safely.

4. Gate the rollout on the Job’s success. Wait for completion (kubectl wait --for=condition=complete job/shop-migrate-20260918 --timeout=30m) and only then update the Deployment. On failure, stop the pipeline and inspect the Job’s logs.

Permanent Fix / Long-Term Pattern

Standardise on “build once, run once”. The CI build produces two artefacts from one commit — the application image and a migration image containing only the bundle — and the deploy runs the migration image as a one-shot job before rolling out the application. The application never migrates at startup, as covered in running EF Core migrations outside application startup. The bundle’s own history check makes reruns safe: running it against an up-to-date database applies nothing and exits zero.

Keep the migrations inside the bundle zero-downtime by construction: additive first, destructive changes one release later, online index builds in their own migrations with suppressTransaction: true. Add a CI rehearsal that runs the bundle against a restored snapshot and then runs it again to confirm it is a no-op, as described in testing migrations against production-like snapshots. Record the bundle’s duration per deploy as a metric; a migration step that suddenly takes minutes is usually waiting on locks.

Deploy Timeline With a Bundle Job Timeline of a deploy over 12 minutes. The bundle Job starts at minute 1 and completes at minute 3. Old pods serve throughout until minute 9. New pods begin rolling in at minute 3.5 after the Job succeeds, and replace the old pods by minute 9. Deploy Timeline With a Bundle Job Job complete efbundle Job Old pods serving (schema-compatible) New pods rolling in after the Job 0 3 min 6 min 9 min 12 min migration Job old version new version
The new version starts only after the schema is in place, and the old version keeps serving against a schema it is compatible with.

Verification Checklist

Frequently Asked Questions

What is the difference between a bundle and an idempotent script? A script is SQL you can read and run with any client; a bundle is an executable that applies migrations with EF Core’s own logic, including suppressTransaction handling and provider-specific operations. Scripts are easier to review; bundles are closer to what database update does. Many teams publish both and apply the bundle.

Does the bundle need the .NET runtime on the runner? Not if you build it with --self-contained and a runtime identifier. Without those options it depends on a compatible runtime being installed.

Is it safe to rerun the bundle after a failure? Yes for migrations that completed — they are recorded and skipped. A migration that failed partway is rerun from the beginning, so its statements should be safe to repeat: transactional on PostgreSQL and SQL Server, and guarded with IF NOT EXISTS for statements that ran outside a transaction.

Can the bundle target a different database than the one configured in the app? Yes. Pass --connection at run time. That is the recommended way to use a dedicated migration login with DDL privileges while the application’s own login stays restricted.