Database Migrations in Deployments
Apply EF Core migrations once per release with a bundle in a Kubernetes Job, and write schema changes that old and new code can both run against.
dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle
ConnectionStrings__Shop="$SHOP_MIGRATOR_CONNECTION" ./efbundleDuring a rolling update, old and new versions of the API run side by side against one database, and a rollback runs the old version against the new schema. So two questions decide every migration: where it runs, and whether both versions of the code survive it.
Where migrations run
| Approach | Good | Risk |
|---|---|---|
Database.MigrateAsync() at app start-up | Simple; fine for development and one-replica tools | Every replica tries at once; the app needs DDL rights; a failing migration crash-loops every pod; long migrations fail startup probes |
| Init container per pod | Runs before the app container | Runs once per pod per rollout; still DDL rights in the app pod |
| A Job before the rollout (pipeline step, Helm pre-upgrade hook, Argo sync wave) | Once per release, own credentials, visible logs, failure stops the deploy | The schema must suit the code that is still running |
Reviewed SQL script (dotnet ef migrations script --idempotent) | DBA review, regulated environments | A manual step that can drift |
Choose the Job. The migrator connects as a role that can change the schema; the API's role can only read and write rows, so a SQL injection in the API cannot drop a table. EF Core also takes a database lock while migrating so concurrent Migrate calls do not apply the same migration twice, but that only serialises the race: the waiting pods still sit unready.
Migration bundles
dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle compiles the migrations into one executable. It needs no SDK and no source code at run time, applies only migrations missing from __EFMigrationsHistory, reads the connection string from the startup project's configuration (supply ConnectionStrings__Shop from a Secret; --connection also works but puts the password in the process list), and exits non-zero on failure so the Job fails and the pipeline stops before new pods start. Bundles are built by the EF tools, which create your DbContext through the app's host or an IDesignTimeDbContextFactory; a factory avoids running start-up code that calls other services.
Expand and contract
Renaming Customers.Name to FullName produces a single RenameColumn, and every old pod fails with "column does not exist" the moment it runs. Split breaking changes across releases:
- Expand (release N): add a nullable
FullName, backfill it in batches outside the ALTER's transaction, write both columns, read the new one with a fallback. - Switch (release N+1): read and write only
FullName. Old pods kept writing onlyNameafter the first backfill, leaving NULL or staleFullNamevalues, so once no release N-1 pod remains, re-run the batched backfill (WHERE "FullName" IS NULL OR "FullName" <> "Name") before making it required. - Contract (release N+2, once N can no longer be rolled back to): drop
Name.
Safe in one step: new tables, nullable columns, columns with a default, new indexes. Needs expand and contract: renames, drops, type changes, a new NOT NULL column without a default.
Locks in PostgreSQL
ALTER TABLE takes an ACCESS EXCLUSIVE lock and holds it until its transaction commits. Queued behind one long transaction, it blocks every query on the table, and the API stalls; a backfill in the same transaction keeps the table locked for its whole run. Set lock_timeout in the migration so it fails fast and can be retried, keep backfills in batches, and create indexes with CREATE INDEX CONCURRENTLY, which cannot run inside a transaction (suppressTransaction: true).
Example
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
public class Customer
{
public int Id { get; set; }
public string Email { get; set; } = "";
// Release N (expand): both columns exist. Old pods read Name, new pods read FullName ?? Name.
public string Name { get; set; } = "";
public string? FullName { get; set; }
public string DisplayName => FullName ?? Name;
}
public class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
}
[DbContext(typeof(ShopDbContext))]
[Migration("20261020093000_AddCustomerFullName")]
public partial class AddCustomerFullName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Fail fast instead of queueing behind a long transaction and blocking all reads.
migrationBuilder.Sql("SET lock_timeout = '5s';");
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Customers",
type: "character varying(200)",
maxLength: 200,
nullable: true);
// Batched backfill outside the ALTER's transaction: each batch commits and releases its row locks.
migrationBuilder.Sql("""
DO $$
DECLARE updated integer;
BEGIN
LOOP
UPDATE "Customers" SET "FullName" = "Name"
WHERE "Id" IN (SELECT "Id" FROM "Customers" WHERE "FullName" IS NULL LIMIT 5000);
GET DIAGNOSTICS updated = ROW_COUNT;
EXIT WHEN updated = 0;
COMMIT;
END LOOP;
END $$;
""", suppressTransaction: true);
// Cannot run inside the migration transaction.
migrationBuilder.Sql(
"""CREATE INDEX CONCURRENTLY IF NOT EXISTS "IX_Customers_FullName" ON "Customers" ("FullName");""",
suppressTransaction: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""DROP INDEX CONCURRENTLY IF EXISTS "IX_Customers_FullName";""", suppressTransaction: true);
migrationBuilder.DropColumn(name: "FullName", table: "Customers");
}
}When to use it
- Rename Customers.Name to FullName across three releases while the orders API keeps serving through each rolling update and rollback.
- Run the EF Core migration bundle as a Helm pre-upgrade Job with a migrator role, so the API's database user cannot alter tables.
- Add an index to a 40-million-row Orders table with CREATE INDEX CONCURRENTLY without blocking checkout writes.
More examples
The bundle as a Job before the rollout
<code>backoffLimit: 0</code> stops Kubernetes from retrying a half-applied migration on its own; a human or the pipeline decides. The Job uses the migrator's credentials, which the API Deployment never sees. The bundle builds the startup project's host, so it reads <code>ConnectionStrings__Shop</code> from the environment; passing <code>--connection</code> in <code>args</code> would expose the password in <code>/proc/<pid>/cmdline</code> and process listings.
apiVersion: batch/v1
kind: Job
metadata:
name: orders-db-migrate-1-5-0
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: 0
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/shop/orders-migrator:1.5.0
env:
- name: ConnectionStrings__Shop
valueFrom:
secretKeyRef:
name: orders-migrator
key: connection-string
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256MiA migrator image built from the bundle
A self-contained bundle carries its own runtime, so it runs on the small <code>runtime-deps</code> chiseled image with no SDK and no source. Build it in the same pipeline run as the API image and tag both with the same version, so the Job always applies exactly the migrations the new pods expect.
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
RUN dotnet tool install --global dotnet-ef --version 10.0.13
ENV PATH="${PATH}:/root/.dotnet/tools"
COPY . .
RUN dotnet ef migrations bundle \
--project src/Orders.Data --startup-project src/Orders.Api \
--self-contained -r linux-x64 -o /out/efbundle
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
COPY --from=build /out/efbundle /app/efbundle
ENTRYPOINT ["/app/efbundle"]Readiness that refuses traffic until the schema is current
A guard for the case where the Job was skipped or failed silently: new pods stay unready, the rollout stalls at <code>maxUnavailable: 0</code>, and the old pods keep serving. It reads <code>__EFMigrationsHistory</code> on each probe, so keep the readiness period at five seconds or more.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddHealthChecks()
.AddCheck<SchemaVersionCheck>("schema-version", tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") });
app.Run();
public class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options);
// The new pods only report ready once the Job has applied every migration they were built with.
sealed class SchemaVersionCheck(ShopDbContext db) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct)
{
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
return pending.Count == 0
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy($"{pending.Count} migration(s) not applied: {pending[^1]}");
}
}
Discussion