.NET Core

.NET CI/CD: Automatisiertes Deployment mit GitHub Actions und Azure DevOps

14 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
.NET CI/CDGitHub Actions .NETAzure DevOps .NET.NET deploymentPipeline .NETAutomated deployment C#Docker .NET CIRelease automation

Eine ausgereifte CI/CD-Pipeline ist die Grundlage fuer zuverlaessige .NET-Releases. Sie reduziert das Release-Risiko, verkuerzt Feedback-Zyklen fuer Entwickler und standardisiert die Deployment-Qualitaet. In diesem Artikel gehe ich die komplette Anatomie einer produktionsreifen Pipeline durch -- vom ersten Commit bis zum erfolgreichen Deployment, mit echten Konfigurationsbeispielen und Erfahrungswerten aus der Praxis.

Warum CI/CD fuer .NET-Projekte so wichtig ist

Manuelle Deployments sind fehleranfaellig. Ich habe Teams erlebt, die ganze Nachmittage damit verbracht haben, einem Deployment-Problem auf den Grund zu gehen -- und am Ende war es ein vergessener Build-Schritt oder eine Umgebungsvariable, die nur auf dem Rechner eines Entwicklers korrekt gesetzt war. CI/CD eliminiert diese gesamte Fehlerkategorie.

Eine gut gestaltete Pipeline bietet:

  • Reproduzierbarkeit -- jeder Build laeuft in derselben sauberen Umgebung
  • Geschwindigkeit -- automatisierte Schritte laufen parallel und reduzieren die Release-Zeit von Stunden auf Minuten
  • Vertrauen -- Tests und Scans laufen bei jedem Commit und fangen Probleme ab, bevor sie die Produktion erreichen
  • Nachvollziehbarkeit -- jedes Deployment ist auf einen bestimmten Commit und Pipeline-Lauf zurueckverfolgbar

Empfohlener Pipeline-Ablauf

Eine solide .NET CI/CD-Pipeline folgt diesen Phasen in der Reihenfolge:

  1. Abhaengigkeiten wiederherstellen und deterministisch bauen
  2. Unit- und Integrationstests ausfuehren
  3. Statische Analyse und Sicherheitsscans durchfuehren
  4. Signierte, versionierte Artefakte erzeugen
  5. Kontrolliert deployen mit Rollback-Strategie

Schauen wir uns an, wie jede Phase in der Praxis aussieht.

GitHub Actions Workflow fuer .NET

GitHub Actions ist mein bevorzugtes Werkzeug fuer die meisten .NET-Projekte. Die YAML-basierte Konfiguration lebt im Repository und ist damit versionskontrolliert und reviewbar wie jeder andere Code.

Hier ein vollstaendiger Workflow, der Build, Test, Publish und Deployment abdeckt:

yaml
name: .NET CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  DOTNET_VERSION: '8.0.x'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

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

      - name: .NET SDK einrichten
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Abhaengigkeiten wiederherstellen
        run: dotnet restore --locked-mode

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

      - name: Unit-Tests ausfuehren
        run: dotnet test --no-build --configuration Release --logger trx --results-directory TestResults/

      - name: Testergebnisse hochladen
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: TestResults/

      - name: Publish
        run: dotnet publish src/MyApp/MyApp.csproj --no-build --configuration Release --output ./publish

      - name: Build-Artefakt hochladen
        uses: actions/upload-artifact@v4
        with:
          name: app-artifact
          path: ./publish

  deploy-staging:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    environment: staging
    steps:
      - name: Artefakt herunterladen
        uses: actions/download-artifact@v4
        with:
          name: app-artifact
          path: ./publish

      - name: Auf Azure Web App deployen (Staging)
        uses: azure/webapps-deploy@v3
        with:
          app-name: myapp-staging
          publish-profile: ${{ secrets.AZURE_STAGING_PUBLISH_PROFILE }}
          package: ./publish

  deploy-production:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - name: Artefakt herunterladen
        uses: actions/download-artifact@v4
        with:
          name: app-artifact
          path: ./publish

      - name: Auf Azure Web App deployen (Production)
        uses: azure/webapps-deploy@v3
        with:
          app-name: myapp-production
          publish-profile: ${{ secrets.AZURE_PROD_PUBLISH_PROFILE }}
          package: ./publish

Ein paar wichtige Details. Das --locked-mode-Flag beim Restore stellt sicher, dass die packages.lock.json beachtet wird -- keine unerwarteten Paketversionsaenderungen waehrend der Pipeline. Das if: always() beim Upload der Testergebnisse bedeutet, dass Berichte auch bei fehlgeschlagenen Tests verfuegbar sind, was fuer die Fehlersuche unverzichtbar ist.

Azure DevOps Pipelines

Fuer Organisationen, die bereits in das Microsoft-Oekosystem investiert haben, ist Azure DevOps die natuerliche Wahl. Es bietet tiefere Integration mit Azure-Diensten, eingebaute Freigabe-Gates und feingranulare Zugriffskontrollen.

yaml
trigger:
  branches:
    include:
      - main
      - develop

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'
  dotnetVersion: '8.0.x'

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        steps:
          - task: UseDotNet@2
            inputs:
              packageType: 'sdk'
              version: $(dotnetVersion)

          - script: dotnet restore --locked-mode
            displayName: 'Abhaengigkeiten wiederherstellen'

          - script: dotnet build --configuration $(buildConfiguration) --no-restore
            displayName: 'Loesung bauen'

          - script: dotnet test --configuration $(buildConfiguration) --no-build --collect:"XPlat Code Coverage"
            displayName: 'Tests mit Coverage ausfuehren'

          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: '**/coverage.cobertura.xml'

          - script: dotnet publish src/MyApp/MyApp.csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)
            displayName: 'Anwendung publizieren'

          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: $(Build.ArtifactStagingDirectory)
              artifactName: 'app-drop'

  - stage: DeployStaging
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
    jobs:
      - deployment: DeployToStaging
        environment: 'staging'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'MyAzureServiceConnection'
                    appName: 'myapp-staging'
                    package: '$(Pipeline.Workspace)/app-drop'

In meinen Deployment-Pipelines richte ich zwischen Staging und Production in Azure DevOps immer Freigabe-Gates ein. Die eingebaute Environment-Approval-Funktion ist weitaus zuverlaessiger als eigene Skripte. Ausserdem liefert sie eine saubere Nachvollziehbarkeit, wer was wann freigegeben hat.

Docker Multi-Stage Build fuer .NET

Containerisierung ist heute der Standard fuer .NET-Deployments. Ein Multi-Stage Dockerfile haelt Images klein und den Build-Prozess sauber, indem es das SDK (Build-Werkzeuge) von der Runtime trennt.

dockerfile
# Phase 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Projektdateien zuerst kopieren fuer besseres Layer-Caching
COPY *.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
COPY src/MyApp.Domain/MyApp.Domain.csproj src/MyApp.Domain/
COPY tests/MyApp.Tests/MyApp.Tests.csproj tests/MyApp.Tests/
RUN dotnet restore

# Rest kopieren und bauen
COPY . .
RUN dotnet build --configuration Release --no-restore

# Phase 2: Test
FROM build AS test
RUN dotnet test --configuration Release --no-build --logger trx --results-directory /TestResults
# Bei fehlgeschlagenen Tests stoppt der Build hier

# Phase 3: Publish
FROM build AS publish
RUN dotnet publish src/MyApp/MyApp.csproj --configuration Release --no-build --output /app/publish /p:UseAppHost=false

# Phase 4: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app

# Nicht-Root-Benutzer erstellen
RUN adduser --disabled-password --gecos "" appuser
USER appuser

COPY --from=publish /app/publish .

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080

ENTRYPOINT ["dotnet", "MyApp.dll"]

Die entscheidende Optimierung besteht darin, .csproj-Dateien zu kopieren und dotnet restore auszufuehren, bevor der restliche Quellcode kopiert wird. Docker cached jede Schicht, sodass der Restore-Schritt komplett uebersprungen wird, wenn sich die Projektdateien nicht geaendert haben. In meinen Deployment-Pipelines hat dieser einzelne Trick die Build-Zeiten durchschnittlich um 40-60% verkuerzt.

Die Ausfuehrung als Nicht-Root-Benutzer in der letzten Phase ist eine bewaeahrte Sicherheitspraxis. Sie begrenzt den Wirkungsradius, falls die Anwendung kompromittiert wird.

Docker-Builds in CI integrieren

Fuegen Sie den Docker-Build-und-Push-Schritt Ihrem GitHub Actions Workflow hinzu:

yaml
  build-and-push-image:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Bei Container Registry anmelden
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker Image bauen und pushen
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

Taggen Sie Ihre Images neben latest immer auch mit dem Commit-SHA. Das macht Rollbacks trivial -- Sie deployen einfach den vorherigen SHA-Tag erneut.

Deployment-Strategien

Die Wahl der richtigen Deployment-Strategie haengt von der Ausfalltoleranz Ihrer Anwendung und dem Risikoniveau des Releases ab.

Blue/Green Deployment

Blue/Green verwendet zwei identische Umgebungen. Eine (Blue) bedient den Live-Traffic, waehrend die andere (Green) das neue Deployment erhaelt. Nach der Verifizierung wird der Traffic auf Green umgeschaltet.

Im Azure App Service bekommen Sie das mit Deployment Slots kostenlos:

yaml
  - name: In Staging-Slot deployen
    uses: azure/webapps-deploy@v3
    with:
      app-name: myapp-production
      slot-name: staging
      package: ./publish

  - name: Smoke-Tests gegen Staging-Slot ausfuehren
    run: |
      response=$(curl -s -o /dev/null -w "%{http_code}" https://myapp-production-staging.azurewebsites.net/health)
      if [ "$response" != "200" ]; then
        echo "Smoke-Test fehlgeschlagen mit Status $response"
        exit 1
      fi

  - name: Slots tauschen
    uses: azure/cli@v2
    with:
      inlineScript: |
        az webapp deployment slot swap \
          --resource-group myapp-rg \
          --name myapp-production \
          --slot staging \
          --target-slot production

Das Schoene am Slot-Swap: Wenn etwas schiefgeht, tauschen Sie einfach zurueck. Die vorherige Version laeuft noch im Staging-Slot.

Canary Deployment

Canary-Releases leiten einen kleinen Prozentsatz des Traffics auf die neue Version und erhoehen diesen Anteil schrittweise, wenn das Vertrauen waechst. Ideal fuer hochfrequentierte Dienste, bei denen selbst ein kurzer Ausfall inakzeptabel ist.

Mit Azure Front Door oder einem Service Mesh wie Istio koennen Sie Traffic-Splitting konfigurieren:

yaml
# Kubernetes Canary mit Istio VirtualService
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: myapp
spec:
  hosts:
    - myapp.example.com
  http:
    - route:
        - destination:
            host: myapp-stable
            port:
              number: 80
          weight: 90
        - destination:
            host: myapp-canary
            port:
              number: 80
          weight: 10

In meinen Deployment-Pipelines starte ich Canary-Rollouts mit 5% Traffic und erhoehe erst, nachdem ich Fehlerraten und Latenzen mindestens 15 Minuten beobachtet habe. Wenn sich eine Schluesselmetrik verschlechtert, rollt die Pipeline automatisch zurueck.

Rolling Deployment

Rolling Deployments aktualisieren Instanzen einzeln nacheinander. In Kubernetes ist dies die Standardstrategie:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  template:
    spec:
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:latest
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5

Die Readiness Probe ist hier entscheidend. Ohne sie koennte Kubernetes Traffic an einen Pod weiterleiten, der noch hochfaehrt.

Sicherheitsscans in der CI

Sicherheit darf kein nachtraeglicher Gedanke sein. Die Integration von Scans in die Pipeline faengt Schwachstellen ab, bevor sie die Produktion erreichen.

Abhaengigkeits-Schwachstellenscan

yaml
  - name: Auf bekannte Schwachstellen pruefen
    run: dotnet list package --vulnerable --include-transitive

  - name: NuGet Audit ausfuehren
    run: dotnet restore --force-evaluate /p:NuGetAudit=true /p:NuGetAuditLevel=moderate

Container-Image-Scan mit Trivy

yaml
  - name: Docker Image auf Schwachstellen scannen
    uses: aquasecurity/trivy-action@master
    with:
      image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
      format: 'sarif'
      output: 'trivy-results.sarif'
      severity: 'CRITICAL,HIGH'

  - name: Trivy-Scanergebnisse hochladen
    uses: github/codeql-action/upload-sarif@v3
    with:
      sarif_file: 'trivy-results.sarif'

Secret-Scanning

yaml
  - name: Code auf Secrets scannen
    uses: trufflesecurity/trufflehog@main
    with:
      extra_args: --only-verified

In meinen Deployment-Pipelines behandle ich Sicherheitsscan-Fehler als harte Blocker. Eine Pipeline, die bekannte kritische Schwachstellen in die Produktion laesst, ist schlimmer als keine Pipeline -- sie vermittelt ein falsches Sicherheitsgefuehl.

Haeufige CI/CD-Fehler

Im Laufe der Jahre habe ich immer wieder dieselben Fehler bei verschiedenen Teams beobachtet. Die folgenden richten den groessten Schaden an:

1. Action- und Image-Versionen nicht pinnen

yaml
# Schlecht -- kann ohne Vorwarnung kaputtgehen
uses: actions/checkout@main

# Gut -- auf bestimmte Version gepinnt
uses: actions/checkout@v4

Ungepinnte Versionen bedeuten, dass Ihre Pipeline an einem beliebigen Dienstag kaputtgehen kann, weil eine Upstream-Action ein Breaking Change veroeffentlicht hat. Pinnen Sie immer Versionen.

2. Secrets im Repository speichern

Es klingt offensichtlich, aber ich stosse immer noch auf Connection Strings und API-Keys, die ins Repository committed wurden. Nutzen Sie das Secret-Management Ihrer CI-Plattform. Bei GitHub Actions sind das Repository- oder Environment-Secrets. Bei Azure DevOps die Azure Key Vault-Integration.

3. Tests ueberspringen, um "Zeit zu sparen"

Manche Teams deaktivieren Tests in der Pipeline, weil sie langsam sind. Die richtige Loesung ist, die Tests schneller zu machen -- parallelisieren, Unit-Tests von Integrationstests trennen oder Test Impact Analysis verwenden. Niemals ueberspringen.

4. Kein Rollback-Plan

Jedes Deployment sollte einen getesteten Rollback-Mechanismus haben. Ob Slot-Swap, Kubernetes-Rollback oder erneutes Deployment des vorherigen Artefakts -- der Rollback-Pfad sollte automatisiert und regelmaessig erprobt sein.

5. Pipeline-Performance ignorieren

Eine Pipeline, die 45 Minuten dauert, toetet die Entwicklerproduktivitaet. Verfolgen Sie Ihre Pipeline-Ausfuehrungszeit und optimieren Sie konsequent. Haeufige Verbesserungen:

  • NuGet-Pakete zwischen Laeufen cachen
  • Unabhaengige Jobs parallel ausfuehren
  • Wo moeglich inkrementelle Builds nutzen
  • Die richtige Runner-Groesse waehlen
yaml
  - name: NuGet-Pakete cachen
    uses: actions/cache@v4
    with:
      path: ~/.nuget/packages
      key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
      restore-keys: |
        ${{ runner.os }}-nuget-

6. Dieselbe Pipeline fuer jeden Branch

Feature-Branches muessen nicht auf Staging deployt werden. Verwenden Sie bedingte Logik, um nur die relevanten Phasen auszufuehren:

yaml
deploy-staging:
  if: github.ref == 'refs/heads/develop'

deploy-production:
  if: github.ref == 'refs/heads/main'

Betriebliche Leitplanken

Ueber die Pipeline hinaus brauchen Sie Beobachtbarkeit in Ihrem Release-Prozess:

  • Deployment-Haeufigkeit -- wie oft deployen Sie. Hoeher ist normalerweise besser, aber nur wenn die Fehlerrate niedrig bleibt.
  • Change Failure Rate -- welcher Prozentsatz der Deployments Vorfaelle verursacht. Zielwert unter 5%.
  • Mean Time to Recovery (MTTR) -- wie schnell erholen Sie sich von einem fehlgeschlagenen Deployment. Automatische Rollbacks reduzieren dies drastisch.
  • Lead Time for Changes -- die Zeit vom Commit bis zur Produktion. Eine gesunde Pipeline haelt dies unter einer Stunde.

Verfolgen Sie diese Metriken und ueberpruefen Sie sie monatlich. Sie sagen mehr ueber die Liefergesundheit Ihres Teams aus als jedes einzelne Tool oder jede einzelne Konfiguration.

Fazit

Effektive CI/CD ist sowohl technische Automatisierung als auch Release-Governance. Eine gut aufgebaute Pipeline fuehrt nicht nur Befehle aus -- sie erzwingt Qualitaets-Gates, ermoeglicht sichere Deployments und gibt dem gesamten Team das Vertrauen, dass jedes Release produktionsreif ist. Die Investition in eine saubere Einrichtung zahlt sich bei jedem einzelnen Deployment aus.

Ich unterstuetze gern beim Entwurf einer produktionsreifen .NET-CI/CD-Pipeline.

Verwandte Artikel

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen