Build once, deploy many: EF Core migrations on Azure SQL

How do you deal with multiple environments, each on their own version?

The past few weeks I was creating the CI/CD pipelines for one of my teams. The build and release pipelines for a .NET backend on Azure App Service is something most dev teams working on Azure have already done so I will not go into detail for that part.

What teams struggle more with, is getting their SQL changes deployed. A decade ago, I’ve used solutions like DACPAC. But these days we’re often using EF Core code first migrations for the development cycle.

The problem

If we look back at the CI/CD concepts, we build our codebase once and deploy the same output on multiple environments after testing.


This will result in possibly each environment being on a different version:

Environment Release version # Migrations ahead of Prod Notes
Dev 2.0.0-beta74 21 Latest CI
Test 2.0.0-beta50 18 A possible release candidate
Staging 1.7.2 2 A hotfix on prod in final test
Production 1.7.1 NA

Let’s say the next pull request merged on Dev is the actual release for version 2.0, with just code changes and no database changes. The table below shows the number of migrations to be executed on each environment’s database if we can promote this exact build to all environments.

Environment # Migrations to be executed
Dev 0
Test 3
Staging 19
Production 21

As you can see, the challenge here is creating a single executable which can run on all environments without failure.


There are many options to tackle this, but let’s only look at those requiring not to restart from scratch (e.g. writing SQL scripts) since we’re already using EF Core Migrations in the development cycle.

The not so clean DevOps way

Commands like Update-Database or its CLI equivalent dotnet ef database update require source code. In a release pipeline I prefer to work with binaries rather than source code, so let’s drop this one.

Quick and easy: app startup

The solution could be as simple as updating the database from within your app startup with this single line of code:

    await _context.Database.MigrateAsync();

While this might seem a smart idea, it holds a lot off possible issues:

  • Slower app startup
  • Possible timeout for big migrations
  • Harder to troubleshoot (logging required)
  • Hard to revert
  • Requires elevated SQL rights (change schema rather than only manage data)

Clearly less smart than we initially thought, certainly for a production environment.

A DevOps way: EF Bundles

EF Bundles are single-file executables that can be used to apply migrations to a database. They address some of the shortcomings of the SQL script and command-line tools:

  • Executing SQL scripts requires additional tools.
  • The transaction handling and continue-on-error behavior of these tools are inconsistent and sometimes unexpected. This can leave your database in an undefined state if a failure occurs.
  • Bundles can be generated as part of your CI process and easily executed later as part of your deployment process, even with different target versions.
  • Bundles are self-contained executables and don’t require installing the .NET SDK or EF Tool.

Build pipeline

The first step is creating the EF bundle and add it as an artifact to our pipeline output.

  # Only doing this after API publish to keep output clean and not mess with publish paths
  - task: DotNetCoreCLI@2
    displayName: 'Install Dotnet EF Tool'
      command: custom
      custom: "tool"
      arguments: "install --global dotnet-ef"

  - task: DotNetCoreCLI@2
    displayName: EFBundle
      command: custom
      custom: 'ef '
      arguments: 'migrations bundle --self-contained --project "src/Database.csproj" --startup-project "src/Api.csproj" -r win-x64 -o $(Build.ArtifactStagingDirectory)/SQL/Bundle.exe --verbose'

  - task: CopyFiles@2
    displayName: 'Copy AppSettings for EF Bundle'
      contents: 'src/Api/appsettings.json'
      targetFolder: '$(Build.ArtifactStagingDirectory)/SQL'
      flattenFolders: true ## copy to the root instead of keeping the full directory structure

  - task: PublishBuildArtifacts@1
    displayName: 'PublishArtifacts - SQL Bundle'
      pathToPublish: '$(Build.ArtifactStagingDirectory)/SQL'
      artifactName: 'SQL'

You could add a condition to your tasks to only do this on e.g. the main branch, or not when a PR is created to speed up those builds.

    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))

Release pipeline

Since you’re probably used to create backend release pipelines already, I will stick to executing the EF Bundle only:

    - task: AzureCLI@2
        displayName: 'Azure SQL Schema Deployment'
        azureSubscription: ${{ parameters.AzureSubscription }}
        scriptType: pscore
        scriptLocation: inlineScript
        inlineScript: |
            cd $(Pipeline.Workspace)\buildpipeline\SQL
            .\Bundle.exe --connection 'Server=tcp:$(SqlServer);Authentication=Active Directory Default; Database=$(SqlDatabase);'            

Here we’re using variable groups for the Azure SQL Server name and SQL Database name. Security is done through Azure Entra ID, this means that the service principal executing the Azure DevOps pipeline has access to Azure SQL and is db_ddladmin.


If you run your pipeline on a new environment, you might see output similar to:

Applying migration '20240317205346_InitialMigration'.
Applying migration '20240319122218_AddUser'.

If the initial migration was already applied, it would only run the second migration. Running the deployment pipeline once again will just say:

No migrations were applied. The database is already up to date.
Licensed under CC BY-NC-SA 4.0; code samples licensed under MIT.
comments powered by Disqus
Built with Hugo - Based on Theme Stack designed by Jimmy