Deploying EF Core Migrations to Production with Migration Bundles and DeployHQ

.NET, Devops & Infrastructure, and Tutorials

Deploying EF Core Migrations to Production with Migration Bundles and DeployHQ

Database schema changes are one of the riskiest parts of any deployment. A missing column, a broken foreign key, or a migration that runs twice can take down a production application in seconds. Entity Framework Core's code-first approach solves half the problem by letting you define schemas in C# and generate migrations automatically. The other half — getting those migrations safely to production — is what this guide focuses on.

We will build a complete deployment pipeline using EF Core 9 migration bundles and DeployHQ, so that every schema change flows through version control, gets tested, and reaches production without anyone running dotnet ef database update over SSH.

What code-first actually gives you

In a code-first workflow, your C# classes are the database schema. Entity Framework Core compares your model classes against the current database state and generates migration files that describe the diff. Those migration files are committed to Git alongside your application code, which means:

  • Schema changes go through code review like any other change
  • You can trace exactly when and why a column was added
  • Rolling back means reverting a Git commit, not writing manual SQL
  • The same migrations run identically across dev, staging, and production

A practical example

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public DateTime CreatedAt { get; set; }

    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
    public List<OrderItem> OrderItems { get; set; } = [];
}

public class Category
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public string? Description { get; set; }
    public List<Product> Products { get; set; } = [];
}

Note the required keyword and nullable annotations — EF Core 9 on .NET 9 has nullable reference types enabled by default. Omitting them produces compiler warnings and can cause unexpected NOT NULL constraints.

Setting up EF Core 9

Project setup

dotnet new webapi -n MyApp
cd MyApp
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design

DbContext with .NET 9 minimal hosting

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();
app.MapControllers();
app.Run();
// Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();
}

Store the connection string in appsettings.json for development:

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=MyApp;Trusted_Connection=true;TrustServerCertificate=true"
  }
}

For production, use environment variables or DeployHQ config files — never commit production connection strings to Git.

Creating and managing migrations

# Install EF Core tools (once)
dotnet tool install --global dotnet-ef

# Create initial migration
dotnet ef migrations add InitialCreate

# Review the generated file before applying
cat Migrations/*_InitialCreate.cs

# Apply to local database
dotnet ef database update

Migration best practices

  • Name migrations descriptively: AddProductCategoryRelationship, not Update1
  • Keep migrations small: one logical change per migration
  • Always review generated code: EF sometimes generates destructive operations (dropping columns) that you may want to adjust
  • Never edit an applied migration: if a migration has been applied to any environment, create a new migration to correct it

EF Core 9 breaking change: pending model changes

EF Core 9 now throws an exception if you call Migrate() or dotnet ef database update when there are model changes without a corresponding migration. This catches a common mistake where developers forget to generate a migration, but it can surprise you if you are upgrading from EF Core 8:

The model for context 'AppDbContext' has pending changes.
Add a new migration before updating the database.

If you need to suppress this during development, configure it explicitly:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));

Do not suppress this in production — it exists to protect you.

The three ways to deploy migrations (and which to use)

Method Use in dev Use in CI/CD Use in production
dotnet ef database update Yes No No
Database.Migrate() at startup Yes No No
Migration bundles Optional Yes Yes

Why not dotnet ef database update in production?

It requires the .NET SDK and your source code on the production server. It also does not play well with multiple app instances — if two instances start simultaneously, both try to run migrations and you get deadlocks.

Why not Database.Migrate() at startup?

Same concurrency problem. In a load-balanced or Kubernetes environment, multiple instances calling Migrate() at the same time causes race conditions. It also couples your application startup to database availability, which increases deployment failure modes.

Migration bundles: the production-ready approach

A migration bundle is a self-contained executable that contains all your migrations and applies them. It does not need the .NET SDK, does not need your source code, and runs as a single process — no concurrency issues.

# Generate the bundle
dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle

# The output is a single executable
ls -la efbundle
# -rwxr-xr-x  1 user  staff  45M  efbundle

Run it against any database:

./efbundle --connection "Server=prod-db;Database=MyApp;User Id=deploy;Password=secret"

The bundle is idempotent — it checks which migrations have already been applied and only runs the new ones.

Building a DeployHQ pipeline

Here is the complete workflow: push code to Git, DeployHQ builds your app and the migration bundle, deploys both to your server, and runs migrations before starting the new application version.

flowchart LR
    Dev["Developer"]
    Git["Git Push"]
    DHQ["DeployHQ"]
    Build["Build Step:\ndotnet publish\ndotnet ef migrations bundle"]
    Deploy["Deploy to VPS\nvia SSH/SFTP"]
    Migrate["SSH Command:\n./efbundle"]
    Restart["SSH Command:\nsystemctl restart myapp"]

    Dev --> Git --> DHQ --> Build --> Deploy --> Migrate --> Restart

Step 1: Repository structure

MyApp/
  src/
    MyApp/
      Program.cs
      Data/
        AppDbContext.cs
      Models/
        Product.cs
        Category.cs
      Migrations/
        20260315_InitialCreate.cs
        ...
  scripts/
    deploy.sh
  MyApp.sln

Step 2: Create a DeployHQ project

  1. Sign up or log in to DeployHQ
  2. Create a new project and connect your GitHub or GitLab repository
  3. Add an SSH/SFTP server pointing to your VPS or cloud server

Step 3: Configure the build step

In DeployHQ's Build Commands section, add:

cd src/MyApp
dotnet restore
dotnet publish -c Release -o ../../publish
dotnet ef migrations bundle --self-contained -r linux-x64 -o ../../publish/efbundle --no-build

This produces two artifacts in publish/:

  • Your compiled application
  • The efbundle migration executable

Step 4: Set deploy path and config files

  • Deploy path: /home/deploy/myapp/
  • Config file: add appsettings.Production.json through DeployHQ's Config Files feature to inject the production connection string without committing it to Git:
{
  "ConnectionStrings": {
    "Default": "Server=prod-db;Database=MyApp;User Id=deploy_user;Password=${DB_PASSWORD}"
  }
}

Step 5: Add SSH commands

In DeployHQ's SSH Commands (run after file transfer):

cd /home/deploy/myapp/publish

# Make bundle executable
chmod +x efbundle

# Run migrations (idempotent — safe to run on every deploy)
./efbundle --connection "$CONNECTION_STRING"

# Restart the application
sudo systemctl restart myapp

Now every git push to your main branch:

  1. Builds the application and migration bundle
  2. Deploys both to your server
  3. Runs pending migrations
  4. Restarts the application

Handling failed migrations

If the migration bundle fails, the SSH command exits with a non-zero code, and DeployHQ marks the deployment as failed. Your application continues running the previous version — no half-applied state.

To recover:

  1. Fix the migration in your codebase
  2. Push the fix
  3. DeployHQ re-deploys and re-runs the bundle (it skips already-applied migrations and only runs the fixed one)

Writing safe production migrations

Not all migrations are safe to run on a live database. Here are patterns that avoid downtime:

Safe: adding a nullable column

public class AddProductSku : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "Sku",
            table: "Products",
            type: "nvarchar(50)",
            nullable: true);  // nullable = no lock, no data rewrite
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "Sku", table: "Products");
    }
}

Unsafe: renaming a column (breaks running app)

Instead, use an expand-and-contract pattern:

// Migration 1: Add new column (deploy this first)
migrationBuilder.AddColumn<string>("ProductName", "Products", nullable: true);
migrationBuilder.Sql("UPDATE Products SET ProductName = Name");

// Migration 2: After app is updated to use ProductName, drop the old column
migrationBuilder.DropColumn("Name", "Products");

Custom SQL in migrations

For operations EF cannot express (data backfills, index changes, stored procedures):

public class AddProductSearchIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(@"
            CREATE INDEX IX_Products_Name_Sku
            ON Products (Name, Sku)
            INCLUDE (Price)
            WHERE Sku IS NOT NULL;
        ");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DROP INDEX IX_Products_Name_Sku ON Products;");
    }
}

Pre-deploy database backup

Add a backup step before migrations in your DeployHQ SSH commands:

# Backup before migration (SQL Server)
sqlcmd -S prod-db -U backup_user -P "$BACKUP_PASSWORD" \
  -Q "BACKUP DATABASE MyApp TO DISK='/backups/myapp_$(date +%Y%m%d_%H%M%S).bak'"

# Or for PostgreSQL
pg_dump -h prod-db -U backup_user -d myapp > /backups/myapp_$(date +%Y%m%d_%H%M%S).sql

# Then run migrations
./efbundle --connection "$CONNECTION_STRING"

Troubleshooting

Problem Cause Solution
Pending model changes exception Model changed without new migration Run dotnet ef migrations add <Name> locally
Bundle fails with already applied Migration was manually applied Check __EFMigrationsHistory table
Timeout during migration Large data migration on big table Break into smaller batches using raw SQL
Login failed for user Wrong connection string Check DeployHQ config files and environment variables
Bundle not found on server Build step failed silently Check DeployHQ build logs

Further reading

If you have questions or need help, reach out at support@deployhq.com or on Twitter/X.