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

Bash
# 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

Code
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

Code
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

Bash
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

Bash
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.