Most .NET developers still remember what deployment used to look like before CI/CD became the default. You publish the project locally, copy the output to a server over RDP or FTP, run a few scripts by hand, and then hope nothing breaks in production. I have done this more times than I would like to admit, and it is stressful every single time, no matter how experienced you are.
A properly wired CI/CD pipeline removes almost all of that anxiety. Once the pipeline is in place, a deployment becomes a routine event instead of a nerve wracking one. This article walks through a working GitHub Actions pipeline that builds, tests, and deploys a .NET 9 application to Azure App Service, and then looks at how to extend it with database migrations, code coverage gates, and multi-environment approval flows.
Why CI/CD Matters for .NET Teams
CI/CD stands for Continuous Integration and Continuous Delivery, sometimes extended to Continuous Deployment. The three terms get used loosely in conversation, so it helps to be precise about what each one actually means.
- Continuous Integration means merging code changes frequently and running automated tests on every merge, so problems surface within minutes rather than days
- Continuous Delivery means every change that passes the pipeline is packaged and ready to ship to production, even if a human still clicks the deploy button
- Continuous Deployment goes one step further and deploys every passing change to production automatically, with no manual gate
The practical benefits show up quickly once a team adopts this discipline. Bugs get caught within minutes of being introduced instead of during a stressful release week. Small, frequent releases are far easier to debug than one giant release that bundles three months of changes. And the classic “it works on my machine” excuse mostly disappears, because the build and test environment is now defined in code rather than living inside someone’s laptop.
A Working GitHub Actions Workflow for .NET 9
Here is a complete workflow that builds, tests, and deploys a small .NET 9 Web API to Azure App Service. It is split into two jobs: one that builds and tests the code, and a second one that deploys it, and the second job only runs if the first one succeeds.
# Name of the workflow as it appears in GitHub Actions UI
name: Time Service CI
# Define when this workflow will run
on:
workflow_dispatch: # Allow manual triggering from GitHub UI
push:
branches:
- main # Run automatically when code is pushed to main branch
# Environment variables used throughout the workflow
env:
AZURE_WEBAPP_NAME: time-service
AZURE_WEBAPP_PACKAGE_PATH: './Time.Api/publish'
DOTNET_VERSION: '9.x'
SOLUTION_PATH: 'Time.Api.sln'
API_PROJECT_PATH: 'Time.Api'
PUBLISH_DIR: './publish'
jobs:
# First job: build and test the application
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore ${{ env.SOLUTION_PATH }}
- name: Build
run: dotnet build ${{ env.SOLUTION_PATH }}
--configuration Release
--no-restore
- name: Test
run: dotnet test ${{ env.SOLUTION_PATH }}
--configuration Release
--no-restore
--no-build
--verbosity normal
- name: Publish
run: dotnet publish ${{ env.API_PROJECT_PATH }}
--configuration Release
--no-restore
--no-build
--property:PublishDir=${{ env.PUBLISH_DIR }}
# Store the published output as an artifact for later jobs
- name: Publish Artifacts
uses: actions/upload-artifact@v4
with:
name: webapp
path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
# Second job: deploy the application to Azure
deploy:
name: Deploy to Azure
runs-on: ubuntu-latest
needs: [build-and-test]
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: webapp
path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
- name: Deploy
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: '${{ env.AZURE_WEBAPP_PACKAGE_PATH }}'
The build-and-test job checks out the repository, installs .NET 9 using actions/setup-dotnet, and runs through a standard pipeline: restore, build, test, publish. Each step depends on the previous one succeeding, and passing –no-restore and –no-build to later steps avoids redoing work that has already been done. Once publish finishes, the output gets uploaded as a build artifact so the deploy job can pick it up.
The deploy job downloads that artifact and pushes it to Azure App Service using the azure/webapps-deploy action, authenticating with a publish profile stored as a GitHub secret. The needs: [build-and-test] line is doing the important work here. It guarantees the deploy job cannot start unless the build and test job finishes cleanly, which keeps broken code from ever reaching the App Service instance.
One thing worth flagging about this approach: publish profile authentication works, but it is a long lived credential sitting in your GitHub secrets. If you can, prefer OpenID Connect federated credentials between GitHub Actions and Microsoft Entra ID instead. It avoids storing any secret at all, since Azure issues a short lived token to the workflow run on demand. It takes a bit more setup up front, an app registration with a federated credential trusting your repository and branch, but it removes an entire category of credential leakage risk.

The screenshot above shows what a healthy run looks like in the GitHub Actions UI: the build-and-test job finishes first, and only then does the deploy job kick off. If you ever see the deploy job start before the build job has gone green, check your needs: clause, because that dependency is the only thing enforcing the order.
Extending the Pipeline for Real Projects
The workflow above is enough to get an application deployed, but most production systems need a bit more around it. As the project grows, the pipeline should grow with it. Three extensions come up constantly in real .NET projects: database migrations, code coverage, and multi-environment deployment with approvals.
Running Database Migrations Safely
Coordinating schema changes with code deployments is one of the trickier parts of any CI/CD setup, because a migration that fails halfway through can leave your database in a state that neither the old code nor the new code understands. EF Core Migration Bundles are a solid option for automating this.
- name: Create migration bundle
run: dotnet ef migrations bundle --project ${{ env.DATA_PROJECT }} --output ${{ env.MIGRATIONS_BUNDLE }}
- name: Apply migrations
run: ${{ env.MIGRATIONS_BUNDLE }}
Migration bundles, introduced in EF Core 6, package all your pending migrations into a single standalone executable. That executable does not need the .NET SDK or your project source on the target machine, it just needs a connection string, which makes it a clean fit for a CI/CD step. Running it applies every pending migration against the target database in one shot.
For anything touching a production database with real customer data, I would not run migrations unattended. A manual approval step is worth the extra friction:
deploy-database:
name: Deploy Database Changes
environment: production
runs-on: ubuntu-latest
needs: [build-and-test]
Attaching a GitHub Environment with required reviewers to this job means the workflow pauses and waits for a database administrator, or whoever owns that responsibility on your team, to look at the pending migration script before it runs. It adds a delay, but for production data that trade-off is usually worth it.
Automated migrations and manually reviewed migrations each have their place, and the right choice depends on how risky your schema changes tend to be.
- Automated migrations mean no manual step, schema and code deploy together, and every database change is versioned alongside the code that depends on it
- The trade-off is that a failed migration can be hard to roll back cleanly, some changes need extra handling to avoid locking large tables, and the pipeline needs secure database credentials sitting in CI
Whichever approach you pick, always test the migration against a staging copy of the database first and take a backup before it touches production. I have seen a migration that ran perfectly on a small staging database lock up a production table for several minutes simply because the production table had ten million more rows.
Enforcing Code Coverage
Knowing how much of the codebase is actually exercised by tests is useful, especially on a team where multiple people are shipping changes daily. Here is how to generate a coverage report and publish it to Codecov as part of the same workflow.
- name: Generate coverage report
run: dotnet test ${{ env.SOLUTION_PATH }} /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
- name: Publish coverage report
uses: codecov/codecov-action@v5
with:
files: ./**/coverage.cobertura.xml
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
The dotnet test step uses the Coverlet collector to produce a Cobertura format report, and the Codecov action then uploads that report and posts a summary back on the pull request. You can configure Codecov to fail the build when overall coverage drops below a chosen threshold, which is a reasonable way to stop coverage from quietly eroding over time. Do not chase 100 percent coverage though, it usually means you are testing trivial getters and setters instead of the logic that actually matters.
Multi-Environment Deployment With Approval Gates
Larger projects benefit from deploying to a staging environment first, with a human checkpoint before anything reaches production.
deploy-staging:
name: Deploy to Staging
environment: staging
runs-on: ubuntu-latest
needs: [build-and-test]
steps:
# Deployment steps...
deploy-production:
name: Deploy to Production
environment: production
runs-on: ubuntu-latest
needs: [deploy-staging]
steps:
# Deployment steps...
GitHub Environments let you attach protection rules to each stage of this chain. The deploy-production job needs: [deploy-staging], so it physically cannot run until staging has succeeded, and the production environment itself can be configured with its own reviewers and wait timers on top of that.
Three protection rules are worth setting up on any production environment.
- Required reviewers, so a named person has to approve the deployment before it proceeds
- Wait timers, which add a fixed delay before a deployment is allowed to run, giving the team a window to catch anything wrong
- Deployment branch restrictions, which stop anyone from accidentally deploying a feature branch straight to production
None of this replaces good testing, but it does add a safety net for the moments when a bug slips through the automated checks. For teams operating under change management or compliance requirements, this pattern is often the difference between passing an audit comfortably and scrambling to produce evidence after the fact.
Practical Notes From Running This in Production
A few things are worth knowing before you copy this workflow into your own repository. The publish profile approach shown here is the fastest way to get started, but as mentioned earlier, moving to OIDC federated credentials is the better long term choice for anything beyond a personal project or a demo.
Watch your artifact sizes too. The upload-artifact and download-artifact steps move the published output between jobs over the network, and a large monorepo build can make this step surprisingly slow. Trimming unused files from the publish output, or splitting a monorepo into separate workflows per deployable project, usually helps more than tuning any other part of the pipeline.
Finally, do not treat this workflow as fixed. A good CI/CD pipeline changes as the project changes. Start with something close to the basic two-job workflow, automate whatever manual step is causing the most pain right now, and add the database, coverage, and multi-environment pieces only when the project actually needs them. Adding all of this complexity on day one for a small internal tool is usually wasted effort.
Leave a Reply