DbUp Migrations in .NET 9: SQL Server, PostgreSQL, MySQL, and CI/CD with DeployHQ

.NET, Tips & Tricks, and Tutorials

DbUp Migrations in .NET 9: SQL Server, PostgreSQL, MySQL, and CI/CD with DeployHQ

DbUp is the right choice when you want version-controlled, code-reviewed SQL scripts that run exactly once per database — and nothing fancier. If you already live in Entity Framework Core and don't write raw SQL, stick with EF Core Migrations. If you want C# DSL migrations with rollback, look at FluentMigrator. If you want database-first SQL scripts with a transparent SchemaVersions ledger and no framework lock-in, DbUp on .NET 9 plus DeployHQ's build pipeline is the setup documented below.

Migration library comparison: DbUp vs EF Core vs FluentMigrator

DbUp 6 EF Core 9 Migrations FluentMigrator 6
Script style Raw SQL files C# DbContext snapshot C# fluent DSL
Supported DBs SQL Server, PostgreSQL, MySQL, SQLite, Oracle, Firebird, Redshift EF Core providers SQL Server, Postgres, MySQL, Oracle, SQLite
Rollback support No (forward-only) Yes (dotnet ef database update <previous>) Yes (Down method)
Transactions Per-script or single Per-migration Per-migration
Requires ORM? No Yes (EF Core) No
Script tracking table SchemaVersions __EFMigrationsHistory VersionInfo
Best fit Teams that already write SQL Greenfield EF Core apps Database-agnostic C# shops

Short version: DbUp wins when your DBAs want to read every migration as literal SQL before it hits production. EF Core Migrations wins when nobody on the team writes SQL by hand. FluentMigrator sits in the middle — it's a C# API like EF Core, but it doesn't need a DbContext.

The rest of this guide is DbUp-specific.

What is DbUp?

DbUp is a lightweight .NET library that deploys changes to SQL Server, PostgreSQL, MySQL, and other database systems. Unlike Entity Framework Core Migrations or FluentMigrator, DbUp takes a SQL-first approach: you write plain SQL scripts, and DbUp handles execution order, tracks which scripts have run (in a SchemaVersions table), and ensures each script runs exactly once.

This approach has three concrete advantages:

  1. Full SQL control — you can use provider-specific features (SQL Server OPTIMIZE FOR UNKNOWN, Postgres CREATE INDEX CONCURRENTLY, MySQL ALGORITHM=INPLACE) without fighting an ORM.
  2. Reviewable diffs — migrations are plain .sql files your DBA can read without running the tool.
  3. No ORM coupling — works with EF Core, Dapper, ADO.NET, or any mix.

As of April 2026, DbUp is at 6.x on .NET 9 (the current LTS until Nov 2026).

The deployment architecture

Here's how the pieces fit together:

  1. You push to your Git repository (GitHub, GitLab, Bitbucket, etc.)
  2. DeployHQ detects the change and triggers a deployment
  3. Your application is built and deployed to your server
  4. DeployHQ runs a post-deploy command that executes your DbUp migration console app
  5. DbUp reads the embedded scripts, compares to SchemaVersions, runs the ones that haven't run
  6. The deployment is marked complete only if DbUp exits with code 0

If DbUp fails, DeployHQ halts the deployment — so application code never ships ahead of its matching schema. This is the whole point of automated deployment.

Setting Up DbUp: SQL Server, PostgreSQL, or MySQL

The DbUp console app structure is the same across all three databases — only the NuGet package and connection string change.

Step 1: Create the migration console application

In your solution, add a new console application targeting .NET 9:

dotnet new console -n YourProject.DatabaseMigration -f net9.0

Step 2: Install the DbUp package for your database

Database NuGet package Example connection string
SQL Server dbup-sqlserver Server=sql.example.com;Database=AppDb;User Id=app;Password=...;TrustServerCertificate=True;
PostgreSQL dbup-postgresql Host=pg.example.com;Database=appdb;Username=app;Password=...;SSL Mode=Require;
MySQL dbup-mysql Server=mysql.example.com;Database=appdb;Uid=app;Pwd=...;SslMode=Required;

Install the one you need:

dotnet add package dbup-sqlserver
# or
dotnet add package dbup-postgresql
# or
dotnet add package dbup-mysql

Step 3: Structure your migration scripts

Create a Scripts folder. DbUp executes scripts in alphabetical order, so use a zero-padded numeric prefix:

  • 0001-CreateUsersTable.sql
  • 0002-AddEmailIndexToUsers.sql
  • 0003-CreateOrdersTable.sql

Mark each .sql file as Embedded Resource in its properties. This ships the SQL inside your compiled assembly, so deployments don't need loose SQL files on disk.

Step 4: Write the migration application

Your Program.cs is nearly identical for all three databases — the only difference is the DeployChanges.To.XxxDatabase(...) call.

SQL Server:

using System.Reflection;
using DbUp;

var connectionString = args.FirstOrDefault()
    ?? throw new ArgumentException("Connection string required");

var upgrader = DeployChanges.To
    .SqlDatabase(connectionString)
    .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
    .WithTransactionPerScript()
    .LogToConsole()
    .Build();

var result = upgrader.PerformUpgrade();

if (!result.Successful)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine(result.Error);
    Console.ResetColor();
    return -1;
}

Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Success!");
Console.ResetColor();
return 0;

PostgreSQL — change the builder line:

var upgrader = DeployChanges.To
    .PostgresqlDatabase(connectionString)
    .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
    .WithTransactionPerScript()
    .LogToConsole()
    .Build();

MySQL — same pattern:

var upgrader = DeployChanges.To
    .MySqlDatabase(connectionString)
    .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
    .WithTransactionPerScript()
    .LogToConsole()
    .Build();

WithTransactionPerScript() is the safest default — if a script fails partway through, its changes roll back. Use WithTransaction() for a single wrapping transaction only if you have a specific reason (some DDL statements won't run inside a transaction on SQL Server, for example).

The non-zero exit code is critical for DeployHQ integration — it's the signal that tells DeployHQ to halt the deployment if migrations fail.

Step 5: Test locally

dotnet run --project YourProject.DatabaseMigration "Server=localhost;Database=YourDb;Trusted_Connection=True;TrustServerCertificate=True;"

On the first run, DbUp creates SchemaVersions and executes your scripts. Run it again and DbUp should skip everything — it's already applied.

Configuring DeployHQ

With your DbUp app working locally, wire it into the deployment pipeline.

Step 6: Set up your DeployHQ project

Log in to DeployHQ and create a new project pointing at your Git repository. Connect your deployment target — SFTP, SSH, Amazon S3, or a custom SSH server. For a typical .NET app, you're probably deploying to a Windows or Linux VM via SFTP or directly to IIS.

Step 7: Configure the build pipeline

In Build Pipeline, add a command to compile and publish your solution:

dotnet publish YourProject.sln -c Release -o ./publish

This publishes everything — web app, API, and the migration console — into a single publish folder that gets shipped to the server.

Step 8: Configure deployment paths

In Deployment Configuration, specify that ./publish is the source of truth for files. The migration console app must be deployed somewhere the server can execute it — typically a sibling folder to your main app.

Step 9: Add the post-deploy migration command

In Commands, add a new command with these settings:

  • Command Type: Run on Server
  • Trigger: After files are copied
  • Command:
cd /path/to/your/deployment/YourProject.DatabaseMigration && dotnet YourProject.DatabaseMigration.dll "$CONNECTION_STRING"

Replace /path/to/your/deployment with the actual path on your server.

Step 10: Configure connection strings as environment variables

In Environment Variables, add CONNECTION_STRING with your database connection string as the value. Keep one variable per environment (staging, production) — DeployHQ lets you set per-server overrides, which is exactly how you avoid migrating production against staging data.

Never hardcode connection strings in your code or in DeployHQ command strings.

Step 11: Run the first deployment

Commit a change (or trigger a manual deploy) and watch the log:

  1. Code pulled from the repository
  2. dotnet publish runs in the build pipeline
  3. Files transferred to the server
  4. Migration command runs, DbUp logs each script it applies
  5. Deployment marked successful — or halted if DbUp exited non-zero

Advanced configuration

Per-environment deployments

Create a separate DeployHQ deployment configuration for each environment (development, staging, production), each with its own CONNECTION_STRING environment variable. This is the standard pattern for preventing cross-environment mistakes.

Transaction strategy

  • WithTransactionPerScript() (default recommendation) — each script is atomic. If script 3 fails, scripts 1 and 2 stay, script 3 rolls back.
  • WithTransaction() — all scripts in one transaction. Fails atomically but can't be used with certain DDL statements (SQL Server CREATE INDEX WITH ONLINE=ON, for example).
  • WithoutTransaction() — required for operations that explicitly forbid transactions (PostgreSQL CREATE INDEX CONCURRENTLY, for example).

Forward-only migrations (no rollback)

DbUp has no rollback mechanism, and that's intentional. Once data has been modified, automated rollback is usually impossible anyway. The recommended pattern is:

  • To undo migration 0042, write a new migration 0043-revert-0042.sql that contains the reverse SQL.
  • This creates a linear, auditable history of every schema change.

Zero-downtime schema changes

For high-availability apps, database changes must stay backward-compatible with the currently-running application code for at least one deploy cycle. For zero-downtime deployments:

  • Adding a required column: ship it as nullable first, deploy the code that writes to it, then make it NOT NULL in a later migration.
  • Renaming: add the new column, dual-write in code, backfill, then drop the old column — three migrations and at least two deploys.
  • Dropping: deprecate in code first, wait a release cycle, then drop in migration.

Logging and monitoring

LogToConsole() is fine for local development but thin in production. Use LogTo(ILog) to plug in Serilog, NLog, or Microsoft.Extensions.Logging so migration events flow into your existing log aggregation. For Slack or email notifications on migration success/failure, have the migration console app POST to a webhook before exiting.

Best practices

  • Test locally before committing. A syntax error in a migration script is a production incident waiting to happen.
  • Keep migrations small. One logical change per file. Makes review and troubleshooting tractable.
  • Never modify a migration that's been applied anywhere. Once a script has run in any environment, it's immutable. If you need to change it, write a new script that fixes it forward.
  • Include manual rollback notes in comments. DbUp won't execute them, but you or a teammate will thank you at 3am.
  • Review migration scripts in PRs. Database changes are code changes — same review process.
  • Watch migration duration. Long-running migrations on large tables can exceed deployment windows. Run EXPLAIN before shipping anything that touches millions of rows.

Troubleshooting

Migration fails but application still deploys. The migration console returned 0 when it should have returned -1. Check your if (!result.Successful) branch actually returns non-zero, and confirm DeployHQ's command is configured to stop on failure.

Scripts run out of order. DbUp sorts alphabetically. 10-foo.sql runs before 2-bar.sql. Always use zero-padded prefixes (0002-..., 0010-...).

Connection string parsing errors. Special characters (semicolons, quotes) in passwords break parsers. Use DeployHQ's encrypted environment variables or URL-encode the password.

Permission denied on the server. The DeployHQ SSH user needs execute permission on the migration binary and network access to the database. Check both file permissions (chmod +x) and firewall / security group rules.

Conclusion

Pairing DbUp with DeployHQ gives you a predictable, reviewable, automated migration pipeline on .NET 9 — across SQL Server, PostgreSQL, and MySQL alike. Database changes move through the same Git-reviewed, CI-built, deploy-gated pipeline as your application code, and you never ship code that out-runs its schema.

The initial setup is a one-time cost. After that, every deploy includes its migrations by default, and every migration is forward-only, script-tracked, and auditable.

Start with the setup above, then grow the pipeline as your team grows. For hands-on help, get started with DeployHQ or reach out at support@deployhq.com or on X.