All articles

Modern .NET Backend Roadmap — Part 8: CI/CD with GitHub Actions

Automate build, smoke test, image, and deployment with GitHub Actions: SHA-tagged images, OIDC federation to Azure, environments with approvals, and pipeline hygiene.

0 · log in to like, save & follow Share on LinkedIn Share on X

Every manual deployment is a small bet that you'll remember all the steps, in order, under pressure. CI/CD replaces that bet with a pipeline: every push builds, tests, and packages the same way; releases become boring. This part wires GitHub Actions for the roadmap API — a CI workflow that builds, smoke-tests, and produces the Docker image, then the deploy job that ships it to Azure from part 7.

Modern .NET Backend Roadmap — Part 8: CI/CD with GitHub Actions

The CI workflow

.github/workflows/ci.yml in the repository root:

name: ci

on:
  push:
    branches: [master]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 10.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore -c Release

      - name: Smoke test the API
        run: |
          dotnet run --project src/Roadmap.Api -c Release --no-build &
          sleep 8
          curl -sf http://localhost:5152/products | grep "Mechanical keyboard"

      - name: Docker image
        run: docker build -t roadmap-api .

Design notes:

  • pull_request + push triggers — PRs get the full check before merge; master gets it again on the real merge commit.
  • A smoke test beats no test. Unit tests belong here too (dotnet test), but even this curl — boot the app, hit an endpoint, assert on the payload — catches the "builds fine, crashes on startup" class that unit tests miss. It exercises DI registration, EF model building, and seeding in one line.
  • The Docker build runs in CI even before you deploy from CI, because part 6 taught us image builds fail for environment reasons (.dockerignore, restore state). Find out on the runner, not on release day.

One practical note from setting this up: pushing workflow files requires an OAuth token with the workflow scope — if your gh login predates that scope, gh auth refresh -h github.com -s workflow fixes the 403.

Adding the deploy job

Deployment extends the same file. The modern way to let GitHub talk to Azure is OpenID Connect federation — no long-lived password stored in GitHub at all; Azure trusts tokens GitHub mints per run:

  deploy:
    needs: build-test
    if: github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Build and push image
        run: az acr build --registry roadmapacr --image roadmap-api:${{ github.sha }} .

      - name: Deploy revision
        run: |
          az containerapp update --name roadmap-api --resource-group roadmap-rg \
            --image roadmapacr.azurecr.io/roadmap-api:${{ github.sha }}

The load-bearing details:

  • needs: build-test + the branch if — deploys only happen for master commits that passed CI. PRs build; they never deploy.
  • github.sha as the image tag. Every deployment maps to exactly one commit. "What's running in prod?" becomes az containerapp show + git show. Never deploy :latest — it's a tag that answers no questions.
  • The three AZURE_* values are identifiers, not secrets (client/tenant/subscription IDs); the trust lives in the federated credential you create once on an Entra app registration, scoped to this repo and branch.

Rollback falls out for free: redeploy the previous SHA, or shift traffic to the previous Container Apps revision.

Environments and approvals

GitHub Environments add the governance layer when you need it: attach the deploy job to environment: production, and you can require a human approval click, restrict which branches may target it, and scope secrets per environment. A staging job deploying automatically + a production job requiring approval is the 80% pattern:

    environment: production   # approval rules configured in repo settings

Real tests in the pipeline

The smoke test guards startup; unit and integration tests guard behavior. The fuller job most teams converge on:

      - name: Unit tests
        run: dotnet test tests/Roadmap.UnitTests -c Release --no-build --logger trx

      - name: Integration tests
        run: dotnet test tests/Roadmap.IntegrationTests -c Release --no-build
        env:
          ConnectionStrings__Default: "Host=localhost;Database=roadmap_test;Username=postgres;Password=postgres"

with a service container supplying the database:

    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: roadmap_test }
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-retries 10

Because part 1 made the domain and application layers infrastructure-free, the unit-test job needs nothing but the runner — it finishes in seconds and gates every PR. The integration job runs real EF migrations and Dapper SQL against disposable Postgres; slower, but it's the only thing that catches a renamed column before production does. Publish results with dorny/test-reporter so failures annotate the PR diff instead of hiding in logs.

When the pipeline fails: debugging CI

CI failures split into three families, each with a fast diagnostic:

  • Fails in CI, passes locally. Almost always environment: a file the runner doesn't have (check .gitignore against what the build needs), case-sensitive paths (Linux runners care; macOS doesn't — our GeeksArray.Ats vs GeeksArray.ATS casing bug shipped exactly this way), or a missing env var. git clean -xdf && dotnet build locally reproduces most of them.
  • Flaky. Timing-dependent tests, port collisions, external calls. Quarantine the test the day it first flakes — a suite people re-run on red is a suite nobody trusts, and its signal is already gone.
  • Slow creep. Pipelines gain steps and lose minutes. Budget it like a feature: cache restore, split jobs to run in parallel, and alert when total time crosses your threshold (10 minutes is a good line for a service this size).

gh run view --log-failed pulls just the failing step's log to your terminal — faster than clicking through the web UI, and scriptable when you're fixing a red main branch under pressure.

Pipeline hygiene that pays compound interest

  • Cache NuGet (actions/setup-dotnet with cache: true) — restore drops from minutes to seconds.
  • Fail fast, fail loud. curl -sf (the -f) makes non-2xx responses fail the step; without it your smoke test is decorative.
  • Keep the workflow in the repo, reviewed like code — it is the release process, and PR review is your change-management.
  • Concurrency guards (concurrency: production-deploy) stop two merges racing each other mid-deploy.

Database migrations in the pipeline

The step everyone defers: schema changes must ship with code changes, safely. Three workable strategies, in order of increasing rigor:

  1. Migrate on startup (db.Database.Migrate() behind a flag) — fine for single-replica apps; dangerous with multiple replicas racing to migrate. If you use it, gate it so only one instance runs migrations (an init job, or a startup lock table).
  2. A migration job in the pipeline — the deploy job runs dotnet ef database update (or applies an idempotent SQL script generated by dotnet ef migrations script --idempotent) before swapping traffic to the new revision. The script variant is auditable and doesn't need the SDK on the target.
  3. Expand–contract for zero-downtime — additive migration first (new nullable column), deploy code that writes both, backfill, then a later contract migration removes the old shape. This is the only strategy that survives rolling deployments where old and new code briefly coexist — which, with Container Apps revisions, is every deployment.

Whichever you choose, one rule is absolute: the pipeline, not a human with a connection string, applies production schema changes. The migration script in the artifact + the SHA-tagged image means any environment can be reconstructed to a known pair of (code, schema) — which is what turns "the deploy broke" from archaeology into a diff.

The goal state is cultural, not technical: deploys stop being events. geeksarray.com ships this way — a push builds the image on the server and swaps the container; publishing a code change and publishing a blog post are comparably dull.

Next

One well-shipped service is the right place to pause and ask the uncomfortable question: should it become several? Part 9 tackles microservices — what they buy, what they cost, and why the modular monolith we've been building is the best launchpad either way.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.