Lessons Learned beim Aufsetzen einer CI/CD Pipeline für ein .NET Backend und ein Svelte Frontend

📖 18 Min. Lesezeit
CI/CD .NET Svelte GitHub Actions DevOps Automatisierung]

Lessons Learned beim Aufsetzen einer CI/CD Pipeline für ein .NET Backend und ein Svelte Frontend

Continuous Integration und Continuous Deployment sind keine neuen Konzepte mehr – aber die erfolgreiche Implementierung einer robusten, wartbaren CI/CD Pipeline bleibt eine Herausforderung. Besonders bei modernen Architekturen mit getrenntem Backend und Frontend, verschiedenen Build-Systemen, unterschiedlichen Test-Frameworks und komplexen Deployment-Anforderungen.

In diesem Artikel teile ich die Lessons Learned aus einem Projekt, bei dem wir eine vollständige CI/CD Pipeline für ein .NET 8 Backend (ASP.NET Core Web API) und ein Svelte Frontend (mit SvelteKit) aufgesetzt haben. Was als "einfaches Pipeline-Setup" geplant war, entwickelte sich zu einem mehrwöchigen Learning-Prozess mit wertvollen Erkenntnissen über Build-Optimierung, Test-Automatisierung und Deployment-Strategien.

Projekthintergrund und Zielsetzung

Das Projekt war eine SaaS-Plattform für Projektmanagement mit folgenden Anforderungen:

  • Backend: ASP.NET Core 8 Web API mit Entity Framework Core, SQL Server-Datenbank
  • Frontend: SvelteKit mit TypeScript, TailwindCSS
  • Deployment: Azure App Services (Backend), Azure Static Web Apps (Frontend)
  • Team: 6 Entwickler, remote arbeitend
  • Release-Cadence-Ziel: Mehrmals täglich in Staging, wöchentlich in Production

Ausgangslage:

  • Manuelle Builds auf Entwickler-Laptops
  • Manuelle Tests ("klicken und hoffen")
  • Deployments via FTP (!!), meist Freitagnachmittags (!!!)
  • Lead Time: 2-3 Wochen
  • Change Failure Rate: ca. 40%
  • Developer Frustration: extrem hoch

Ziele für die CI/CD Pipeline:

  • Vollständige Build-Automatisierung für Backend und Frontend
  • Automatisierte Tests (Unit, Integration, E2E) mit Coverage-Reporting
  • Automatische Deployments nach Staging bei jedem Merge
  • Manuelle Freigabe für Production mit Smoke-Tests
  • Build-Zeit unter 5 Minuten
  • Zero-Downtime-Deployments

Technologiestack: Überblick und Entscheidungen

Backend-Stack

.NET 8 SDK
ASP.NET Core Web API
Entity Framework Core 8
SQL Server 2022
xUnit (Unit-Tests)
Testcontainers (Integration-Tests)
FluentAssertions
NSwag (OpenAPI/Swagger)
Serilog (Logging)

Frontend-Stack

SvelteKit 2.0
TypeScript 5.3
Vite 5.0
TailwindCSS 3.4
Vitest (Unit-Tests)
Playwright (E2E-Tests)
ESLint + Prettier
pnpm (Package Manager)

CI/CD-Tooling

Wir evaluierten drei Optionen:

  1. GitHub Actions: Nativ in GitHub integriert, einfache YAML-Syntax, gute Marketplace mit vorgefertigten Actions
  2. GitLab CI: Leistungsstark, eigene Runners möglich, komplexere Syntax
  3. Azure DevOps: Microsoft-native, enge Azure-Integration, aber separates Tooling

Entscheidung: GitHub Actions, weil:

  • Repository bereits auf GitHub
  • Team vertraut mit GitHub
  • Kostenlos für public repos, günstig für private
  • Exzellente Community und Marketplace
  • Einfache Syntax für schnellen Start

Diese Entscheidung erwies sich als richtig – GitHub Actions war produktiv nach 2 Tagen statt den geschätzten 2 Wochen für Azure DevOps.

Build-Automatisierung: Backend (.NET)

Der Backend-Build erschien zunächst relativ straightforward – die praktische Umsetzung offenbarte jedoch zahlreiche Herausforderungen in den Details. Von der korrekten Konfiguration der Build-Parameter über die Integration von Testcontainern bis hin zur Optimierung der Build-Zeiten mussten wir uns mit vielen technischen Feinheiten auseinandersetzen. Besonders die Entscheidung für SQL Server als Datenbank-Backend brachte spezifische Anforderungen mit sich, die sich durch alle Schichten der Pipeline zogen – von der lokalen Entwicklungsumgebung über die Testinfrastruktur bis hin zum Deployment.

Initiale Pipeline (naive Version)

name: Backend CI

on:
  push:
    branches: [ main, develop ]
    paths:
      - 'src/Backend/**'
  pull_request:
    branches: [ main, develop ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4

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

    - name: Restore dependencies
      run: dotnet restore src/Backend/ProjectName.sln

    - name: Build
      run: dotnet build src/Backend/ProjectName.sln --configuration Release --no-restore

    - name: Test
      run: dotnet test src/Backend/ProjectName.sln --no-build --verbosity normal

Problem: Diese Pipeline lief 8-12 Minuten. Unakzeptabel für schnelles Feedback.

Optimierte Pipeline (finale Version)

name: Backend CI/CD

on:
  push:
    branches: [ main, develop ]
    paths:
      - 'src/Backend/**'
      - '.github/workflows/backend-ci.yml'
  pull_request:
    branches: [ main, develop ]
    paths:
      - 'src/Backend/**'

env:
  DOTNET_VERSION: '8.0.x'
  SOLUTION_PATH: 'src/Backend/ProjectName.sln'
  BUILD_CONFIGURATION: 'Release'

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        fetch-depth: 0  # Für besseres Caching und SonarQube

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

    # KRITISCH: NuGet-Caching für 3-5x schnellere Builds
    - name: Cache NuGet packages
      uses: actions/cache@v3
      with:
        path: ~/.nuget/packages
        key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
        restore-keys: |
          ${{ runner.os }}-nuget-

    - name: Restore dependencies
      run: dotnet restore ${{ env.SOLUTION_PATH }}

    - name: Build
      run: dotnet build ${{ env.SOLUTION_PATH }} --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore

    # Tests parallel ausführen für 2x Speed
    - name: Run Unit Tests
      run: |
        dotnet test ${{ env.SOLUTION_PATH }} \
          --configuration ${{ env.BUILD_CONFIGURATION }} \
          --no-build \
          --filter "Category=Unit" \
          --logger "trx;LogFileName=unit-tests.trx" \
          --collect:"XPlat Code Coverage" \
          -- RunConfiguration.MaxCpuCount=2

    - name: Run Integration Tests
      run: |
        dotnet test ${{ env.SOLUTION_PATH }} \
          --configuration ${{ env.BUILD_CONFIGURATION }} \
          --no-build \
          --filter "Category=Integration" \
          --logger "trx;LogFileName=integration-tests.trx" \
          -- RunConfiguration.MaxCpuCount=1
      env:
        # Testcontainers braucht Docker
        DOCKER_HOST: unix:///var/run/docker.sock

    # Test-Ergebnisse als Artifact für Debugging
    - name: Upload Test Results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: |
          **/TestResults/*.trx
          **/TestResults/*/coverage.cobertura.xml

    # Coverage-Report generieren und hochladen
    - name: Generate Coverage Report
      uses: danielpalme/ReportGenerator-GitHub-Action@5.2.0
      with:
        reports: '**/TestResults/*/coverage.cobertura.xml'
        targetdir: 'coverage-report'
        reporttypes: 'HtmlInline;Cobertura;Badges'

    - name: Upload Coverage Report
      uses: actions/upload-artifact@v3
      with:
        name: coverage-report
        path: coverage-report/

    # Code-Quality-Check (optional aber empfohlen)
    - name: SonarCloud Scan
      if: github.event_name == 'pull_request'
      uses: SonarSource/sonarcloud-github-action@master
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
      with:
        args: >
          -Dsonar.projectKey=my-project
          -Dsonar.cs.opencover.reportsPaths=**/TestResults/*/coverage.cobertura.xml

    # Build Artifact für Deployment
    - name: Publish Application
      run: |
        dotnet publish src/Backend/ProjectName.API/ProjectName.API.csproj \
          --configuration ${{ env.BUILD_CONFIGURATION }} \
          --output ./publish \
          --no-build \
          --self-contained false

    - name: Upload Publish Artifact
      uses: actions/upload-artifact@v3
      with:
        name: backend-artifact
        path: ./publish/
        retention-days: 7

Verbesserungen:

  • NuGet-Caching: Reduzierte Restore-Zeit von 90s auf 5s
  • Parallele Tests: Unit-Tests laufen parallel (MaxCpuCount=2), Integration-Tests sequentiell
  • Conditional Steps: SonarCloud nur bei PRs, spart Zeit
  • Artifact-Upload: Debugging fehlgeschlagener Tests möglich
  • Path-Filtering: Pipeline läuft nur bei Backend-Änderungen

Ergebnis: Build-Zeit von 8-12 Minuten auf 3-4 Minuten reduziert.

Lesson Learned #1: NuGet Package Lock Files sind essentiell

Initial hatten wir kein packages.lock.json, was inkonsistente Builds verursachte. Die Lösung:

# In jedem .csproj:
dotnet restore --use-lock-file

# Commit der generierten packages.lock.json files
git add **/packages.lock.json
git commit -m "Add NuGet lock files for reproducible builds"

Dies ermöglichte nicht nur besseres Caching, sondern auch reproduzierbare Builds.

Lesson Learned #2: Integration-Tests mit Testcontainers

Für Integration-Tests brauchten wir eine echte SQL Server-Datenbank, die der Production-Umgebung möglichst nahe kommt. Testcontainers erwies sich als ideale Lösung, um isolierte, reproduzierbare Testumgebungen zu schaffen. Anders als bei Mock-Implementierungen testen wir damit gegen eine echte Datenbank-Instanz, was SQL Server-spezifische Features wie Transaktionsverhalten, Indizes, gespeicherte Prozeduren und Constraints vollständig abdeckt.

Die Verwendung von SQL Server in Testcontainern bringt einige besondere Überlegungen mit sich:

  • Lizenzierung: Wir nutzen das offizielle SQL Server Docker-Image mit Developer-Edition (kostenlos für Entwicklung/Test)
  • Ressourcen: SQL Server benötigt mindestens 2GB RAM – GitHub Actions Runner haben ausreichend Kapazität
  • Startup-Zeit: SQL Server startet langsamer als leichtgewichtige Datenbanken, daher optimieren wir durch Container-Wiederverwendung wo möglich
public class IntegrationTestBase : IAsyncLifetime
{
    private readonly MsSqlContainer _dbContainer;
    protected string ConnectionString { get; private set; }

    public IntegrationTestBase()
    {
        _dbContainer = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithPassword("YourStrong!Passw0rd")
            .WithEnvironment("ACCEPT_EULA", "Y")
            .WithEnvironment("MSSQL_SA_PASSWORD", "YourStrong!Passw0rd")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        ConnectionString = _dbContainer.GetConnectionString();

        // Run migrations - wichtig für SQL Server-spezifische Features
        await using var context = new ApplicationDbContext(
            new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlServer(ConnectionString)
                .Options);
        await context.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await _dbContainer.DisposeAsync();
    }

    protected ApplicationDbContext CreateContext(string connectionString)
    {
        return new ApplicationDbContext(
            new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlServer(connectionString)
                .EnableSensitiveDataLogging() // Nur für Tests
                .Options);
    }
}

[Collection("Database")]
[Trait("Category", "Integration")]
public class ProjectRepositoryTests : IntegrationTestBase
{
    [Fact]
    public async Task CreateProject_ShouldPersistToDatabase()
    {
        // Arrange
        await using var context = CreateContext(ConnectionString);
        var repository = new ProjectRepository(context);
        var project = new Project { Name = "Test Project" };

        // Act
        await repository.CreateAsync(project);
        await context.SaveChangesAsync();

        // Assert
        var savedProject = await repository.GetByIdAsync(project.Id);
        savedProject.Should().NotBeNull();
        savedProject.Name.Should().Be("Test Project");
    }

    [Fact]
    public async Task QueryWithSqlServerSpecificFeatures_ShouldWork()
    {
        // Arrange
        await using var context = CreateContext(ConnectionString);
        var repository = new ProjectRepository(context);

        // Test SQL Server-spezifische Features wie Temporal Tables, JSON-Funktionen, etc.
        var projects = await context.Projects
            .Where(p => EF.Functions.Like(p.Name, "%Test%"))
            .ToListAsync();

        // Assert
        projects.Should().NotBeNull();
    }
}

Wichtig für GitHub Actions:

  • Docker muss verfügbar sein (ist standardmäßig auf ubuntu-latest der Fall)
  • SQL Server Container benötigt mindestens 2GB RAM – bei GitHub Actions kein Problem
  • Die Umgebungsvariable ACCEPT_EULA=Y muss gesetzt werden für die SQL Server-Lizenz
  • Passwörter müssen die SQL Server-Komplexitätsanforderungen erfüllen

Performance-Optimierung: Wir haben die Integration-Test-Suite in mehrere Collections aufgeteilt, um Container-Wiederverwendung zu ermöglichen:

[CollectionDefinition("Database Collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
    // Diese Klasse bleibt leer - sie dient nur als Marker für xUnit
}

public class DatabaseFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container;
    public string ConnectionString { get; private set; }

    public DatabaseFixture()
    {
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithPassword("YourStrong!Passw0rd")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        ConnectionString = _container.GetConnectionString();
    }

    public async Task DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

// Alle Tests in dieser Collection teilen sich denselben SQL Server Container
[Collection("Database Collection")]
public class ProjectRepositoryTests : IAsyncLifetime
{
    private readonly DatabaseFixture _fixture;
    private ApplicationDbContext _context;

    public ProjectRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    public async Task InitializeAsync()
    {
        _context = new ApplicationDbContext(
            new DbContextOptionsBuilder<ApplicationDbContext>()
                .UseSqlServer(_fixture.ConnectionString)
                .Options);
        await _context.Database.EnsureCreatedAsync();
    }

    public async Task DisposeAsync()
    {
        // Cleanup nach jedem Test - Datenbank zurücksetzen
        await _context.Database.EnsureDeletedAsync();
        await _context.DisposeAsync();
    }

    // Tests hier...
}

Diese Optimierung reduzierte unsere Integration-Test-Laufzeit von 8 Minuten auf 3 Minuten, da der SQL Server Container nur einmal pro Test-Collection gestartet wird statt für jeden einzelnen Test.

Build-Automatisierung: Frontend (Svelte)

Das Frontend war komplexer als erwartet – besonders wegen Node.js-Version-Kompatibilität und Dependency-Management.

Finale Frontend-Pipeline

name: Frontend CI/CD

on:
  push:
    branches: [ main, develop ]
    paths:
      - 'src/Frontend/**'
      - '.github/workflows/frontend-ci.yml'
  pull_request:
    branches: [ main, develop ]
    paths:
      - 'src/Frontend/**'

env:
  NODE_VERSION: '20.x'
  PNPM_VERSION: '8.15.0'

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ env.NODE_VERSION }}

    # pnpm ist 2-3x schneller als npm
    - name: Setup pnpm
      uses: pnpm/action-setup@v2
      with:
        version: ${{ env.PNPM_VERSION }}

    # KRITISCH: pnpm Store Caching für 5-10x schnellere Installs
    - name: Get pnpm store directory
      shell: bash
      run: |
        echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

    - name: Setup pnpm cache
      uses: actions/cache@v3
      with:
        path: ${{ env.STORE_PATH }}
        key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-pnpm-store-

    - name: Install dependencies
      working-directory: src/Frontend
      run: pnpm install --frozen-lockfile

    # Linting vor dem Build (Fail-Fast)
    - name: Run ESLint
      working-directory: src/Frontend
      run: pnpm run lint

    - name: Run Prettier Check
      working-directory: src/Frontend
      run: pnpm run format:check

    # Type-Checking (TypeScript)
    - name: TypeScript Check
      working-directory: src/Frontend
      run: pnpm run check

    # Build (generiert optimierte Production-Build)
    - name: Build
      working-directory: src/Frontend
      run: pnpm run build
      env:
        PUBLIC_API_URL: ${{ secrets.API_URL_STAGING }}

    # Unit-Tests mit Vitest
    - name: Run Unit Tests
      working-directory: src/Frontend
      run: pnpm run test:unit -- --coverage

    - name: Upload Coverage
      uses: codecov/codecov-action@v3
      with:
        files: src/Frontend/coverage/coverage-final.json
        flags: frontend

    # Build-Artifact für Deployment
    - name: Upload Build Artifact
      uses: actions/upload-artifact@v3
      with:
        name: frontend-artifact
        path: src/Frontend/build/
        retention-days: 7

  # E2E-Tests in separatem Job (können parallel laufen)
  e2e-tests:
    runs-on: ubuntu-latest
    needs: build-and-test  # Nur wenn Build erfolgreich

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ env.NODE_VERSION }}

    - name: Setup pnpm
      uses: pnpm/action-setup@v2
      with:
        version: ${{ env.PNPM_VERSION }}

    - name: Install dependencies
      working-directory: src/Frontend
      run: pnpm install --frozen-lockfile

    # Playwright-Browser installieren (gecached für schnellere Runs)
    - name: Install Playwright Browsers
      working-directory: src/Frontend
      run: pnpm exec playwright install --with-deps chromium

    - name: Run E2E Tests
      working-directory: src/Frontend
      run: pnpm run test:e2e
      env:
        # E2E-Tests gegen Staging-API
        PUBLIC_API_URL: ${{ secrets.API_URL_STAGING }}

    # Bei Fehlern: Screenshots/Videos als Artifact
    - name: Upload Playwright Report
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: playwright-report
        path: src/Frontend/playwright-report/
        retention-days: 7

Lesson Learned #3: pnpm > npm für CI/CD

Wir starteten mit npm, wechselten zu pnpm und sahen dramatische Verbesserungen:

Metrik npm pnpm Verbesserung
Install-Zeit (ohne Cache) 85s 28s 67%
Install-Zeit (mit Cache) 22s 4s 82%
Disk-Space 450MB 180MB 60%

Setup in pnpm:

# Einmalig im Projekt
npm install -g pnpm
pnpm install  # Generiert pnpm-lock.yaml

# Im CI: pnpm/action-setup@v2 verwenden

Lesson Learned #4: Environment-spezifische Builds

SvelteKit kompiliert Environment-Variablen zur Build-Zeit. Initial bauten wir für jede Umgebung neu – ineffizient.

Bessere Lösung:

// src/Frontend/src/lib/config.ts
export const config = {
  apiUrl: import.meta.env.PUBLIC_API_URL || 'http://localhost:5000',
  environment: import.meta.env.PUBLIC_ENVIRONMENT || 'development',
  version: import.meta.env.PUBLIC_VERSION || 'dev'
};
# Separate Build-Jobs für Staging und Production
build-staging:
  env:
    PUBLIC_API_URL: https://api-staging.example.com
    PUBLIC_ENVIRONMENT: staging

build-production:
  env:
    PUBLIC_API_URL: https://api.example.com
    PUBLIC_ENVIRONMENT: production

Deployment-Automatisierung

Builds ohne Deployments sind nutzlos. Hier unsere Deployment-Strategie:

Backend-Deployment zu Azure App Service

name: Deploy Backend to Azure

on:
  workflow_run:
    workflows: ["Backend CI/CD"]
    types:
      - completed
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    strategy:
      matrix:
        environment:
          - name: staging
            azure_app_name: myapp-api-staging
            slot_name: production
          - name: production
            azure_app_name: myapp-api-prod
            slot_name: staging  # Deploy to slot, then swap

    environment:
      name: ${{ matrix.environment.name }}
      url: https://${{ matrix.environment.azure_app_name }}.azurewebsites.net

    steps:
    - name: Download Artifact
      uses: actions/download-artifact@v3
      with:
        name: backend-artifact
        path: ./publish

    - name: Login to Azure
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}

    - name: Deploy to Azure App Service
      uses: azure/webapps-deploy@v2
      with:
        app-name: ${{ matrix.environment.azure_app_name }}
        slot-name: ${{ matrix.environment.slot_name }}
        package: ./publish

    # Nur für Production: Smoke-Tests vor Slot-Swap
    - name: Run Smoke Tests
      if: matrix.environment.name == 'production'
      run: |
        chmod +x ./scripts/smoke-tests.sh
        ./scripts/smoke-tests.sh https://${{ matrix.environment.azure_app_name }}-staging.azurewebsites.net

    # Swap nur für Production nach erfolgreichen Smoke-Tests
    - name: Swap Slots (Production only)
      if: matrix.environment.name == 'production'
      run: |
        az webapp deployment slot swap \
          --resource-group myapp-rg \
          --name ${{ matrix.environment.azure_app_name }} \
          --slot ${{ matrix.environment.slot_name }} \
          --target-slot production

    - name: Logout from Azure
      run: az logout

Lesson Learned #5: Deployment Slots für Zero-Downtime

Azure App Service Slots ermöglichen Zero-Downtime-Deployments:

  1. Deploy zu Staging-Slot
  2. Staging-Slot warmed up (automatisch)
  3. Smoke-Tests gegen Staging-Slot
  4. Bei Erfolg: Instant-Swap (< 1 Sekunde Downtime)
  5. Bei Fehler: Kein Swap, Production bleibt unberührt

Smoke-Tests-Script:

#!/bin/bash
# scripts/smoke-tests.sh

BASE_URL=$1
TIMEOUT=30
RETRY_COUNT=5

echo "Running smoke tests against $BASE_URL"

# Test 1: Health Check
for i in $(seq 1 $RETRY_COUNT); do
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/health")
  if [ "$HTTP_CODE" -eq 200 ]; then
    echo "✓ Health check passed"
    break
  fi
  if [ $i -eq $RETRY_COUNT ]; then
    echo "✗ Health check failed after $RETRY_COUNT attempts"
    exit 1
  fi
  echo "Health check attempt $i failed, retrying..."
  sleep 5
done

# Test 2: API Endpoint
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/projects")
if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 401 ]; then
  echo "✓ API endpoint accessible"
else
  echo "✗ API endpoint returned $HTTP_CODE"
  exit 1
fi

# Test 3: Database Connectivity
RESPONSE=$(curl -s "$BASE_URL/health/database")
if echo "$RESPONSE" | grep -q "healthy"; then
  echo "✓ Database connection healthy"
else
  echo "✗ Database connection failed"
  exit 1
fi

echo "All smoke tests passed!"
exit 0

Frontend-Deployment zu Azure Static Web Apps

name: Deploy Frontend to Azure

on:
  workflow_run:
    workflows: ["Frontend CI/CD"]
    types:
      - completed
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
    - name: Checkout code
      uses: actions/checkout@v4

    - name: Download Artifact
      uses: actions/download-artifact@v3
      with:
        name: frontend-artifact
        path: ./build

    - name: Deploy to Azure Static Web Apps
      uses: Azure/static-web-apps-deploy@v1
      with:
        azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
        repo_token: ${{ secrets.GITHUB_TOKEN }}
        action: "upload"
        app_location: "./build"
        skip_app_build: true  # Bereits gebaut

Typische Fehlerquellen und Troubleshooting

Hier die häufigsten Probleme, auf die wir stießen, und wie wir sie lösten:

Problem 1: "It works on my machine" – Build-Inkonsistenzen

Symptom: Build erfolgreich lokal, fehlgeschlagen in CI.

Root Cause: Unterschiedliche .NET SDK-Versionen.

Lösung: global.json im Repo-Root:

{
  "sdk": {
    "version": "8.0.100",
    "rollForward": "latestPatch"
  }
}

Dadurch wird lokal und in CI exakt dieselbe SDK-Version verwendet.

Problem 2: Flaky E2E-Tests

Symptom: E2E-Tests schlagen intermittierend fehl.

Root Cause: Race-Conditions, fehlende Waits.

Lösung: Playwright Auto-Waiting nutzen, explizite Waits für API-Calls:

// ❌ Schlecht: Hartcodierte Waits
await page.click('button#submit');
await page.waitForTimeout(2000);  // Flaky!

// ✅ Gut: Auf bestimmten State warten
await page.click('button#submit');
await page.waitForSelector('.success-message');
await expect(page.locator('.success-message')).toBeVisible();

// ✅ Noch besser: Auf Netzwerk-Request warten
await Promise.all([
  page.waitForResponse(resp =>
    resp.url().includes('/api/projects') && resp.status() === 200
  ),
  page.click('button#submit')
]);

Problem 3: Secrets in Logs

Symptom: Versehentlich API-Keys in Logs geloggt.

Root Cause: Unvorsichtiges Logging von Konfiguration.

Lösung: GitHub Actions maskiert automatisch Secrets, aber trotzdem vorsichtig:

# ❌ Gefährlich
- name: Debug
  run: echo "API Key: ${{ secrets.API_KEY }}"

# ✅ Sicher
- name: Debug
  run: echo "API Key is set: ${{ secrets.API_KEY != '' }}"

Problem 4: Zu lange Build-Zeiten trotz Caching

Symptom: Cache wird nicht getroffen.

Root Cause: Cache-Key zu spezifisch oder zu generisch.

Lösung: Hierarchisches Caching mit Fallbacks:

- name: Cache NuGet packages
  uses: actions/cache@v3
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
      ${{ runner.os }}-nuget-

Wenn exakter Hash nicht matched, fällt es auf weniger spezifischen Key zurück.

Problem 5: Database-Migrations in Production

Symptom: Deployments schlagen fehl wegen Database-Schema-Änderungen.

Lösung: Migrations als separater Schritt vor Deployment:

- name: Run Database Migrations
  run: |
    dotnet ef database update \
      --project src/Backend/ProjectName.Infrastructure \
      --startup-project src/Backend/ProjectName.API \
      --connection "${{ secrets.DB_CONNECTION_STRING }}"
  env:
    ASPNETCORE_ENVIRONMENT: Production

Wichtig: Migrations müssen backward-compatible sein (Expand-Contract-Pattern).

Monitoring und Feedback-Mechanismen

Eine Pipeline ohne Monitoring ist blind. Wir implementierten:

1. GitHub Actions Dashboard

Status-Badges im README für sofortige Sichtbarkeit:

[![Backend CI/CD](https://github.com/user/repo/actions/workflows/backend-ci.yml/badge.svg)](https://github.com/user/repo/actions/workflows/backend-ci.yml)
[![Frontend CI/CD](https://github.com/user/repo/actions/workflows/frontend-ci.yml/badge.svg)](https://github.com/user/repo/actions/workflows/frontend-ci.yml)

2. Slack-Notifikationen bei Failures

- name: Notify Slack on Failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "❌ Backend CI failed on ${{ github.ref }}",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Pipeline Failed*\n*Branch:* ${{ github.ref }}\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

3. Application Insights für Production-Deployments

Nach jedem Production-Deployment loggen wir ein Custom-Event:

public class DeploymentTracker
{
    private readonly TelemetryClient _telemetry;

    public void TrackDeployment(string version, string environment)
    {
        _telemetry.TrackEvent("Deployment", new Dictionary<string, string>
        {
            { "Version", version },
            { "Environment", environment },
            { "Timestamp", DateTime.UtcNow.ToString("O") },
            { "DeployedBy", "CI/CD Pipeline" }
        });
    }
}

// In Startup.cs oder Program.cs
var deploymentTracker = app.Services.GetRequiredService<DeploymentTracker>();
deploymentTracker.TrackDeployment(
    version: Assembly.GetExecutingAssembly().GetName().Version.ToString(),
    environment: app.Environment.EnvironmentName
);

Dadurch können wir in Application Insights sehen, wann deployed wurde und Fehler-Spikes mit Deployments korrelieren.

Lessons Learned: Best Practices und Empfehlungen

Nach 6 Monaten mit dieser Pipeline hier die wichtigsten Erkenntnisse:

1. Start Simple, Iterate

Versuchen Sie nicht, die perfekte Pipeline von Tag 1 zu bauen. Unsere Evolution:

  • Woche 1: Basic Build + Test
  • Woche 2: Caching hinzugefügt
  • Woche 3: Deployment zu Staging
  • Woche 4: E2E-Tests integriert
  • Monat 2: Production-Deployment mit Slots
  • Monat 3: Monitoring, Alerts, Coverage-Reporting

2. Fail Fast, Fail Loud

Ordnen Sie Pipeline-Schritte nach "Wahrscheinlichkeit zu failen":

  1. Linting (schnell, fängt viele Fehler)
  2. Type-Checking (schnell, TypeScript-Fehler)
  3. Build (mittel, Compile-Fehler)
  4. Unit-Tests (mittel, Logik-Fehler)
  5. Integration-Tests (langsam, Infrastruktur)
  6. E2E-Tests (sehr langsam, UI/UX)

So bekommen Entwickler schnelles Feedback.

3. Treat Pipeline Code wie Production Code

Pipeline-YAML ist Code. Behandeln Sie es entsprechend:

  • Code-Reviews für Pipeline-Änderungen
  • Versionskontrolle (natürlich)
  • Testing (ja, Sie können Pipelines testen!)
  • Dokumentation (inline-Kommentare in YAML)

4. Messen Sie Pipeline-Performance

Tracken Sie:

  • Build-Zeit (Ziel: < 5 Minuten)
  • Cache-Hit-Rate (Ziel: > 80%)
  • Test-Flakiness (Ziel: < 1%)
  • Deployment-Frequenz (unser Ziel: täglich)
  • Lead Time (Commit bis Production)

Wir nutzen ein einfaches Script, das diese Metriken aus GitHub Actions API extrahiert und in ein Dashboard pusht.

5. Invest in Developer Experience

Features, die sich lohnten:

  • Branch-Protection-Rules: Main-Branch nur mergebar nach erfolgreicher Pipeline
  • Auto-Merge für Dependabot: Nach erfolgreichen Tests
  • Review-Apps: Für jeden PR ein ephemeres Environment (via Azure Container Instances)
  • Performance-Budget-Check: E2E-Tests schlagen fehl, wenn Bundle-Size > 500KB

6. Documentation Matters

Wir haben ein CI_CD.md im Repo mit:

  • Architektur-Übersicht der Pipeline
  • Wie man lokal testet (inkl. act für lokale GitHub Actions)
  • Troubleshooting-Guide
  • Secrets-Dokumentation (welches Secret wofür)
  • Runbook für Pipeline-Failures

Das reduzierte "Wie funktioniert das?"-Fragen drastisch.

7. Security First

  • Secret-Scanning: GitHub Secret-Scanning aktiviert
  • Dependency-Scanning: Dependabot Alerts für vulnerabilities
  • SAST: SonarCloud für statische Code-Analyse
  • Container-Scanning: Wenn Sie Docker nutzen, scannen Sie Images (Trivy, Snyk)

Messbare Ergebnisse nach 6 Monaten

Die Investition in CI/CD hat sich ausgezahlt:

Vorher (manuelle Prozesse):

  • Deployment-Frequenz: 2x monatlich
  • Lead Time (Commit → Production): 2-3 Wochen
  • Change Failure Rate: ~40%
  • Mean Time to Recovery: 4-6 Stunden
  • Developer-Satisfaction: 4/10

Nachher (automatisierte Pipeline):

  • Deployment-Frequenz: 15x wöchentlich (täglich zu Staging, 2-3x wöchentlich zu Production)
  • Lead Time: 2-4 Stunden
  • Change Failure Rate: 8%
  • Mean Time to Recovery: 20-40 Minuten (dank Rollback-Automatisierung)
  • Developer-Satisfaction: 8.5/10

Business-Impact:

  • Time-to-Market: 85% schneller
  • Bugs in Production: -72%
  • Developer-Produktivität: +35% (weniger Zeit für manuelle Deployments, mehr für Features)
  • Kosten: CI/CD-Infrastruktur €280/Monat, Einsparung durch Effizienz: geschätzt €12.000/Monat

Fazit

Das Aufsetzen einer robusten CI/CD Pipeline für ein .NET Backend und Svelte Frontend war kein Zwei-Tage-Projekt, sondern ein iterativer Lernprozess über mehrere Monate. Aber die Investition hat sich vielfach ausgezahlt.

Key Takeaways:

  1. Caching ist kritisch: NuGet und pnpm Caching sparen 60-80% Build-Zeit
  2. Automatisierung > Perfektion: Lieber eine funktionierende 80%-Lösung als keine
  3. Tests sind nicht optional: Unit, Integration, E2E – alle haben ihren Platz
  4. Deployments sollten langweilig sein: Slots, Smoke-Tests, automatische Rollbacks
  5. Monitoring gibt Confidence: Sie müssen wissen, ob Ihr Deployment erfolgreich war
  6. Developer Experience zählt: Schnelles Feedback, klare Fehler, gute Dokumentation
  7. Sicherheit von Anfang an: Secret-Management, Dependency-Scanning, SAST

Wenn Sie eine ähnliche Pipeline aufsetzen: Starten Sie einfach, iterieren Sie schnell, messen Sie konsequent, und scheuen Sie sich nicht, Dinge zu ändern, wenn sie nicht funktionieren.

Ihre Entwickler werden es Ihnen danken – und Ihre Kunden werden schneller bessere Features bekommen.

← Zurück zu allen Publikationen