EF Core Migration Bundles with CI/CD to Azure (Github Actions, Azure SQL & App Service)

Introduction
Once you accept that applying migrations should not happen from application startup, the question becomes: where does it happen? In a real pipeline the answer is a dedicated step that runs before the new code goes live, so the database is always at least as new as the app talking to it.
This video builds that pipeline on .NET 10 with GitHub Actions. The build job produces a migration bundle - a single self-contained executable that applies pending migrations. The deploy job downloads it, runs it against Azure SQL, deploys the API to Azure App Service, and finishes with a smoke test against the live endpoint.
🎬 Watch the full video here:
Why a bundle and not dotnet ef database update
dotnet ef database update needs the .NET SDK, the dotnet-ef tool, and your project source on whatever machine runs it. A migration bundle needs none of that:
dotnet ef migrations bundle --configuration Release --self-contained -r linux-x64 -o efbundle --force
--self-contained -r linux-x64 bakes the runtime in, so the output efbundle is one file you can run on a bare Linux box. --force lets the build overwrite a previous bundle. You then apply it with a connection string:
./efbundle --connection "Server=tcp:...database.windows.net,1433;Database=...;User Id=...;Password=...;Encrypt=True;"
It reads the __EFMigrationsHistory table and applies only what is missing, so re-running it is safe.
Two jobs: build, then deploy
Build job - checkout, set up .NET, install dotnet-ef, restore, build the bundle, and upload it as a workflow artifact with a short retention. That is the only thing the build produces that the deploy job needs.
Deploy job - needs: build, and gated so it only runs on a push to main, not on pull requests:
deploy:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
It publishes the API, downloads the bundle artifact, chmod +x it, applies migrations to Azure SQL, then deploys to App Service.
Order matters: migrate before you deploy
The steps run database-first:
- Apply the bundle to Azure SQL.
- Deploy the new API to App Service.
This ordering means the running app never sees a schema older than it expects. It does require your migrations to be backwards compatible with the currently-live app for the short window between step 1 and step 2 - additive changes (new nullable column, new table) are safe; a destructive change (dropping a column the old code still reads) needs the expand/contract pattern across two deploys.
Secrets, not connection strings in YAML
The workflow references ${{ secrets.AZURE_SQL_ADMIN_PASSWORD }} and ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - stored in GitHub repository secrets, never in the file. The publish profile is downloaded from the App Service in the Azure portal and pasted in as a secret; azure/webapps-deploy@v3 uses it to authenticate the deployment.
Forwarded headers for App Service
One .NET-specific detail in the demo's Program.cs: App Service terminates TLS at its edge proxy and forwards the request over HTTP with X-Forwarded-Proto / X-Forwarded-For headers. To make the app generate correct https:// URLs and see real client IPs, it configures ForwardedHeadersOptions and calls UseForwardedHeaders() - and clears KnownProxies / KnownIPNetworks, because App Service's proxy is not on a loopback address and the default restriction would ignore its headers.
The smoke test
The last step curls the live endpoint in a retry loop, expecting a 200:
for i in $(seq 1 15); do
code=$(curl -s -o /tmp/response.json -w "%{http_code}" https://<app>.azurewebsites.net/posts/offset)
[ "$code" = "200" ] && exit 0
sleep 10
done
exit 1
App Service takes a moment to warm up after a deploy, hence the loop. If it never returns 200, the job fails and you find out from the pipeline, not from users.
Common pitfalls
- Running the bundle without
--self-containedon a runner that lacks the matching runtime. Match-rto the target OS/arch. - Deploying code before migrating. The new app hits a schema it expects to be newer and errors.
- Destructive migrations in a single deploy. Use expand/contract across two releases.
- Putting the SQL admin password in the YAML. Repository secrets only, and prefer a scoped login or managed identity over the server admin.
- Forgetting
UseForwardedHeaders()on App Service. Redirects and generated links come out ashttp://. - Long retention on the bundle artifact. It is a build output; a few days is plenty.
Key Takeaways
- A migration bundle (
dotnet ef migrations bundle --self-contained -r linux-x64) is one executable that applies pending migrations with no SDK or source on the target. - Split the pipeline into a build job that produces the bundle as an artifact and a deploy job that consumes it.
- Gate the deploy job to pushes on
mainso pull requests never touch the database. - Apply migrations to Azure SQL before deploying the API, and keep migrations backwards compatible for that window.
- Store the SQL password and the App Service publish profile as GitHub secrets.
- Configure forwarded headers so the app behaves correctly behind App Service's TLS-terminating proxy.
- End with a retrying smoke test against the live endpoint.
Get the Full Source Code
The complete runnable solution - the API, the migrations, and the full ci-cd.yaml with the build, migrate, deploy, and smoke-test steps - is available to Patreon supporters. If you want to fork it and point it at your own Azure resources instead of rebuilding it from the walkthrough above, you can find it on Patreon.