CI/CD for ASP.NET Core with GitHub Actions

Khimananda Oli 7 min read Programming and Languages
CI/CD for ASP.NET Core with GitHub Actions

By Khimananda Oli | Last reviewed: August 2026

Shipping .NET applications reliably requires automating the path from commit to production without exposing credentials or skipping validation. CI/CD for ASP.NET Core with GitHub Actions provides a native, YAML-driven workflow that integrates directly with your repository and cloud provider identity systems. This guide walks through building a secure, containerized pipeline for .NET 9 that passes audits and scales with your team, avoiding common pitfalls like hardcoded secrets and unoptimized layer caching.

How do you structure a secure CI/CD pipeline for ASP.NET Core?

A production-grade pipeline separates concerns into distinct jobs: linting/building, testing, containerizing, and deploying. Never combine deployment logic with build steps; if tests fail, the artifact should never reach the registry. For teams managing sensitive data, understanding how to handle secrets in CI/CD pipelines safely is the first step before writing any workflow code.

Git Pushmain / PRBuild & Testdotnet testContainerizeDocker BuildDeployOIDC AuthGitHub Actions Runners (Ubuntu 24.04 / Windows)
High-level architecture for CI/CD for ASP.NET Core with GitHub Actions showing sequential gates and OIDC-based deployment.

In practice, I structure workflows with explicit dependency chains using the needs keyword. This ensures the deploy job only executes if both build and security scan jobs succeed. Always pin action versions to full SHA hashes rather than tags to prevent supply chain attacks—a critical requirement for SOC 2 compliance.

How do you configure OIDC authentication for Azure or AWS?

Stop storing long-lived cloud credentials as repository secrets. OpenID Connect (OIDC) allows GitHub Actions to request short-lived tokens directly from your cloud provider. This eliminates secret rotation overhead and reduces blast radius if a runner is compromised.

Configuring Azure Federated Credentials

  1. Create an Azure AD Application and add a federated credential issuer pointing to https://token.actions.githubusercontent.com.
  2. Set the subject identifier to repo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main to restrict access to specific branches.
  3. Assign the app a least-privilege role (e.g., "Website Contributor") on the target resource group.
  4. In your workflow, use azure/login@v2 with client-id, tenant-id, and subscription-id—no creds parameter needed.
- name: Azure Login (OIDC)
  uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Deploy to Azure Web App
  uses: azure/webapps-deploy@v3
  with:
    app-name: 'aspnet-core-prod'
    images: 'myregistry.azurecr.io/app:${{ github.sha }}'

For teams comparing automation platforms, our analysis of GitHub Actions vs Azure Pipelines highlights that OIDC support is now mature on both, but GitHub’s per-repository scoping offers finer granularity for multi-team organizations.

How do you optimize Docker builds for .NET 9 applications?

Naive Dockerfiles rebuild dependencies on every code change, wasting minutes per commit. Multi-stage builds with layer caching are non-negotiable for .NET workloads. The key is separating dependency restoration from source compilation.

Inefficient Single-StageCOPY . /src (Invalidates cache)RUN dotnet restore (Re-downloads)RUN dotnet publishFinal Image: ~800MBOptimized Multi-StageCOPY *.csproj → RUN restoreCOPY . → RUN publishRuntime-only stage (alpine)Final Image: ~120MBOptimize
Comparison of naive vs optimized Docker layer caching for ASP.NET Core CI/CD builds.
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
WORKDIR /src
COPY ["MyApp.csproj", "."]
RUN dotnet restore --runtime linux-x64
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore --self-contained false

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

Always specify --runtime during restore to enable NuGet package caching for the target platform. Use Alpine-based images to reduce attack surface and image size. For deeper container fundamentals, see our Docker for beginners guide which covers these patterns across language ecosystems.

How do you implement testing and quality gates in GitHub Actions?

Automated tests without enforcement are just suggestions. Configure your workflow to fail fast on test failures and enforce coverage thresholds before allowing merges.

  • Unit Tests: Run dotnet test --collect:"XPlat Code Coverage" and upload results via dorny/test-reporter@v2 for inline PR annotations.
  • Integration Tests: Use service containers for PostgreSQL or Redis. Never depend on external test databases.
  • Security Scanning: Add dotnet list package --vulnerable and Trivy image scanning as mandatory jobs.
  • Matrix Builds: Test against multiple .NET versions or OS targets simultaneously to catch platform-specific regressions early.
jobs:
  test:
    runs-on: ubuntu-24.04
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      - run: dotnet test --logger trx --collect:"XPlat Code Coverage"
      - uses: dorny/test-reporter@v2
        if: always()
        with:
          name: .NET Tests
          path: '**/*.trx'
          reporter: dotnet-trx

How does GitHub Actions compare to other CI tools for .NET?

Choosing the right tool depends on your existing ecosystem, compliance needs, and budget. While GitHub Actions dominates for open-source and Azure-native shops, regulated environments sometimes require self-hosted alternatives.

FeatureGitHub ActionsAzure PipelinesGitLab CI
.NET Native SupportExcellent (first-party actions)Excellent (built-in tasks)Good (community templates)
OIDC / Passwordless AuthAzure, AWS, GCP nativeAzure native, others via service connectionsAzure/AWS/GCP via ID tokens
Self-Hosted RunnersYes (VM, container, ARC)Yes (scale sets, agents)Yes (runner manager)
Free Tier (Public)Unlimited minutes1 parallel job, 1800 min/mo400 compute min/mo
SOC 2 Audit TrailWorkflow logs + APIPipeline audit logs + extensionsCompliance dashboard built-in
Best ForGitHub-native repos, OSS, AzureEnterprise Azure shops, hybridIntegrated DevSecOps platform
Start: Choose CI ToolCode hosted on GitHub?YesNoNeed strict SOC 2 / On-prem?Azure DevOps Ecosystem?YesNoSelf-Hosted / GitLabMax control & auditGitHub ActionsFastest setup & OIDCAzure PipelinesDeep Azure integration
Decision framework for selecting CI/CD for ASP.NET Core based on hosting, compliance, and cloud provider alignment.

What are common mistakes to avoid in .NET GitHub Actions workflows?

After auditing dozens of .NET pipelines, these issues appear repeatedly:

  • Hardcoded connection strings in workflow files: Even if the repo is private, this violates every compliance framework. Use environment-scoped secrets or OIDC.
  • Skipping vulnerability scans on base images: Your application code may be clean, but mcr.microsoft.com/dotnet/aspnet:9.0 could have CVEs. Scan the final image, not just source.
  • Not tagging images immutably: Using :latest makes rollbacks impossible. Always tag with ${{ github.sha }} and optionally semantic version.
  • Running tests without isolation: Integration tests hitting shared staging databases cause flaky failures. Spin up ephemeral service containers per job.
  • Ignoring runner architecture: Building on ubuntu-24.04-arm but deploying to x64 hosts causes silent crashes. Match build and target architectures explicitly.

Next Steps for Production-Ready ASP.NET Core CI/CD

Implementing CI/CD for ASP.NET Core with GitHub Actions correctly means treating your pipeline as production infrastructure: version-controlled, tested, and secured. Start by migrating one service to OIDC authentication, enforce multi-stage Docker builds, and add Trivy scanning as a required status check. Monitor pipeline duration and failure rates as SLOs—if builds exceed 10 minutes or fail more than 5% of the time, investigate caching and test parallelization. When you're ready to harden your deployment strategy or need an audit-ready pipeline review, reach out to discuss your specific requirements.

Frequently Asked Questions

Create a workflow YAML file in .github/workflows targeting dotnet build and test commands. Configure triggers for push or pull requests to main branch. Add deployment steps using azure/webapps-deploy action for automated publishing to Azure App Service or container registry.

Use ubuntu-latest for faster, cheaper builds since .NET 8 and 9 run natively on Linux. Switch to windows-latest only if your project requires Windows-specific APIs, IIS configuration, or legacy .NET Framework dependencies that cannot run cross-platform.

Store connection strings and API keys as encrypted repository or environment secrets in GitHub Settings. Reference them via ${{ secrets.SECRET_NAME }} syntax in workflows. Never hardcode credentials in YAML files or commit appsettings.Production.json to source control.

Yes. Use the azure/login action with OpenID Connect federated credentials, then azure/webapps-deploy to publish your published output directly. This eliminates long-lived service principals and supports slot-based deployments for zero-downtime releases in production environments.

Use actions/cache with path ~/.nuget/packages and key based on global-packages hash from csproj files. This reduces restore time by sixty percent on subsequent runs. Alternatively, use the built-in cache-dependency-path parameter in setup-dotnet action.

Missing SDK version mismatches cause most failures. Specify exact dotnet-version in setup-dotnet. Other issues include missing environment variables, incorrect publish paths, case-sensitive Linux file references, and unconfigured database migrations during integration test execution stages.

Public repositories get unlimited minutes. Private repos receive two thousand monthly minutes on free plans. Linux runners consume one minute per minute; Windows runners consume two. Most ASP.NET Core teams stay within free tier using Linux containers efficiently.

Generate idempotent SQL scripts using dotnet ef migrations script --idempotent during build. Apply scripts via Azure SQL action or dedicated migration step before app deployment. Avoid running migrations at application startup in production to prevent race conditions and timeout failures.

Use Docker for consistent runtime environments and Kubernetes deployments. Use direct zip publish for simpler Azure App Service scenarios without container orchestration overhead. Docker adds thirty seconds to builds but eliminates environment drift between staging and production servers.

Spin up PostgreSQL or SQL Server using services section in workflow YAML. Configure test connection strings via environment variables pointing to localhost service ports. Run dotnet test after build step. Use Testcontainers library for more complex dependency orchestration needs.

Use trunk-based development with short-lived feature branches triggering CI-only workflows. Protect main branch with required status checks enforcing successful builds and tests. Deploy to staging automatically on merge, then promote to production via manual approval gate or tag trigger.

Enable NuGet caching, use Linux runners, parallelize independent jobs, and skip redundant restores with --no-restore after initial restore step. Target only changed projects in monorepos using dorny/paths-filter. Typical optimized ASP.NET Core pipelines complete under four minutes.

Yes. Use aws-actions/configure-aws-credentials with OIDC for ECS or Lambda deployments. For GCP, use google-github-actions/auth with Workload Identity Federation. Both support artifact upload and infrastructure-as-code provisioning through Terraform or Pulumi steps within same workflow.

Define separate GitHub Environments with unique secrets and protection rules. Use environment-specific job conditions and deployment gates. Pass environment name to webapps-deploy action. This isolates dev, staging, and production configurations while maintaining single reusable workflow definition across all targets.

Check deployment logs for swallowed exceptions in post-build hooks. Enable verbose logging with ACTIONS_RUNNER_DEBUG=true secret. Verify publish profile matches target runtime. Confirm Azure resource permissions include write access. Silent failures often stem from misconfigured health check endpoints returning false positives.