This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Sunday, 23 November 2025
Running MigrateAsync() at startup? You're giving your app database owner rights and hoping nothing goes wrong. There's a better way - EF migration bundles let you run migrations as a controlled CI step, keeping your production app secure. But here's the thing: sometimes the "wrong" way is actually fine. Let's explore when to use each approach.
Official docs: Migrations Overview | Applying Migrations | Bundles
This blog uses MigrateAsync() at startup - the approach I'm about to tell you not to use. Here's why that's okay for me, and why it probably isn't for you.
In my Program.cs file I have the following:
using (var scope = app.Services.CreateScope())
{
var blogContext = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
await blogContext.Database.MigrateAsync();
}
MigrateAsync() applies pending migrations and creates the database if needed. Simple - but problematic:
db_owner rights. You just gave your runtime app the keys to drop tables.Why I get away with it: public data, single Docker network, personal project. You probably can't.
An EF bundle is a self-contained executable containing your compiled migrations. Think dotnet ef database update packaged into a standalone .exe.
Why bundles win:
db_owner; only CI runner does, only during deploymentNote: For production-grade security, use Managed Identity instead of connection strings. But bundles are still a major step up from runtime migrations.
- name: Install EF Core tools
run: dotnet tool install --global dotnet-ef
- name: Add EF tools to PATH
run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: Generate EF migration bundle
run: |
dotnet ef migrations bundle \
--project ${{ env.WEB_PROJECT }} \
--output efbundle.exe \
--configuration ${{ env.BUILD_CONFIGURATION }} \
--runtime ${{ env.RUNTIME_IDENTIFIER }} \
--context AdminDbContext \
env:
AdminSite__ConnectionString: ${{ secrets.PROD_SQL_CONNECTIONSTRING }}
- name: Run EF migration bundle
run: |
./efbundle.exe
env:
AdminSite__ConnectionString: ${{ secrets.PROD_SQL_CONNECTIONSTRING }}
The bundle reads connection strings from environment variables and applies pending migrations. Already applied? It just exits successfully.
No CI? Want to test before pushing? Build bundles locally.
Use cases: Test before CI, DBA handoff (self-contained exe, no SDK needed), staging deploys, debugging with --verbose.
# Install EF CLI (once)
dotnet tool install --global dotnet-ef
# Basic bundle
dotnet ef migrations bundle \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid \
--output efbundle.exe
# Self-contained (includes runtime - portable to machines without .NET)
dotnet ef migrations bundle \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid \
--output efbundle.exe \
--self-contained
# Cross-platform (e.g., build on Windows, deploy to Linux)
dotnet ef migrations bundle \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid \
--output efbundle \
--runtime linux-x64
# Using default connection string from appsettings.json
./efbundle.exe
# Override with a specific connection string
./efbundle.exe --connection "Host=localhost;Database=mostlylucid;Username=postgres;Password=secret"
# Using an environment variable (matches your config key)
$env:ConnectionStrings__DefaultConnection="Host=localhost;..." # PowerShell
export ConnectionStrings__DefaultConnection="Host=localhost;..." # Bash
./efbundle.exe
# See what migrations would be applied without running them
./efbundle.exe --dry-run
# Verbose output for debugging
./efbundle.exe --verbose
# Apply migrations up to a specific migration (useful for testing)
./efbundle.exe --target-migration "20231115_AddUserTable"
# Combine options
./efbundle.exe --verbose --dry-run
# 1. Create migration
dotnet ef migrations add AddNewFeature \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid
# 2. Build bundle
dotnet ef migrations bundle \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid \
--output efbundle.exe
# 3. Dry run first
./efbundle.exe --dry-run --verbose
# 4. Run for real
./efbundle.exe --verbose
# 5. Broken? Remove and retry
dotnet ef migrations remove \
--project Mostlylucid.DbContext \
--startup-project Mostlylucid
Catches syntax errors, constraint violations, FK issues - all before CI or production.
Bundle generation is slow - 30+ seconds on large projects. Don't generate on every build.
If you really want auto-generation, add a MSBuild target:
<Target Name="BuildMigrationBundle">
<Exec Command="dotnet ef migrations bundle --output $(OutputPath)efbundle.exe --force" />
</Target>
Then: dotnet build -t:BuildMigrationBundle
Best of both worlds: convenience locally, security in production.
if (builder.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
await context.Database.MigrateAsync();
}
// Production: CI pipeline runs the bundle
Generate plain SQL instead of an executable. Great for DBA review and existing change management processes.
# All migrations
dotnet ef migrations script --output migrations.sql
# Idempotent (safe to run multiple times) - USE THIS
dotnet ef migrations script --idempotent --output migrations.sql
# Range of migrations
dotnet ef migrations script FromMigration ToMigration --output migrations.sql
Pros: Full visibility, any SQL client can run it, version control friendly, DBA approval workflows.
Cons: No auto-tracking (use --idempotent), manual execution, potential drift if scripts are modified.
See official docs on SQL scripts.
- name: Generate and apply migrations
run: |
dotnet ef migrations script --idempotent --output migrations.sql
# SQL Server
sqlcmd -S ${{ secrets.DB_SERVER }} -d ${{ secrets.DB_NAME }} -i migrations.sql
# Or PostgreSQL
PGPASSWORD=${{ secrets.DB_PASSWORD }} psql -h ${{ secrets.DB_HOST }} -f migrations.sql
DACPACs are state-based not migration-based. You define the desired schema, and SqlPackage diffs it against the target database.
SqlPackage.exe /Action:Publish /SourceFile:MyDatabase.dacpac /TargetConnectionString:"..."
Pros: Schema as code, auto-diff generation, handles everything (tables, views, SPs, indexes), enterprise tooling.
Cons: SQL Server only, schema in two places (EF models + SQL project), diff engine makes questionable choices, column renames look like drop+add.
See SqlPackage docs.
| Approach | Best For | Requires .NET | Auto-tracks Applied | DBA Friendly | Cross-platform DB |
|---|---|---|---|---|---|
MigrateAsync() |
Dev/small projects | Yes (runtime) | Yes | No | Yes |
| EF Bundles | CI/CD pipelines | No (self-contained) | Yes | Somewhat | Yes |
| SQL Scripts | DBA-controlled environments | No | With --idempotent |
Yes | Yes |
| DACPAC | SQL Server enterprise | No | Yes (state-based) | Yes | No |
Migrations work locally but not in CI? Check you committed both files:
20231115_AddUserTable.cs - The migration code20231115_AddUserTable.Designer.cs - The model snapshotMissing the Designer file = silent failure.
dotnet ef migrations bundle --context BlogDbContext --output blog-migrations.exe
dotnet ef migrations bundle --context IdentityDbContext --output identity-migrations.exe
--connection argumentappsettings.jsonUse environment variables in CI.
EF tools need to instantiate your DbContext. If your DbContext is in a separate project or has complex startup, implement IDesignTimeDbContextFactory<T>:
public class AdminDbContextFactory : IDesignTimeDbContextFactory<AdminDbContext>
{
public AdminDbContext CreateDbContext(string[] args)
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddUserSecrets<AdminDbContextFactory>()
.Build();
var connectionString = config["AdminSite:ConnectionString"]
?? throw new InvalidOperationException("Missing connection string");
var optionsBuilder = new DbContextOptionsBuilder<AdminDbContext>();
optionsBuilder.UseSqlServer(connectionString, sql => sql.CommandTimeout(120));
return new AdminDbContext(optionsBuilder.Options);
}
}
Use when: DbContext in separate project, complex startup, need User Secrets for design-time.
Common questions and pushback I've received.
dotnet ef database update in CI?"Covered above, but the short version: bundles are portable artifacts. Your deployment step doesn't need EF CLI, source code, or design-time resolution. Same bundle runs in test, staging, and prod - zero drift.
Maybe. If you're solo, data is public, and blast radius is low - MigrateAsync() is fine. But the moment you add a second developer, sensitive data, or multiple environments, bundles pay for themselves.
EF doesn't do automatic rollbacks. Options:
Down() migration and run it (but you have to have written it)For critical systems: test migrations against a database clone first.
Yes. Bundle + init container is a solid pattern:
initContainers:
- name: migrate
image: myapp:latest
command: ["./efbundle.exe"]
env:
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: db-secrets
key: connection-string
App container waits for init to complete.
They work great. EF bundles are the EF-native solution, but FluentMigrator and DbUp have their fans. Key difference: those are migration-specific tools, while EF bundles come from your existing EF model.
Use --idempotent scripts:
dotnet ef migrations script --idempotent --output migrations.sql
DBA reviews and approves. Then either:
That's a deployment strategy question, not a migrations question. Generally:
Bundles don't solve this - they just make step 3 more predictable.
© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.