Lessons Learned beim Aufsetzen einer CI/CD Pipeline für ein .NET Backend und ein Svelte Frontend
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:
- GitHub Actions: Nativ in GitHub integriert, einfache YAML-Syntax, gute Marketplace mit vorgefertigten Actions
- GitLab CI: Leistungsstark, eigene Runners möglich, komplexere Syntax
- 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-latestder Fall) - SQL Server Container benötigt mindestens 2GB RAM – bei GitHub Actions kein Problem
- Die Umgebungsvariable
ACCEPT_EULA=Ymuss 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:
- Deploy zu Staging-Slot
- Staging-Slot warmed up (automatisch)
- Smoke-Tests gegen Staging-Slot
- Bei Erfolg: Instant-Swap (< 1 Sekunde Downtime)
- 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:
[](https://github.com/user/repo/actions/workflows/backend-ci.yml)
[](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":
- Linting (schnell, fängt viele Fehler)
- Type-Checking (schnell, TypeScript-Fehler)
- Build (mittel, Compile-Fehler)
- Unit-Tests (mittel, Logik-Fehler)
- Integration-Tests (langsam, Infrastruktur)
- 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.
actfü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:
- Caching ist kritisch: NuGet und pnpm Caching sparen 60-80% Build-Zeit
- Automatisierung > Perfektion: Lieber eine funktionierende 80%-Lösung als keine
- Tests sind nicht optional: Unit, Integration, E2E – alle haben ihren Platz
- Deployments sollten langweilig sein: Slots, Smoke-Tests, automatische Rollbacks
- Monitoring gibt Confidence: Sie müssen wissen, ob Ihr Deployment erfolgreich war
- Developer Experience zählt: Schnelles Feedback, klare Fehler, gute Dokumentation
- 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.