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](https://www.deployhq.com), 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](https://www.deployhq.com/support/configuration/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](https://www.deployhq.com) 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](https://www.deployhq.com/signup) or log in to [DeployHQ](https://www.deployhq.com)
2. Create a new project and connect your [GitHub](https://www.deployhq.com/deploy-from-github) or [GitLab](https://www.deployhq.com/deploy-from-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](https://www.deployhq.com/support/configuration/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](https://www.deployhq.com) 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](https://www.deployhq.com) 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](https://www.deployhq.com) 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

- [Deploying .NET Applications with](https://www.deployhq.com/blog/deploying-net-applications-with-deployhq-on-digital-ocean)[DeployHQ](https://www.deployhq.com) on DigitalOcean — full .NET deployment walkthrough
- [Protecting Your API Keys: Best Practices for Secure Deployment](https://www.deployhq.com/blog/protecting-your-api-keys-best-practices-for-secure-deployment) — managing secrets in deployments
- [Managing Environment Variables Across Deployment Stages](https://www.deployhq.com/blog/managing-environment-variables-across-deployment-stages-a-guide-for-developers) — per-environment configuration
- [What Is a Build Pipeline?](https://www.deployhq.com/blog/what-is-a-build-pipeline-and-how-can-it-improve-your-workflow) — build step fundamentals
- [Official EF Core Migrations Documentation](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/) — Microsoft reference
- [EF Core Migration Bundles](https://devblogs.microsoft.com/dotnet/introducing-devops-friendly-ef-core-migration-bundles/) — Microsoft deep dive on bundles

If you have questions or need help, reach out at [support@deployhq.com](mailto:support@deployhq.com) or on [Twitter/X](https://x.com/deployhq).

