Enterprise software delivery relies on predictability. When deploying microservices, the goal is to build an immutable container image once and promote that exact artifact through isolated environments, ensuring that what is tested in QA is exactly what runs in Production.
This guide details how to architect a complete deployment pipeline for an ASP.NET Core Web API running on .NET 9. We will use GitHub Actions to automate the build and push processes, enforce manual approval gates for environment promotion, and expose the application securely using an NGINX Ingress Controller on Azure Kubernetes Service (AKS).
1. Architectural Prerequisites
Before defining the pipeline, the infrastructure foundation must be established:
Azure Kubernetes Service (AKS): A running cluster with Contributor access.
Azure Container Registry (ACR): Attached to the AKS cluster to allow image pulling without external credentials.
Logical Namespaces: dev, qa, and prod namespaces pre-provisioned inside the cluster.
GitHub Environments: Configured within the repository settings to enforce manual review gates for the qa and prod environments.
2. Dockerizing the ASP.NET Core Application
The foundation of the pipeline is a highly optimized, multi-stage Dockerfile. By leveraging the .NET 9 SDK for the build phase and the minimal ASP.NET runtime for execution, we reduce the final attack surface and image size.
Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["MyWebApi/MyWebApi.csproj", "MyWebApi/"]
RUN dotnet restore "./MyWebApi/MyWebApi.csproj"
COPY . .
WORKDIR "/src/MyWebApi"
RUN dotnet build "./MyWebApi.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish "./MyWebApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
EXPOSE 8080
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyWebApi.dll"]
3. Designing Environment-Agnostic Kubernetes Manifests
Instead of maintaining hardcoded manifests for every environment, we define a baseline deployment and service. The CI/CD pipeline will dynamically inject the target namespace and the precise image tag during execution.
Deployment Configuration (k8s/deployment.yaml):
YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapi-deployment
spec:
replicas: 2
selector:
matchLabels:
app: webapi
template:
metadata:
labels:
app: webapi
spec:
containers:
- name: webapi
image: <ACR_SERVER>/webapi:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
Internal Service (k8s/service.yaml):
YAML
apiVersion: v1
kind: Service
metadata:
name: webapi-service
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 8080
selector:
app: webapi
4. Automating Promotion with GitHub Actions
A robust CI/CD pipeline must balance automation with control. The following GitHub Actions workflow compiles the code upon a push to the main branch, pushes the image to ACR, and automatically updates the dev namespace.
By leveraging the environment: block, the pipeline halts before executing the deploy-qa and deploy-prod jobs, awaiting manual sign-off from an authorized reviewer.
YAML
name: Deploy .NET 9 API to DEV, QA, PROD
on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY_NAME: myenterpriseacr.azurecr.io
APP_NAME: enterprise-webapi
RESOURCE_GROUP: aks-resource-group
CLUSTER_NAME: primary-aks-cluster
IMAGE_TAG: ${{ github.sha }}
jobs:
build-and-push:
name: Build & Push to ACR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- run: az acr login --name ${{ env.REGISTRY_NAME }}
- run: |
docker build -t ${{ env.REGISTRY_NAME }}/${{ env.APP_NAME }}:${{ env.IMAGE_TAG }} -f MyWebApi/Dockerfile .
docker push ${{ env.REGISTRY_NAME }}/${{ env.APP_NAME }}:${{ env.IMAGE_TAG }}
deploy-dev:
name: Deploy to DEV
needs: build-and-push
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- uses: azure/aks-set-context@v3
with:
resource-group: ${{ env.RESOURCE_GROUP }}
cluster-name: ${{ env.CLUSTER_NAME }}
- uses: azure/k8s-deploy@v4
with:
namespace: dev
manifests: |
k8s/deployment.yaml
k8s/service.yaml
images: ${{ env.REGISTRY_NAME }}/${{ env.APP_NAME }}:${{ env.IMAGE_TAG }}
# The QA and PROD jobs follow the exact same structure as deploy-dev,
# updating only the 'environment' and 'namespace' variables.
5. Exposing Environments via NGINX Ingress
To make the APIs externally accessible without provisioning expensive Azure Load Balancers for every microservice, we deploy a single NGINX Ingress Controller. We then use path-based routing combined with regex rewrite rules to strip environment prefixes (/dev, /qa, /prod) before the request reaches the container framework.
YAML
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: webapi-ingress
namespace: dev
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /dev(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: webapi-service
port:
number: 80
Field Note on Azure Networking: When configuring external ingress on AKS, Azure dynamically updates the associated Network Security Group (NSG) to allow traffic to the load balancer. If connections result in a ERR_CONNECTION_TIMED_OUT, ensure the NSG attached to the Node Resource Group (MC_...) has an explicit inbound rule permitting traffic on port 80/443.
Implementing this architecture ensures a zero-downtime deployment lifecycle. The infrastructure manages container health and load balancing, while the CI/CD pipeline guarantees that the execution environments remain strictly synchronized with the source control definitions.
Join the conversation! Your thoughts help the community grow.