In this comprehensive guide, we'll walk through the complete process of setting up a modern CI/CD pipeline for a C# application using Azure Kubernetes Service (AKS), Azure Container Registry (ACR), and GitHub Actions. We'll leverage Azure CLI to provision all necessary infrastructure, implement proper security practices with managed identities and service principals, and automate the build and deployment process.
By the end of this article, you'll have a fully functional pipeline that automatically builds your C# application into a Docker container and deploys it to AKS whenever you push code changes to GitHub.
Before we begin, ensure you have the following:
- An active Azure subscription
- Azure CLI installed and configured (
az --versionto verify) - A GitHub account
- Docker installed locally (for testing)
- .NET SDK installed (for the sample C# application)
kubectlinstalled for Kubernetes management
Our solution will include:
- Azure Container Registry (ACR): Private container registry for storing Docker images
- Azure Kubernetes Service (AKS): Managed Kubernetes cluster for running containers
- Managed Identity: For AKS to pull images from ACR securely
- Service Principal: For GitHub Actions to authenticate with Azure
- GitHub Actions: CI/CD pipeline for automated builds and deployments
First, let's define our environment variables to make the setup process easier and more consistent:
# Define your variables
$RESOURCE_GROUP="rg-aks-github-demo"
$LOCATION="westus3"
$ACR_NAME="acraksdemo20251112" # Must be globally unique
$AKS_CLUSTER_NAME="aks-github-demo-cluster"
$SERVICE_PRINCIPAL_NAME="sp-aks-github-actions"
$SUBSCRIPTION_ID=$(az account show --query id -o tsv)
# Display variables for verification
echo "Resource Group: $RESOURCE_GROUP"
echo "Location: $LOCATION"
echo "ACR Name: $ACR_NAME"
echo "AKS Cluster: $AKS_CLUSTER_NAME"
echo "Subscription ID: $SUBSCRIPTION_ID"Create a resource group to contain all our Azure resources:
az group create `
--name $RESOURCE_GROUP `
--location $LOCATION
echo "✓ Resource group created successfully"Now let's create our Azure Container Registry where we'll store our Docker images:
az acr create `
--resource-group $RESOURCE_GROUP `
--name $ACR_NAME `
--sku Basic `
--location $LOCATION
echo "✓ ACR created successfully"
# Get ACR login server
$ACR_LOGIN_SERVER=$(az acr show --name $ACR_NAME --resource-group $RESOURCE_GROUP --query loginServer -o tsv)
echo "ACR Login Server: $ACR_LOGIN_SERVER"Note: We're using the Basic SKU for this demo. For production workloads, consider using Standard or Premium SKUs for better performance and features like geo-replication.
Create an AKS cluster with managed identity enabled:
az aks create `
--resource-group $RESOURCE_GROUP `
--name $AKS_CLUSTER_NAME `
--node-count 2 `
--node-vm-size Standard_D2lds_v6 `
--enable-managed-identity `
--generate-ssh-keys `
--location $LOCATION `
--attach-acr $ACR_NAME
echo "✓ AKS cluster created successfully"This command does several important things:
- Creates a 2-node cluster with Standard_D2lds_v6 VMs
- Enables managed identity (recommended over service principal for cluster identity)
- Automatically configures AKS to pull images from our ACR using
--attach-acr - Generates SSH keys for node access
Best Practice: Using
--attach-acrautomatically grants the AKS cluster's managed identity theAcrPullrole on the specified ACR, eliminating the need for image pull secrets.
Configure kubectl to connect to your AKS cluster:
az aks get-credentials `
--resource-group $RESOURCE_GROUP `
--name $AKS_CLUSTER_NAME
# Verify connection
kubectl get nodes
echo "✓ AKS credentials configured"For GitHub Actions to authenticate with Azure, we'll create a service principal with appropriate permissions:
# Create service principal and capture output
$SP_OUTPUT=$(az ad sp create-for-rbac `
--name $SERVICE_PRINCIPAL_NAME `
--role Contributor `
--scopes /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP `
--sdk-auth)
echo "✓ Service Principal created"
echo "⚠️ Save this output securely - you'll need it for GitHub Actions:"
echo "$SP_OUTPUT"
# Extract service principal details
$SP_APP_ID=$(echo $SP_OUTPUT | jq -r '.clientId')
$SP_PASSWORD=$(echo $SP_OUTPUT | jq -r '.clientSecret')
$SP_TENANT_ID=$(echo $SP_OUTPUT | jq -r '.tenantId')Security Note: Store these credentials securely. We'll add them to GitHub Secrets in a later step.
Allow the service principal to push images to ACR:
# Get ACR resource ID
$ACR_ID=$(az acr show `
--name $ACR_NAME `
--resource-group $RESOURCE_GROUP `
--query id -o tsv)
# Assign AcrPush role to service principal
az role assignment create `
--assignee $SP_APP_ID `
--role AcrPush `
--scope $ACR_ID
echo "✓ Service Principal granted ACR push permissions"Now let's create a simple C# web application:
# Create a new ASP.NET Core web API
dotnet new webapi -n Bugay.TestWebApi
cd Bugay.TestWebApiUpdate Program.cs to add a custom endpoint:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapGet("/", () => "Hello from AKS!")
.WithName("GetRoot")
.WithOpenApi();
app.MapGet("/health", () => new { status = "healthy", timestamp = DateTime.UtcNow })
.WithName("HealthCheck")
.WithOpenApi();
app.Run();Create a Dockerfile in the Bugay.TestWebApi directory:
# Use the official .NET SDK image for building
FROM mcr.microsoft.com/dotnet/nightly/sdk:10.0-preview AS build
WORKDIR /app
# Copy csproj and restore dependencies
COPY *.csproj ./
RUN dotnet restore
# Copy everything else and build
COPY . ./
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/nightly/aspnet:10.0-preview
WORKDIR /app
COPY --from=build /app/out .
# Expose port
EXPOSE 5028
ENV ASPNETCORE_URLS=http://+:5028
ENTRYPOINT ["dotnet", "Bugay.TestWebApi.dll"]Create a directory for Kubernetes manifests:
mkdir k8sCreate k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: Bugay.TestWebApi
labels:
app: Bugay.TestWebApi
spec:
replicas: 3
selector:
matchLabels:
app: Bugay.TestWebApi
template:
metadata:
labels:
app: Bugay.TestWebApi
spec:
containers:
- name: Bugay.TestWebApi
image: ${ACR_LOGIN_SERVER}/Bugay.TestWebApi:${IMAGE_TAG}
ports:
- containerPort: 5028
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 5028
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 5028
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: Bugay.TestWebApi
spec:
type: LoadBalancer
selector:
app: mywebapi
ports:
- protocol: TCP
port: 80
targetPort: 5028Create .github/workflows/build-deploy.yml:
name: Build and Deploy to AKS
on:
push:
branches:
- develop
workflow_dispatch:
env:
ACR_NAME: ${{ vars.ACR_NAME }}
ACR_LOGIN_SERVER: ${{ vars.ACR_LOGIN_SERVER }}
AKS_CLUSTER_NAME: ${{ vars.AKS_CLUSTER_NAME }}
AKS_RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
jobs:
build-and-push:
runs-on: ubuntu-latest
environment: main
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Build and push image to ACR
run: |
az acr build \
--registry ${{ env.ACR_NAME }} \
--image ${{ env.IMAGE_NAME }}:${{ github.sha }} \
--image ${{ env.IMAGE_NAME }}:latest \
--file ./Bugay.TestWebApi/Dockerfile \
./Bugay.TestWebApi
- name: Set AKS context
uses: azure/aks-set-context@v3
with:
resource-group: ${{ env.AKS_RESOURCE_GROUP }}
cluster-name: ${{ env.AKS_CLUSTER_NAME }}
- name: Deploy to AKS
run: |
# Replace placeholders in deployment file
sed -i "s|\${ACR_LOGIN_SERVER}|${{ env.ACR_LOGIN_SERVER }}|g" k8s/deployment.yaml
sed -i "s|\${IMAGE_TAG}|${{ github.sha }}|g" k8s/deployment.yaml
# Apply Kubernetes manifests
kubectl apply -f k8s/deployment.yaml
# Wait for rollout to complete
kubectl rollout status deployment/bugay-testwebapi
# Get service external IP
echo "Waiting for external IP..."
timeout 300 bash -c 'until kubectl get service bugay-testwebapi -o jsonpath="{.status.loadBalancer.ingress[0].ip}" | grep -q .; do sleep 5; done' || true
EXTERNAL_IP=$(kubectl get service bugay-testwebapi -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
echo "Service deployed at: $EXTERNAL_IP"- Initialize Git repository:
cd aks-csharp-demo
git init
git add .
git commit -m "Initial commit: AKS C# demo application"- Create GitHub repository (using GitHub CLI or web interface):
# If using GitHub CLI
gh repo create aks-csharp-demo --public --source=. --remote=origin --push-
Add Azure credentials to GitHub Secrets:
- Go to your GitHub repository
- Navigate to Settings > Secrets and variables > Actions
- Click New repository secret
- Name:
AZURE_CREDENTIALS - Value: Paste the entire JSON output from the service principal creation step
-
Update the workflow file with your actual ACR name:
# Update the ACR_NAME and ACR_LOGIN_SERVER in .github/workflows/build-deploy.yml
sed -i "s/<YOUR_ACR_NAME>/$ACR_NAME/g" .github/workflows/build-deploy.yml- Managed Identity: Using managed identity for AKS-to-ACR authentication eliminates the need for storing credentials
- Resource Limits: Defined CPU and memory requests/limits for predictable performance
- Health Checks: Implemented liveness and readiness probes for automatic health monitoring
- Multi-stage Docker Build: Optimized Docker image size using multi-stage builds
- Secrets Management: Stored sensitive credentials in GitHub Secrets
- Image Tagging: Used Git SHA for image tags to enable easy rollbacks
- Rolling Updates: Kubernetes handles zero-downtime deployments automatically
- Service Principal Scope: Limited to specific resource group
- RBAC: Used minimum required permissions (Contributor on resource group, AcrPush on ACR)
- No Hardcoded Secrets: All secrets managed through GitHub Secrets or Azure managed identities
- Private Registry: ACR provides private container storage
- Network Security: Consider implementing Azure Network Policies for pod-to-pod communication
- Right-size your nodes: Start with smaller VM sizes and scale as needed
- Use AKS cluster autoscaler: Automatically adjust node count based on demand
- Implement pod autoscaling: Use Horizontal Pod Autoscaler (HPA) for application scaling
- Stop dev/test clusters: Use
az aks stopto deallocate nodes when not in use - Monitor ACR storage: Implement retention policies to remove old images
When you're done, clean up all resources to avoid unnecessary charges:
# Delete the resource group (this removes all resources within it)
az group delete \
--name $RESOURCE_GROUP \
--yes \
--no-wait
# Delete service principal
az ad sp delete --id $SP_APP_ID
echo "✓ Cleanup initiated"In this article, we've built a complete CI/CD pipeline for a C# application using Azure Kubernetes Service, Azure Container Registry, and GitHub Actions. We've implemented security best practices with managed identities and service principals, automated the build and deployment process, and created a scalable, production-ready infrastructure.
This setup provides a solid foundation for deploying containerized applications to Azure. You can extend this further by:
- Implementing Azure Application Insights for monitoring
- Adding multiple environments (dev, staging, production)
- Implementing GitOps with Flux or Argo CD
- Adding Azure Key Vault integration for secrets management
- Implementing advanced networking with Azure CNI or Calico
- AKS Documentation
- Azure Container Registry Documentation
- GitHub Actions Documentation
- Kubernetes Best Practices
- Azure CLI Reference
This article demonstrates modern DevOps practices for deploying .NET applications to Azure Kubernetes Service. Feel free to adapt these patterns to your specific requirements and organizational standards.
Tags: #Azure #AKS #Kubernetes #ACR #GitHub Actions #DevOps #CI/CD #CSharp #DotNet #Containers