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.
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 foundfordotnet-ef, or need the full SDK installed just to migrate. dotnet ef database updatein 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 erroror a missinglibicudependency.
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.
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.
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.