Run one step, then check it
Read the step before copying. Bash commands belong in Git Bash on Windows or Azure Cloud Shell set to Bash; PowerShell uses different syntax. A JSON, YAML, Bicep or application-code example belongs in the named file, not directly at the terminal prompt. Follow any shell-specific instructions below.
Keep the same terminal open so earlier variables remain available. Copy one complete command block without the terminal's $ prompt. A backslash continues a command onto the next line. If Bash shows a lone >, it is waiting for the rest of a quote, command or file block. Finish the complete block; if the paste was incomplete, press Ctrl+C and copy it again. Do not paste the next step into an unfinished command.
A line such as RG="training-group" stores a value without printing it. Output captured with $(...) is also stored; a blank screen alone does not prove failure. A sleep deliberately waits, and -o none suppresses normal output. Wait for your normal prompt, then run the step's checkpoint. After an error, stop and keep the exact command and message; later steps cannot repair a missing resource. Never share passwords, access tokens or secret values in a screenshot.
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 creates its own uniquely named cluster and an unauthenticated public HTTP demo endpoint. Use no real data or credentials. The Free management tier still has billable node and networking resources; price and completion time vary. Microsoft supports critical security patches for this managed NGINX add-on through November 2026 and recommends planning migration toward Gateway API. Delete and verify both lab groups when finished.
Create a dedicated cluster with policy enforcement
Use Bash in Cloud Shell or a local terminal. Confirm the subscription, then create a uniquely named lab group and cluster with Azure CNI and Calico policy enforcement. Keep the names in this Bash session for cleanup. This Calico setup is for the teaching demo; Microsoft currently recommends Azure CNI powered by Cilium for new production clusters.
# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 az account show --query '{name:name,subscriptionId:id}' -o table RG="rg-cx27c-aks-$RANDOM-$RANDOM" CLUSTER="cx27c-aks-$RANDOM" printf 'Group: %s\nCluster: %s\n' "$RG" "$CLUSTER" az group create -n "$RG" -l eastus # Free management tier; VM and related resources can bill # if Azure says this size "is not allowed in your subscription", the same error lists the sizes # that are; pick a 2-vCPU one from that list (Standard_D2as_v4 worked for us in October 2026) az aks create -n "$CLUSTER" -g "$RG" \ --tier free \ --node-count 1 \ --node-vm-size Standard_D2as_v4 \ --network-plugin azure \ --network-policy calico \ --enable-managed-identity \ --generate-ssh-keys az aks get-credentials -n "$CLUSTER" -g "$RG"
Enable the managed NGINX application-routing add-on for this demo. Calico was selected during cluster creation; confirm the network profile and later test that its policy filters traffic. A NetworkPolicy manifest alone is not enforcement evidence.
# managed NGINX ingress controller — creates the webapprouting.kubernetes.azure.com IngressClass
az aks approuting enable -g "$RG" -n "$CLUSTER"
az aks show -g "$RG" -n "$CLUSTER" --query networkProfile.networkPolicy -o tsv
kubectl get nodes
kubectl get pods -n app-routing-system
calico; the managed controller pods should become ready in app-routing-system. If creation or enablement fails, still use the cleanup section to inspect and remove billable resources.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:v1
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 demo Ingress object, one public IP, two paths. The NGINX-specific rewrite-target strips the prefix before the request reaches either backend. Regex paths use ImplementationSpecific; other controllers may handle these annotations differently. This example serves plain HTTP without authentication and is only for disposable test data.
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: ImplementationSpecific
backend:
service: { name: shop, port: { number: 80 } }
- path: /partner(/|$)(.*)
pathType: ImplementationSpecific
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.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.
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
For this demo, allow traffic to partner-gw from any pod in the managed controller's namespace, and deny other pod sources, including curler. This broad namespace allow rule makes the behavior easy to see; it is not a production least-privilege policy or an outside-partner allow-list.
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, with a new pod name — if the first curler pod is still terminating when you get here, reusing its name fails with AlreadyExists instead of showing you the result you came for.
kubectl run curler2 -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. Depending on the client and policy engine, the exact output may differ from HTTP 000. If it still succeeds, inspect the policy selector and AKS network profile; applying a manifest is not proof of enforcement.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. Re-derive $IP here rather than trust it survived from Step 3 — Cloud Shell can drop an idle session, and Step 1's Calico reimage alone can eat several minutes.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.
IP=$(kubectl get service -n app-routing-system nginx -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
curl -s "http://$IP/partner/" | grep -o 'Partner Gateway'
The policy selects gateway pods, allows sources from the app-routing-system namespace, and blocks the demo curl pod's direct route. It does not distinguish outside clients. The public HTTP route remains accessible to anyone who knows its address until separate TLS, authentication and authorization controls are added.
Tear it down
Use the same Bash session that holds this lab's $RG and $CLUSTER. Stop any watch commands with Ctrl-C, confirm the group name, record the AKS-managed node group if available, and wait for deletion. If setup failed earlier, still inspect the subscription for resources.
Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.
kubectl delete namespace netdemo --ignore-not-found
printf 'Delete lab group: %s\n' "$RG"
az group show -n "$RG" --query '{name:name,resourceId:id}' -o table
NODE_RG=$(az aks show -n "$CLUSTER" -g "$RG" --query nodeResourceGroup -o tsv 2>/dev/null || true)
printf 'Managed node group: %s\n' "$NODE_RG"
az group delete -n "$RG" --yes
az group exists -n "$RG" # expected: false
if [ -n "$NODE_RG" ]; then az group exists -n "$NODE_RG"; fi # expected: false
What you can now honestly claim
You routed demo HTTP traffic to two Services through one managed NGINX controller. An internal pod could call the gateway directly before an enforced policy blocked that path, while the controller-namespace path remained allowed. You also verified the billable cluster was removed. This lab does not authenticate an outside partner or provide a production ingress design; those require separate access and TLS controls and a supported edge plan.