DEV Community

Cover image for Build Once, Deploy Everywhere: Deploying a .NET 8 API with Docker, AKS & GitHub Actions
Ibrahim Bio Abubakar
Ibrahim Bio Abubakar

Posted on

Build Once, Deploy Everywhere: Deploying a .NET 8 API with Docker, AKS & GitHub Actions

Cloud-native development can feel overwhelming at first—containers, Kubernetes, CI/CD pipelines, cloud providers, etc. This tutorial walks you step by step from writing a simple .NET 8 Web API to deploying it on Azure Kubernetes Service (AKS) with a fully automated GitHub Actions CI/CD pipeline.

We’ll go beyond just writing code. You’ll also learn how and why each tool fits into the bigger cloud-native picture.


Learning Objective

By completing this tutorial, you will master:

  • Building production-ready .NET 8 Web APIs

  • Containerizing applications with Docker

  • Deploying to Azure Kubernetes Service (AKS)

  • Setting up CI/CD pipelines with GitHub Actions

  • Applying cloud-native development best practices


Project Overview

We’ll build a Weather Forecast API and deploy it to the cloud.

  • Backend → .NET 8 Web API

  • Containerization → Docker

  • Cloud Platform → Azure Kubernetes Service (AKS)

  • Automation → GitHub Actions for CI/CD


Prerequisites

Required Software

Install these tools (in order):

Visual Studio Code

.NET 8 SDK

Git

Docker Desktop

⚠️ Remember: Start Docker Desktop after installation

Azure CLI

kubectl

GitHub CLI (optional)

Required Accounts

GitHub(free)

Azure (free tier with $200 credits)


Verify Installation

Run the following commands in your terminal:

dotnet --version          # Shows installed .NET SDK version (should be 8.x.x)
git --version             # Confirms Git is installed
docker --version          # Confirms Docker CLI works
az --version              # Shows Azure CLI version
kubectl version --client  # Verifies kubectl client
Enter fullscreen mode Exit fullscreen mode

Note: If any fail → reinstall that tool.

Image 1


💻 Step 1: Create the .NET Application

1.1 Set Up Project Structure

mkdir weather-app-demo   # Create root project folder
cd weather-app-demo
mkdir WeatherApp         # Create app folder
cd WeatherApp
Enter fullscreen mode Exit fullscreen mode

Why? → Organizing code and infra configs in separate folders makes CI/CD pipelines easier to manage.

Image 2


1.2 Initialize .NET Project

dotnet new webapi -minimal
Enter fullscreen mode Exit fullscreen mode

dotnet new → Creates a new project

webapi → Template for REST APIs

-minimal → Uses the simplified .NET 8 minimal API syntax (less boilerplate)

Image 3


1.3 Add Dependencies

dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks
Enter fullscreen mode Exit fullscreen mode

HealthChecks → Adds endpoints for monitoring app health (Kubernetes probes rely on this).

Image 4

dotnet add package Swashbuckle.AspNetCore
Enter fullscreen mode Exit fullscreen mode

Swashbuckle.AspNetCore → Generates Swagger/OpenAPI docs automatically.

Image 5


1.4 Application Code

Replace the content Program.cs with:

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddHealthChecks();          // Adds /health endpoint
builder.Services.AddEndpointsApiExplorer();  // Enables endpoint discovery for Swagger
builder.Services.AddSwaggerGen();            // Generates Swagger UI

// Configure Kestrel to listen on port 8080 (important for containers)
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(8080);  // Allows traffic from any network interface
});

var app = builder.Build();

// Enable Swagger UI in Dev & Prod
if (app.Environment.IsDevelopment() || app.Environment.IsProduction())
{
    app.UseSwagger();
    app.UseSwaggerUI(c => 
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Weather API v1");
        c.RoutePrefix = "swagger";  // URL: /swagger
    });
}

// Welcome endpoint
app.MapGet("/", () => new
{
    Message = "Welcome to the Weather App!",
    Version = "1.0.0",
    Environment = app.Environment.EnvironmentName,
    Timestamp = DateTime.UtcNow
})
.WithName("GetWelcome")
.WithTags("General");

// Weather forecast endpoint
app.MapGet("/weather", () =>
{
    var summaries = new[] { 
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", 
        "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 
    };

    var forecast = Enumerable.Range(1, 5).Select(index => new
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), // Next 5 days
        TemperatureC = Random.Shared.Next(-20, 55),                 // Random °C
        TemperatureF = 0,                                           // Placeholder
        Summary = summaries[Random.Shared.Next(summaries.Length)]
    })
    .Select(temp => new
    {
        temp.Date,
        temp.TemperatureC,
        TemperatureF = 32 + (int)(temp.TemperatureC / 0.5556), // Formula °C → °F
        temp.Summary
    });

    return forecast;
})
.WithName("GetWeatherForecast")
.WithTags("Weather");

// Health check endpoint (important for Kubernetes probes)
app.MapHealthChecks("/health")
.WithTags("Health");

app.Run();  // Starts the web server

Enter fullscreen mode Exit fullscreen mode

Concepts Explained:

  • Minimal APIs → Introduced in .NET 6, let you build APIs with fewer lines.

  • Swagger → Auto-generates interactive API docs.

  • Probes → Kubernetes uses health endpoints to restart/retry containers when needed.


1.5 Test Locally

dotnet run
Enter fullscreen mode Exit fullscreen mode

Image 5

Test endpoints in browser or with curl in the terminal:

http://localhost:8080/ → Welcome message

http://localhost:8080/weather → Forecast data

http://localhost:8080/swagger → Swagger docs

http://localhost:8080/health → Health check

Below is the Weather App on a browser:

Image z

Stop the app with Ctrl + C.


🐳 Step 2: Containerize Your Application with Docker

We’ll package the .NET app into a Docker container so it can run consistently anywhere.

2.1 Create Dockerfile

Create a file named Dockerfile inside WeatherApp/ directory:

touch Dockerfile
Enter fullscreen mode Exit fullscreen mode

Image 6

Add the content below to the Dockerfilecreated:

# Multi-stage build for optimized image size

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src

# Copy project file and restore dependencies
COPY ["WeatherApp/WeatherApp.csproj", "."]
RUN dotnet restore "WeatherApp.csproj"

# Copy source code and build
COPY . .
WORKDIR /src
COPY WeatherApp/ ./WeatherApp/
WORKDIR /src/WeatherApp
RUN dotnet restore "WeatherApp.csproj"
RUN dotnet build "WeatherApp.csproj" -c $BUILD_CONFIGURATION -o /app/build

# Publish stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "WeatherApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

# Final stage
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WeatherApp.dll"]

Enter fullscreen mode Exit fullscreen mode

Concept: Multi-stage builds → build and runtime environments are separate. This keeps the final image small and secure.


2.2 Create .dockerignore

Prevent unnecessary files from bloating your image:

touch .dockerignore

Enter fullscreen mode Exit fullscreen mode

Image 7

Add the content below to the Dockerfilecreated:


# Build outputs
bin/
obj/
out/

# IDE files
.vs/
.vscode/
*.user
*.suo

# OS files
.DS_Store
Thumbs.db

# Git
.git/
.gitignore

# Docs
README.md
*.md

# Docker
Dockerfile*
.dockerignore

# Logs
*.log
logs/

Enter fullscreen mode Exit fullscreen mode

2.3 Build & Test Container

# Build image
docker build -t weather-app:local .     # Ensure your Docker Desktop is running before you run this command
Enter fullscreen mode Exit fullscreen mode

Image 8

Below is the built WeatherApp image in Docker Desktop app:

Image 9

Now let's run the container:

# Run container
docker run -d -p 8080:8080 --name weather-test weather-app:local
Enter fullscreen mode Exit fullscreen mode

Image 10

Test the running app using curl in the terminal:

# Test endpoints
curl http://localhost:8080/
curl http://localhost:8080/weather
Enter fullscreen mode Exit fullscreen mode

Image 11

# Clean up
docker stop weather-test
docker rm weather-test

Enter fullscreen mode Exit fullscreen mode

Image 12

Concept: Containers make your app portable — works the same on your laptop and in the cloud.


Step 3: Set Up Azure Infrastructure

We’ll use Azure CLI to provision resources.

3.1 Login to Azure

az login  #Opens browser for Azure sign-in.

Enter fullscreen mode Exit fullscreen mode

3.2 Select Subscription

az account list --output table     # See subscriptions
az account set --subscription "Your-Subscription-Name"
Enter fullscreen mode Exit fullscreen mode

3.3 Create Resource Group

az group create --name student-demo --location eastus
Enter fullscreen mode Exit fullscreen mode

Image 14

Concept: A resource group is a logical container for all your Azure resources.


3.4 Create Container Registry (ACR)

az acr create --resource-group student-demo --name studentdemo2042acr --sku Basic
Enter fullscreen mode Exit fullscreen mode

Image 15

Concept: ACR = private Docker Hub inside Azure.


3.5 Build & Push Image

az acr build --registry studentdemo2042acr --image weather-app:latest .
Enter fullscreen mode Exit fullscreen mode

Image 16

Why ACR build? → Azure builds inside the cloud, avoiding OS/CPU compatibility issues.


3.6 Create AKS Cluster

az aks create \
  --resource-group student-demo \
  --name student-aks-cluster \
  --node-count 1 \
  --node-vm-size Standard_B2s \
  --attach-acr studentdemo2042acr \
  --enable-managed-identity \
  --generate-ssh-keys
Enter fullscreen mode Exit fullscreen mode

Image 17

📌 Concept:

  • AKS = Azure-managed Kubernetes cluster

  • attach-acr → avoids ImagePullBackOff errors by linking AKS to ACR automatically


3.7 Connect to Cluster

az aks get-credentials \
  --resource-group student-demo \
  --name student-aks-cluster
Enter fullscreen mode Exit fullscreen mode

Image 18

kubectl get nodes   # Verify cluster connection
Enter fullscreen mode Exit fullscreen mode

Image 19


☸️ Step 4: Deploy with Kubernetes

Kubernetes uses YAML manifests to declare your app’s state.

cd ..  # Back to weather-app-demo folder
mkdir k8s
cd k8s
touch deployment.yaml
Enter fullscreen mode Exit fullscreen mode

Image 20

4.1 Create Deployment

Copy and paste the content below in k8s/deployment.yaml


apiVersion: apps/v1
kind: Deployment
metadata:
  name: weather-app
  labels:
    app: weather-app
    version: v1
spec:
  replicas: 2  # Run 2 pods for high availability
  selector:
    matchLabels:
      app: weather-app
  template:
    metadata:
      labels:
        app: weather-app
        version: v1
    spec:
      containers:
      - name: weather-app
        image: studentdemo2024acr.azurecr.io/weather-app:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
        env:
        - name: ASPNETCORE_ENVIRONMENT
          value: "Production"
        resources:  # Resource requests & limits
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        # Probes for self-healing
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10

Enter fullscreen mode Exit fullscreen mode

📌 Concepts:

  • Deployment → ensures the desired # of pods run.

  • Probes → tell Kubernetes when a pod is healthy or ready.


4.2 Create Service

Create k8s/service.yaml file

touch service.yaml 
Enter fullscreen mode Exit fullscreen mode

Image 22

Copy and paste the content below in k8s/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: weather-app-service
spec:
  type: LoadBalancer   # Exposes app to internet
  selector:
    app: weather-app
  ports:
  - port: 80           # External port
    targetPort: 8080   # Maps to container
Enter fullscreen mode Exit fullscreen mode

📌 Concept: A Service gives your pods a stable IP address + load balancing.


4.3 Deploy

# Apply Kubernetes manifests

kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
Enter fullscreen mode Exit fullscreen mode

Image 23

# Check deployment status
kubectl get deployments
kubectl get pods
Enter fullscreen mode Exit fullscreen mode

Image 24

# Check deployment status
kubectl get services
Enter fullscreen mode Exit fullscreen mode

Image 25

🔄 Step 5: GitHub Actions CI/CD

We’ll automate build → push → deploy.

5.1 Init Git

cd ..  # Back to weather-app-demo folder

# Initialize Git repository
git init
git add .
git commit -m "Initial commit: Weather App"

Enter fullscreen mode Exit fullscreen mode

Image 26

5.2 Create Service Principal

# Get your subscription ID
SUBSCRIPTION_ID=$(az account show --query id --output tsv)
echo "Subscription ID: $SUBSCRIPTION_ID"

# Create service principal with contributor role
az ad sp create-for-rbac \
  --name "weather-app-github-sp" \
  --role contributor \
  --scopes /subscriptions/$SUBSCRIPTION_ID/resourceGroups/student-demo \
  --sdk-auth
Enter fullscreen mode Exit fullscreen mode

Image 27

Save JSON output → add as GitHub Secret AZURE_CREDENTIALS.


5.3 Create GitHub Repo

gh auth login  # Follow the prompts
Enter fullscreen mode Exit fullscreen mode

Image 28

Image 29

# Create GitHub repository (using GitHub CLI) and Push to Repo
gh repo create weather-app-demo --public --source=. --push

Enter fullscreen mode Exit fullscreen mode

Alternative: Create the repository manually on GitHub.com and push your code.

Image 30

Image 31

5.4 Configure GitHub Secrets

Use JSON output from step 5.2

gh secret set AZURE_CREDENTIALS -b'PASTE_JSON_HERE'
Enter fullscreen mode Exit fullscreen mode

Image 32

gh secret set ACR_NAME -b"studentdemo2042acr"
Enter fullscreen mode Exit fullscreen mode

Image 33

gh secret set RESOURCE_GROUP -b"student-demo"
Enter fullscreen mode Exit fullscreen mode

Image 34

gh secret set CLUSTER_NAME -b"student-aks-cluster"
Enter fullscreen mode Exit fullscreen mode

Image 35

5.5 Create a GitHub Workflow

Create the workflow directory and file:


mkdir -p .github/workflows
touch .github/workflows/deploy.yml

Enter fullscreen mode Exit fullscreen mode

Copy and paste the content below in .github/workflows/deploy.yml file.

name: Build and Deploy to AKS
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  IMAGE_NAME: weather-app

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Restore dependencies
        run: dotnet restore WeatherApp/WeatherAPP.csproj

      - name: Build application
        run: dotnet build WeatherApp/WeatherAPP.csproj --configuration Release --no-restore

      - name: Run tests
        run: dotnet test WeatherApp/WeatherAPP.csproj --no-build --verbosity normal || true

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

      - name: Setup Azure CLI
        uses: azure/cli@v2
        with:
          inlineScript: echo "Azure CLI setup complete"

      - name: Build and push Docker image to ACR
        run: |
          IMAGE_TAG=${{ secrets.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
          az acr build \
            --registry ${{ secrets.ACR_NAME }} \
            --image ${{ env.IMAGE_NAME }}:${{ github.sha }} \
            --file WeatherApp/Dockerfile \
            .
          echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV

      - name: Deploy to AKS
        if: github.ref == 'refs/heads/main'
        run: |
          echo "Checking if AKS cluster exists..."
          if ! az aks show --resource-group "${{ secrets.RESOURCE_GROUP }}" --name "${{ secrets.CLUSTER_NAME }}" --output table; then
            echo "ERROR: AKS cluster '${{ secrets.CLUSTER_NAME }}' not found in resource group '${{ secrets.RESOURCE_GROUP }}'"
            exit 1
          fi

          echo "Getting AKS credentials..."
          az aks get-credentials \
            --resource-group ${{ secrets.RESOURCE_GROUP }} \
            --name ${{ secrets.CLUSTER_NAME }} \
            --overwrite-existing

          echo "Testing kubectl connection..."
          kubectl cluster-info

          echo "Updating deployment with image: ${{ env.IMAGE_TAG }}"
          kubectl set image deployment/weather-app \
            weather-app=${{ env.IMAGE_TAG }}

          echo "Waiting for rollout to complete..."
          kubectl rollout status deployment/weather-app --timeout=600s

          echo "Deployment status:"
          kubectl get pods -l app=weather-app
          kubectl get service weather-app-service
Enter fullscreen mode Exit fullscreen mode

📌 Concept:

  • GitHub Actions → automates the whole pipeline.

  • Every push to main → build → push image → deploy to AKS.

5.6 Deploy Your Application

git add .
git commit -m "Add GitHub Actions CI/CD pipeline"
git push origin main
Enter fullscreen mode Exit fullscreen mode

Image 35

Monitor the deployment:

  • Go to your GitHub repository
  • Click the Actions tab
  • Watch your workflow run in real-time

Image 36


🌐 Step 6: Access Your Deployed App

6.1 Get External IP Address

# Check service status
kubectl get service weather-app-service
Enter fullscreen mode Exit fullscreen mode

Image 38

# Wait for EXTERNAL-IP (may take 2-5 minutes)
kubectl get service weather-app-service --watch
Enter fullscreen mode Exit fullscreen mode

Wait until EXTERNAL-IP is assigned. Then test:

# Test the endpoints
curl http://YOUR-EXTERNAL-IP/
curl http://YOUR-EXTERNAL-IP/weather
curl http://YOUR-EXTERNAL-IP/health
Enter fullscreen mode Exit fullscreen mode

Image 40////

# Or open in browser
open http://YOUR-EXTERNAL-IP/swagger  # macOS
start http://YOUR-EXTERNAL-IP/swagger  # Windows
Enter fullscreen mode Exit fullscreen mode

Image 41


🔄 Step 7: Test Continuous Deployment

  1. Edit Program.cs welcome message.
app.MapGet("/", () => new
{
    Message = "Welcome to the Updated Weather App! 🌤️",
    Version = "1.1.0",
    Environment = app.Environment.EnvironmentName,
    Timestamp = DateTime.UtcNow,
    DeployedBy = "GitHub Actions"
})

Enter fullscreen mode Exit fullscreen mode
  1. Commit & push:
git add .
git commit -m "Update welcome message"
git push origin main
Enter fullscreen mode Exit fullscreen mode
  1. GitHub Actions will rebuild & redeploy automatically.

Image 42

  1. Test again → you should see your new message.
curl http://YOUR-EXTERNAL-IP/
Enter fullscreen mode Exit fullscreen mode

Image 43


Conclusion

You’ve just taken a local .NET 8 Web API and transformed it into a cloud-native application running on Azure Kubernetes Service, packaged with Docker, and automated with a GitHub Actions CI/CD pipeline. That’s the full lifecycle of modern cloud development — from writing code → to shipping containers → to running resilient workloads in production.

The best part? You now have a repeatable blueprint you can apply to almost any project:

Swap the Weather API with your own service

Reuse the Docker + Kubernetes setup

Extend your GitHub workflow for tests, security scans, or staging environments

This isn’t just about building an app — it’s about learning how to ship production-ready software at scale.

Keep experimenting: add monitoring, autoscaling, or connect a database. The cloud-native journey is iterative, but now you’ve got the foundations locked in.

Top comments (0)