From "dotnet publish on my laptop" to repeatable pipelines
Most .NET teams know they need CI/CD; fewer have a workflow they trust when a intern merges on Friday afternoon. GitHub Actions is attractive because it lives next to your code, supports reusable workflows, and integrates cleanly with Azure, AWS, and container registries. This walkthrough mirrors a pipeline we use for ASP.NET Core Web APIs: restore, build, test, publish artifacts, deploy to staging, smoke test, swap to production.
You will copy YAML that works with .NET 8, adjust secrets once, and understand each job—not just green checkmarks.
Repository layout assumptions
src/
MyApi/
MyApi.csproj
MyApi.Tests/
MyApi.Tests.csproj
MyApi.IntegrationTests/
MyApi.IntegrationTests.csproj
Dockerfile
.github/workflows/ci-cd.yml
Integration tests use Testcontainers for SQL Server or the workflow service container below. Adjust paths to match your solution.
Complete workflow file (annotated sections)
name: CI/CD ASP.NET Core
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
DOTNET_VERSION: '8.0.x'
AZURE_WEBAPP_NAME: myapi-prod
AZURE_WEBAPP_PACKAGE_PATH: ./publish
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: Your_strong_password123
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Your_strong_password123 -Q 'SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: nuget-
- name: Restore
run: dotnet restore src/MyApi/MyApi.csproj
- name: Build
run: dotnet build src/MyApi/MyApi.csproj --configuration Release --no-restore
- name: Unit tests
run: dotnet test src/MyApi.Tests/MyApi.Tests.csproj --configuration Release --no-build --verbosity normal
- name: Integration tests
env:
ConnectionStrings__Default: Server=localhost,1433;Database=MyApiTest;User Id=sa;Password=Your_strong_password123;TrustServerCertificate=True
run: dotnet test src/MyApi.IntegrationTests/MyApi.IntegrationTests.csproj --configuration Release
- name: Publish
run: dotnet publish src/MyApi/MyApi.csproj -c Release -o ${{ env.AZURE_WEBAPP_PACKAGE_PATH }} --no-restore
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: webapp
path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
security-scan:
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
severity: HIGH,CRITICAL
exit-code: 1
deploy-staging:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
needs: [build-and-test, security-scan]
environment: staging
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: webapp
path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
- name: Deploy to Azure Web App (staging slot)
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
slot-name: staging
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE_STAGING }}
package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
- name: Smoke test
run: |
curl -f https://${{ env.AZURE_WEBAPP_NAME }}-staging.azurewebsites.net/health || exit 1
deploy-production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- name: Swap staging to production
uses: azure/CLI@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group my-rg \
--name ${{ env.AZURE_WEBAPP_NAME }} \
--slot staging \
--target-slot production
Secrets and environments you must configure
AZURE_WEBAPP_PUBLISH_PROFILE_STAGING— download from Azure portal for the staging slot.- GitHub Environments
stagingandproductionwith required reviewers on production. - Optional:
AZURE_CREDENTIALSservice principal JSON if using az CLI heavily or deploying to AKS.
Never commit publish profiles or connection strings. Use Key Vault references in App Service configuration populated separately.
Pull request workflows
On pull_request, run build-and-test and security-scan only—skip deploy jobs. Add a job commenting coverage from dotnet test --collect:"XPlat Code Coverage" if your team tracks thresholds. For fork PRs, restrict secrets with pull_request_target only when you understand the security implications; default safe pattern is tests without deployment secrets.
Docker alternative job
If you deploy containers to Azure Container Apps or ACR:
- name: Login ACR
uses: azure/docker-login@v2
with:
login-server: myregistry.azurecr.io
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Build and push
run: |
docker build -t myregistry.azurecr.io/myapi:${{ github.sha }} .
docker push myregistry.azurecr.io/myapi:${{ github.sha }}
Speed tips that matter at scale
NuGet caching saves two to four minutes per run. --no-restore after restore avoids duplicate work. Split slow integration tests into a nightly workflow if they block hotfix deploys. Use concurrency groups to cancel obsolete runs on the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
AI in the pipeline (optional, bounded)
Some teams add a job that posts an AI-generated summary of EF migration diffs on PRs—useful for reviewers, not a substitute for human approval on schema changes. Keep API keys in org secrets; cap tokens; never send production connection strings to external models. Static analysis with built-in Roslyn analyzers and Trivy remains the compliance baseline.
Failure modes we see repeatedly
- Tests pass locally but fail in CI because
ASPNETCORE_ENVIRONMENTdiffers. - Publish profile expired; rotate and update secrets proactively.
- Smoke test hits wrong hostname after custom domains.
- Missing
TrustServerCertificatein CI SQL connection strings.
A real GitHub Actions pipeline for ASP.NET Core is boring when it works: fast feedback on PRs, gated production, artifacts you can redeploy without rebuilding. Start with the YAML above, trim jobs you do not need yet, and add environments before your next release crunch—not after an outage.