Skip to content
CAMPUX Cloud Bootcamp Lab · Class 27c · AKS ← All labs
Hands-On Lab · Beginner
~25 min · Free control plane · Cloud Shell
AKS & kubectl · torn down
Class 27c · Networking

One door in. Then a wall.

Two Services, one ingress controller routing by path — you prove it reaches both. Then you prove the fact that surprises people: any pod in the cluster can already reach either Service directly, no ingress involved. A NetworkPolicy closes that off, and you watch the exact traffic you just proved works stop working — while the ingress route, which is on the allow-list, keeps going.

Fig. 1 · The door stays open. The pod-to-pod path gets walled off.
An ingress controller routes to shop and partner-gw by path. A NetworkPolicy on partner-gw keeps the ingress route open but blocks a direct pod-to-pod request. Request /shop · /partner Ingress webapprouting Service · shop ClusterIP Service · partner-gw ClusterIP curl pod NetworkPolicy: allow ingress, deny the direct pod-to-pod route
Why

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.

Before you begin — one-time setup

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.

Cost & teardown

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.

Step 1

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
Checkpoint 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.
Step 2

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
Checkpoint 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.
Step 3

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'
Checkpoint The first 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.
Step 4

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
Checkpoint Prints 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.
Step 5

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
Checkpoint This time the 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'
What just happened

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.

Down

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
Checkpoint Confirm in the portal that the load balancer and public IP the application routing add-on created are gone along with the resource group, and that the auto-managed node group (named like MC_campux-lab-aks-rg_campux-aks_eastus) is gone too.
End

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.