Previous article: ASP.NET Core Docker Kubernetes Deployment Guide | Cloud-Native DevOps (Part-32 of 40)
1. Complete Project Structure & Configuration
Solution Structure
src/
├── MyApp.API/
│ ├── Controllers/
│ ├── Program.cs
│ ├── MyApp.API.csproj
│ └── appsettings.json
├── MyApp.Core/
│ ├── Entities/
│ ├── Interfaces/
│ └── MyApp.Core.csproj
├── MyApp.Infrastructure/
│ ├── Data/
│ ├── Repositories/
│ └── MyApp.Infrastructure.csproj
├── MyApp.Tests.Unit/
│ ├── Controllers/
│ ├── Services/
│ └── MyApp.Tests.Unit.csproj
└── MyApp.Tests.Integration/
├── API/
├── Database/
└── MyApp.Tests.Integration.csproj
Key Configuration Files
Directory.Build .props (for consistent build configuration):
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisRuleSet>../stylecop.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
API Project File (MyApp.API.csproj)
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
</Project>
2. Docker Configuration
Multi-stage Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files
COPY ["src/MyApp.API/MyApp.API.csproj", "src/MyApp.API/"]
COPY ["src/MyApp.Core/MyApp.Core.csproj", "src/MyApp.Core/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
COPY ["src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj", "src/MyApp.Tests.Unit/"]
COPY ["src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj", "src/MyApp.Tests.Integration/"]
# Restore dependencies
RUN dotnet restore "src/MyApp.API/MyApp.API.csproj"
RUN dotnet restore "src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj"
RUN dotnet restore "src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj"
# Copy everything else
COPY . .
# Build and publish
WORKDIR "/src/src/MyApp.API"
RUN dotnet build "MyApp.API.csproj" -c Release -o /app/build
RUN dotnet publish "MyApp.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Test stage
FROM build AS test
WORKDIR "/src/src/MyApp.Tests.Unit"
RUN dotnet test --logger "trx;LogFileName=test-results.trx" --collect:"XPlat Code Coverage"
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
EXPOSE 8081
COPY --from=build /app/publish .
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "MyApp.API.dll"]
docker-compose.yml for local development:
version: '3.8'
services:
myapp.api:
build:
context: .
dockerfile: src/MyApp.API/Dockerfile
target: final
ports:
- "8080:8080"
- "8081:8081"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=MyAppDb;User Id=sa;Password=YourPassword123!;TrustServerCertificate=true;
depends_on:
sqlserver:
condition: service_healthy
networks:
- myapp-network
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
SA_PASSWORD: "YourPassword123!"
ACCEPT_EULA: "Y"
MSSQL_PID: "Express"
ports:
- "1433:1433"
networks:
- myapp-network
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "YourPassword123!" -Q "SELECT 1" || exit 1
interval: 10s
retries: 10
start_period: 30s
timeout: 3s
seq:
image: datalust/seq:latest
environment:
- ACCEPT_EULA=Y
ports:
- "5341:5341"
- "8081:80"
networks:
- myapp-network
networks:
myapp-network:
driver: bridge
3. Comprehensive Test Suite
Unit Test Example (MyApp.Tests.Unit/Services/UserServiceTests.cs):
using Moq;
using FluentAssertions;
using MyApp.Core.Entities;
using MyApp.Core.Interfaces;
using MyApp.Infrastructure.Services;
namespace MyApp.Tests.Unit.Services;
public class UserServiceTests
{
private readonly Mock<IUserRepository> _mockUserRepo;
private readonly UserService _userService;
public UserServiceTests()
{
_mockUserRepo = new Mock<IUserRepository>();
_userService = new UserService(_mockUserRepo.Object);
}
[Fact]
public async Task GetUserById_WithValidId_ReturnsUser()
{
// Arrange
var expectedUser = new User { Id = 1, Email = "[email protected]", Name = "Test User" };
_mockUserRepo.Setup(repo => repo.GetByIdAsync(1)).ReturnsAsync(expectedUser);
// Act
var result = await _userService.GetUserById(1);
// Assert
result.Should().NotBeNull();
result.Email.Should().Be("[email protected]");
result.Name.Should().Be("Test User");
_mockUserRepo.Verify(repo => repo.GetByIdAsync(1), Times.Once);
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("invalid-email")]
public async Task CreateUser_WithInvalidEmail_ThrowsArgumentException(string invalidEmail)
{
// Arrange
var user = new User { Email = invalidEmail, Name = "Test User" };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _userService.CreateUser(user));
}
}
Integration Test Example (MyApp.Tests.Integration/API/UsersControllerIntegrationTests.cs)
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using MyApp.API;
using MyApp.Core.Entities;
namespace MyApp.Tests.Integration.API;
public class UsersControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private readonly string _connectionString;
public UsersControllerIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real database with test database
services.AddScoped<IConnectionFactory, TestConnectionFactory>();
});
});
_client = _factory.CreateClient();
_connectionString = "TestConnectionString";
}
[Fact]
public async Task GetUsers_ReturnsSuccessStatusCode()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.EnsureSuccessStatusCode();
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task CreateUser_WithValidData_ReturnsCreatedUser()
{
// Arrange
var user = new { Name = "Integration Test User", Email = "[email protected]" };
var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/users", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var responseContent = await response.Content.ReadAsStringAsync();
var createdUser = JsonSerializer.Deserialize<User>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
createdUser.Should().NotBeNull();
createdUser.Name.Should().Be("Integration Test User");
}
public async Task InitializeAsync()
{
await ResetTestDatabase();
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
private async Task ResetTestDatabase()
{
// Implementation to reset test database
await Task.CompletedTask;
}
}
4. GitHub Actions CI/CD Pipeline
.github/workflows/ci-cd.yml
name: .NET CI/CD Pipeline
on:
push:
branches: [ main, develop, feature/* ]
pull_request:
branches: [ main, develop ]
env:
DOTNET_VERSION: '8.0.x'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# JOB 1: Code Quality & Security
code-analysis:
name: Code Analysis & Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache SonarCloud packages
uses: actions/cache@v3
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: ${{ runner.os }}-nuget-
- name: Install SonarCloud scanner
run: |
dotnet tool install --global dotnet-sonarscanner
- name: SonarCloud Analysis
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
dotnet sonarscanner begin /k:"your-org_${{ github.event.repository.name }}" /o:"your-org" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml"
dotnet build --configuration Release
dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
- name: Run Security Scan
uses: shiftleftsecurity/scan-action@master
with:
output: reports
type: dotnet
env:
SCAN_AUTO_BUILD: true
SCAN_AUDIT: true
# JOB 2: Build & Test
build-and-test:
name: Build, Test & Analyze
runs-on: ubuntu-latest
needs: code-analysis
strategy:
matrix:
configuration: [Debug, Release]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: ${{ runner.os }}-nuget-
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration ${{ matrix.configuration }} --no-restore --verbosity minimal
- name: Run unit tests with coverage
run: |
dotnet test src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj \
--configuration ${{ matrix.configuration }} \
--no-build \
--verbosity normal \
--logger "trx;LogFileName=test-results.trx" \
--collect:"XPlat Code Coverage" \
--results-directory ./TestResults
- name: Run integration tests
run: |
dotnet test src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj \
--configuration ${{ matrix.configuration }} \
--no-build \
--verbosity normal \
--logger "trx;LogFileName=integration-test-results.trx"
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results-${{ matrix.configuration }}
path: |
**/test-results.trx
**/integration-test-results.trx
retention-days: 30
# JOB 3: Docker Build & Security Scan
docker-build:
name: Build & Scan Docker Image
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./src/MyApp.API/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'
# JOB 4: Deployment to Environments
deploy:
name: Deploy to Environment
runs-on: ubuntu-latest
needs: [build-and-test, docker-build]
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
environment:
name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
url: ${{ github.ref == 'refs/heads/main' && 'https://myapp.prod.com' || 'https://myapp.staging.com' }}
strategy:
matrix:
include:
- environment: staging
condition: github.ref == 'refs/heads/develop'
- environment: production
condition: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Azure Container Instances
if: matrix.condition
uses: azure/aci-deploy@v1
with:
resource-group: myapp-${{ matrix.environment }}-rg
dns-name-label: myapp-${{ matrix.environment }}-${{ github.sha }}
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
registry-login-server: ${{ env.REGISTRY }}
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
name: myapp-${{ matrix.environment }}
location: 'eastus'
cpu: 1
memory: 1
- name: Run smoke tests
run: |
echo "Running smoke tests against ${{ matrix.environment }} environment"
# Implement actual smoke tests using curl or HttpClient
curl -f https://myapp-${{ matrix.environment }}.azurecontainer.io/health || exit 1
- name: Slack Notification
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
channel: '#deployments'
text: |-
Deployment to ${{ matrix.environment }} ${{ job.status }}
Commit: ${{ github.event.head_commit.message }}
By: ${{ github.actor }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: always()
# JOB 5: Database Migrations
database-migrations:
name: Run Database Migrations
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Run EF Core Migrations
run: |
dotnet tool install --global dotnet-ef
dotnet ef database update --project src/MyApp.Infrastructure --startup-project src/MyApp.API --connection "${{ secrets.STAGING_DB_CONNECTION }}"
env:
ASPNETCORE_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Staging' }}
5. Infrastructure as Code (Terraform)
main.tf for Azure
terraform {
required_version = ">= 1.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~>3.0"
}
}
backend "azurerm" {
resource_group_name = "tfstate"
storage_account_name = "mytfstatestorage"
container_name = "tfstate"
key = "myapp.terraform.tfstate"
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = "myapp-${var.environment}-rg"
location = var.location
}
resource "azurerm_container_group" "main" {
name = "myapp-${var.environment}-ci"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_address_type = "Public"
dns_name_label = "myapp-${var.environment}-${replace(substr(sha1(timestamp()), 0, 8), "/[^a-z0-9]/", "")}"
os_type = "Linux"
container {
name = "myapp"
image = var.container_image
cpu = "1"
memory = "1"
ports {
port = 8080
protocol = "TCP"
}
environment_variables = {
ASPNETCORE_ENVIRONMENT = var.environment
}
secure_environment_variables = {
ConnectionStrings__DefaultConnection = var.database_connection_string
}
}
tags = {
environment = var.environment
version = var.app_version
}
}
6. Advanced Health Checks & Monitoring
Custom Health Checks
public class DatabaseHealthCheck : IHealthCheck
{
private readonly IConfiguration _configuration;
public DatabaseHealthCheck(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
using var connection = new SqlConnection(_configuration.GetConnectionString("DefaultConnection"));
await connection.OpenAsync(cancellationToken);
var command = connection.CreateCommand();
command.CommandText = "SELECT 1";
var result = await command.ExecuteScalarAsync(cancellationToken);
return result?.ToString() == "1"
? HealthCheckResult.Healthy("Database is responsive")
: HealthCheckResult.Unhealthy("Database check failed");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database connection failed", ex);
}
}
}
// Program.cs configuration
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddUrlGroup(new Uri("https://api.example.com/external"), "external_api")
.AddApplicationInsightsPublisher();
builder.Services.Configure<HealthCheckPublisherOptions>(options =>
{
options.Delay = TimeSpan.FromSeconds(5);
options.Period = TimeSpan.FromSeconds(30);
});
This comprehensive setup provides enterprise-grade CI/CD with security scanning, multi-environment deployment, infrastructure as code, and robust monitoring.