Open by default in both directions
A ClusterIP Service is invisible from outside the cluster, so exposing many apps on one public IP means one ingress controller reading Host and path and fanning out to the right Service. That much is the everyday convenience. The part that surprises people is what happens inside the cluster once that door exists: by default every pod can already open a connection straight to partner-gw's ClusterIP, no ingress involved, no wall in the way. The ingress controller does not create that openness — it was already there. A NetworkPolicy is the object that narrows it, and this lab makes you watch both halves: the door opening, and the wall going up around a route that used to be wide open.
One door. No wall, until you build one.
You need a free Azure account and the Azure CLI (az), signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs, and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash) — az and kubectl are preinstalled and already signed in.
This lab assumes the cluster from Your first AKS cluster. If you already tore that one down, Step 1 recreates it with the same command. Turning on the application routing add-on adds a small Standard Load Balancer (a few cents an hour) for its public IP, and turning on a network policy engine reimages the node pool, which takes several minutes but costs nothing extra by itself. The teardown step removes all of it.
The cluster, plus two features it does not have yet
If you still have the cluster from Your first AKS cluster, skip straight to the two az aks commands below. Otherwise recreate it with the identical command first.
# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 RG="campux-lab-aks-rg" az group create -n "$RG" -l eastus # same command as the first-cluster lab: free control plane, one small node az aks create -n campux-aks -g "$RG" \ --tier free \ --node-count 1 \ --node-vm-size Standard_B2s \ --enable-managed-identity \ --generate-ssh-keys az aks get-credentials -n campux-aks -g "$RG"
Now the two features this lab needs. Neither is on by default: the application routing add-on gives you a managed NGINX ingress controller, and the cluster needs a network policy engine before any NetworkPolicy object does anything at all — without one, kubectl apply on a NetworkPolicy succeeds and silently enforces nothing.
# managed NGINX ingress controller — creates the webapprouting.kubernetes.azure.com IngressClass az aks approuting enable -g "$RG" -n campux-aks # Calico network policy engine — Azure's recommended non-deprecated option alongside Cilium. # This reimages the node pool; expect several minutes, not seconds. az aks update -g "$RG" -n campux-aks --network-policy calico kubectl get nodes kubectl get pods -n app-routing-system
kubectl get pods -n app-routing-system lists the managed nginx ingress controller pods, all Running. Both commands can be slow the first time — the add-on stands up a load balancer and Calico reimages every node — so re-run the get pods line until it settles before moving on.Two Services: shop and partner-gw
A small, well-known demo image that prints a title you set by environment variable — perfect for telling two backends apart by eye. Both land behind ordinary ClusterIP Services, invisible from outside the cluster on their own.
kubectl create namespace netdemo
cat > shop.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop
namespace: netdemo
spec:
replicas: 1
selector:
matchLabels: { app: shop }
template:
metadata:
labels: { app: shop }
spec:
containers:
- name: shop
image: mcr.microsoft.com/azuredocs/aks-helloworld:v1
ports: [{ containerPort: 80 }]
env: [{ name: TITLE, value: "Shop" }]
---
apiVersion: v1
kind: Service
metadata:
name: shop
namespace: netdemo
spec:
selector: { app: shop }
ports: [{ port: 80, targetPort: 80 }]
EOF
cat > partner-gw.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: partner-gw
namespace: netdemo
spec:
replicas: 1
selector:
matchLabels: { app: partner-gw }
template:
metadata:
labels: { app: partner-gw }
spec:
containers:
- name: partner-gw
image: mcr.microsoft.com/azuredocs/aks-helloworld:v2
ports: [{ containerPort: 80 }]
env: [{ name: TITLE, value: "Partner Gateway" }]
---
apiVersion: v1
kind: Service
metadata:
name: partner-gw
namespace: netdemo
spec:
selector: { app: partner-gw }
ports: [{ port: 80, targetPort: 80 }]
EOF
kubectl apply -f shop.yaml -f partner-gw.yaml
kubectl wait --for=condition=Available deployment/shop deployment/partner-gw -n netdemo --timeout=180s
kubectl get pods -n netdemo shows both pods Running. Neither Service has an external IP — kubectl get svc -n netdemo shows ClusterIP on both, reachable only from inside the cluster so far.One Ingress, routed by path
One Ingress object, one public IP, two paths. rewrite-target strips the path prefix before the request reaches either backend, since neither app expects to be served from a subpath.
cat > netdemo-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: netdemo
namespace: netdemo
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
ingressClassName: webapprouting.kubernetes.azure.com
rules:
- http:
paths:
- path: /shop(/|$)(.*)
pathType: Prefix
backend:
service: { name: shop, port: { number: 80 } }
- path: /partner(/|$)(.*)
pathType: Prefix
backend:
service: { name: partner-gw, port: { number: 80 } }
EOF
kubectl apply -f netdemo-ingress.yaml
kubectl get ingress -n netdemo -w # Ctrl-C once ADDRESS is populated
Once the Ingress has an address, capture the ingress controller's public IP and hit both paths.
IP=$(kubectl get service -n app-routing-system nginx -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
echo "$IP"
curl -s "http://$IP/shop/" | grep -o 'Shop'
curl -s "http://$IP/partner/" | grep -o 'Partner Gateway'
curl prints Shop, the second prints Partner Gateway — the exact TITLE value each Deployment was given, rendered into the page each app returns. Same IP, same port, two different backends, chosen entirely by the path in the URL. One door, two rooms, one routing table.Pod-to-pod is open by default — prove it before you close it
Now skip the ingress entirely. Start a throwaway pod inside the cluster and have it call partner-gw directly, by its in-cluster DNS name, over its ClusterIP — the exact CoreDNS-resolved address any pod on the cluster could already use.
kubectl run curler -n netdemo --rm -it --restart=Never \
--image=curlimages/curl:8.21.0 -- \
curl -s --max-time 5 -o /dev/null -w "HTTP %{http_code}\n" \
http://partner-gw.netdemo.svc.cluster.local
HTTP 200. Nothing about curler identifies it as the ingress controller, the partner, or anything else — it is just some pod in the cluster, and the all-to-all default lets it straight through. Hold onto this result; the next step is proving it stops.The wall: a NetworkPolicy on partner-gw
Admit traffic to partner-gw only from the ingress controller's namespace — the legitimate front door — and deny everything else, which by default means every other pod on the cluster, curler included.
cat > partner-gw-policy.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: partner-gw-only-ingress
namespace: netdemo
spec:
podSelector:
matchLabels:
app: partner-gw
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: app-routing-system
ports:
- protocol: TCP
port: 80
EOF
kubectl apply -f partner-gw-policy.yaml
Re-run the exact same pod-to-pod request from Step 4.
kubectl run curler -n netdemo --rm -it --restart=Never \
--image=curlimages/curl:8.21.0 -- \
curl -s --max-time 5 -o /dev/null -w "HTTP %{http_code}\n" \
http://partner-gw.netdemo.svc.cluster.local
curl hangs for the full 5 seconds and prints HTTP 000 — a connection that never completed, not a clean rejection. Same command, same target, same pod-to-pod path that returned HTTP 200 a minute ago; the only thing that changed in between is the NetworkPolicy.Now prove the door you built in Step 3 is still standing, because it is on the allow-list — the ingress controller runs in app-routing-system, exactly the namespace the policy admits.
curl -s "http://$IP/partner/" | grep -o 'Partner Gateway'
By default, Kubernetes draws no line between pods — curler and the ingress controller looked identical to partner-gw before this step, because nothing was checking. The NetworkPolicy is a label-selected allow-list: it selects partner-gw's pods, declares one legitimate source — the ingress controller's namespace — and everything not on that list is denied, silently, the connection just never completes. This is why the request timed out instead of erroring cleanly: NetworkPolicy drops packets, it does not tell the caller no. And note what did not need to change — the Ingress object, the Service, the ingress controller's route — none of it. The policy narrowed exactly one thing: who else may reach partner-gw directly.
Tear it down
Delete the namespace, then the whole resource group — that removes the Deployments, Services, Ingress, NetworkPolicy, the ingress controller's load balancer, and the cluster in one move.
kubectl delete namespace netdemo
az group delete -n campux-lab-aks-rg --yes --no-wait
az group exists -n campux-lab-aks-rg # -> false once complete
MC_campux-lab-aks-rg_campux-aks_eastus) is gone too.What you can now honestly claim
You enabled the application routing add-on and routed two Services through one managed NGINX ingress by path, proving the same public IP reaches two different backends. You then proved, with a plain pod and a stopwatch, that Kubernetes' default posture is all-to-all — any pod can already reach any Service directly, ingress or no ingress. Finally you applied a NetworkPolicy that closed exactly that gap for one Service, watched the identical request that worked a minute earlier time out, and confirmed the legitimate route through the ingress controller kept working, because it — and only it — was on the allow-list.