Architecting a multi-environment deployment strategy for enterprise applications—such as digital invoicing gateways or public sector portals—requires strict isolation between Development, QA, and Production environments. Azure Kubernetes Service (AKS) combined with GitHub Actions provides a scalable foundation for this, but exposing these environments securely often introduces complex network routing challenges.
This article provides a complete, end-to-end guide to building a branch-driven CI/CD pipeline for an ASP.NET Core MVC application, configuring NGINX Ingress for multi-stage routing, and troubleshooting aggressive network timeouts (ERR_CONNECTION_TIMED_OUT) caused by Azure firewalls or local ISPs.
1. The Architectural Blueprint
To achieve true environment isolation without provisioning multiple independent Kubernetes clusters, we will use namespace isolation within a single AKS cluster.
Our architecture includes:
Source Control: GitHub repository with dev, qa, and main branches.
Container Registry: Azure Container Registry (ACR) to host our compiled images.
Orchestration: AKS with three dedicated namespaces (dev, qa, prod).
Routing: NGINX Ingress Controller utilizing a wildcard DNS service (nip.io) to route traffic dynamically based on the requested subdomain.
Let's assume our application is an ASP.NET Core MVC project named TaxInvoice.Web.
2. Automating Deployments with GitHub Actions
Instead of creating redundant workflow files for each environment, we can leverage GitHub Actions environment variables and dynamic substitution to deploy to the correct namespace based on the triggering branch.
Create a .github/workflows/deploy.yml file in your repository:
YAML
name: TaxInvoice Gateway CI/CD
on:
push:
branches: [ "dev", "qa", "main" ]
env:
ACR_LOGIN_SERVER: taxinvoiceacr.azurecr.io
CLUSTER_NAME: aks-tax-cluster
RESOURCE_GROUP: rg-tax-infrastructure
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Login to ACR
run: az acr login --name ${{ env.ACR_LOGIN_SERVER }}
- name: Determine Environment
id: env-vars
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "ENV_NAME=prod" >> $GITHUB_ENV
else
echo "ENV_NAME=${{ github.ref_name }}" >> $GITHUB_ENV
fi
- name: Build and Push Docker Image
run: |
IMAGE_TAG=${{ env.ACR_LOGIN_SERVER }}/taxinvoice.web:${{ env.ENV_NAME }}-${{ github.sha }}
docker build -t $IMAGE_TAG -f src/TaxInvoice.Web/Dockerfile .
docker push $IMAGE_TAG
echo "IMAGE_URI=$IMAGE_TAG" >> $GITHUB_ENV
- name: Set AKS Context
uses: azure/aks-set-context@v3
with:
resource-group: ${{ env.RESOURCE_GROUP }}
cluster-name: ${{ env.CLUSTER_NAME }}
- name: Deploy to Kubernetes
run: |
# Dynamically replace variables in the manifest
envsubst < k8s/deployment.yaml | kubectl apply -n ${{ env.ENV_NAME }} -f -
This workflow builds the Docker image, tags it with the Git SHA, and deploys it to the namespace matching the branch name.
3. Configuring Ingress and Wildcard DNS
To expose these services without purchasing a custom domain for initial testing, we use a wildcard DNS resolution service like nip.io. Assuming your NGINX Ingress Controller was assigned the public IP 20.100.50.25, you can define your Ingress manifest (k8s/ingress.yaml) to route traffic based on the host header.
YAML
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: taxinvoice-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: ${ENV_NAME}.20.100.50.25.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: taxinvoice-svc
port:
number: 80
When processed by envsubst during the GitHub Action, this creates isolated routing for dev.20.100.50.25.nip.io, qa.20.100.50.25.nip.io, and prod.20.100.50.25.nip.io.
4. Troubleshooting Network Timeouts (ERR_CONNECTION_TIMED_OUT)
A common hurdle immediately following a successful deployment is the ERR_CONNECTION_TIMED_OUT error when attempting to load the application in a browser. Unlike HTTP 500 or 404 errors, a timeout indicates that the TCP connection is being actively dropped before it ever reaches the Ingress controller.
There are two primary layers where this blockage occurs.
Layer 1: Azure Network Security Groups (NSG)
By default, AKS creates a secondary infrastructure resource group (e.g., MC_rg-tax-infrastructure_aks-tax-cluster_eastus) that contains a strict Network Security Group. Even if NGINX provisions a public IP, the underlying node firewall may block inbound web traffic.
The Fix: Run the following Azure CLI script to locate the autogenerated node resource group and open ports 80 and 443:
Bash
# 1. Get the auto-generated Node Resource Group
NODE_RG=$(az aks show --resource-group rg-tax-infrastructure --name aks-tax-cluster --query nodeResourceGroup -o tsv)
# 2. Get the NSG name
NSG_NAME=$(az network nsg list --resource-group $NODE_RG --query "[0].name" -o tsv)
# 3. Create the allow rule
az network nsg rule create \
--resource-group $NODE_RG \
--nsg-name $NSG_NAME \
--name Allow_Web_Traffic \
--priority 1000 \
--destination-port-ranges 80 443 \
--access Allow \
--protocol Tcp
Layer 2: Local ISP DNS Blocking
If the NSG is open but the browser still times out, the local Internet Service Provider (ISP) or a corporate firewall is likely blocking the nip.io wildcard DNS service. Many local networks flag dynamic DNS resolution as a security risk.
The Isolation Test: Run curl -Iv [http://20.100.50.25](http://20.100.50.25) from your local machine. If the output returns an HTTP/1.1 404 Not Found from NGINX, your Azure network is perfectly healthy. The timeout is purely a localized DNS block.
5. The Architecture Pivot: Bypassing ISP Blocks with Dedicated IPs
If you are developing behind a strict network where modifying local hosts files is impractical for the broader QA team, the NGINX host-based routing approach becomes a bottleneck.
The most reliable architectural pivot is to bypass Ingress routing entirely and convert your environment services into direct Azure Standard Load Balancers. This provisions a dedicated, unblocked IP address for each environment.
You can instantly patch your existing ClusterIP services to request dedicated public IPs:
Bash
# Convert DEV
kubectl patch svc $(kubectl get svc -n dev -o jsonpath='{.items[0].metadata.name}') -n dev -p '{"spec": {"type": "LoadBalancer"}}'
# Convert QA
kubectl patch svc $(kubectl get svc -n qa -o jsonpath='{.items[0].metadata.name}') -n qa -p '{"spec": {"type": "LoadBalancer"}}'
# Convert PROD
kubectl patch svc $(kubectl get svc -n prod -o jsonpath='{.items[0].metadata.name}') -n prod -p '{"spec": {"type": "LoadBalancer"}}'
Monitor the allocation using kubectl get svc -A | grep LoadBalancer. Within two minutes, Azure will assign three distinct public IP addresses (e.g., 20.100.51.10, 20.100.51.11, 20.100.51.12).
Conclusion
Building an automated release pipeline for ASP.NET Core applications requires balancing deployment velocity with robust infrastructure configuration. By combining GitHub Actions with environment-specific Kubernetes manifests, development teams can ensure code isolation across stages. Furthermore, understanding how to navigate the network layers—from Azure NSG rules to overcoming local ISP restrictions—guarantees that your enterprise applications remain accessible and highly available throughout the entire development lifecycle.
Join the conversation! Your thoughts help the community grow.