If you have run AGIC (the Application Gateway Ingress Controller) at any scale, you already know its failure mode: a single pod reconciling the entire Application Gateway config, mutating an ARM resource on every Ingress change, and grinding through multi-minute control-plane updates while a noisy namespace starves everyone else. Application Gateway for Containers (AGC) is Microsoft’s clean break from that design. The data plane is a managed, regional, near-real-time proxy fleet; the control plane is the ALB Controller running in your cluster, and — critically — it speaks the Kubernetes Gateway API, not the Ingress API.
This guide walks the full production path: install the ALB Controller against workload identity, deploy a managed AGC, expose it with Gateway and HTTPRoute, then layer on weighted traffic splitting, backend mTLS, and header/path routing. Every command here is the Gateway API variant. AGC does support a legacy Ingress path, but if you are adopting AGC in 2026 you adopt Gateway API — it is where all the routing capability lives.
1. AGC architecture, and how it differs from AGIC
Two mental-model shifts matter before you touch a CLI.
The proxy is not an Application Gateway v2. AGC is a separate product. There is no WAF policy, no Standard_v2 SKU, no per-Ingress ARM mutation. Routing changes propagate in seconds, not minutes, because the controller writes to a managed config plane rather than re-deploying a gateway resource.
Deployment comes in two flavors:
| Mode | Who creates the AGC + association | Use when |
|---|---|---|
| Managed by ALB Controller | The controller, from an ApplicationLoadBalancer CRD in-cluster |
Greenfield, GitOps-driven, you want lifecycle in Kubernetes |
| Bring your own (BYO) | You, via ARM/Bicep/Terraform; controller only references it | Central network team owns the resource, strict subnet/RBAC governance |
Managed mode is faster to stand up and keeps everything in cluster manifests. BYO mode fits enterprises where a platform-networking team must own the AGC, its subnet delegation, and Private Link surface independently of any cluster. We will deploy managed mode end to end, then show the BYO association snippet, because most regulated estates land there.
The mapping from Gateway API to AGC objects is worth memorizing:
Gateway-> an AGC frontend plus its listenersHTTPRoute-> AGC routing rules (path, header, query, method)backendRefswith weights -> AGC weighted traffic splitBackendTLSPolicy-> backend re-encryption / mTLS to pods
2. Prerequisites and the ALB Controller install
You need an AKS cluster with OIDC issuer and workload identity enabled, plus a dedicated subnet (minimum /24, delegated to Microsoft.ServiceNetworking/trafficControllers) that AGC injects its data plane into.
RG=rg-agc-prod
AKS=aks-agc-prod
LOCATION=eastus2
# Ensure OIDC + workload identity are on (idempotent on an existing cluster)
az aks update -g "$RG" -n "$AKS" \
--enable-oidc-issuer \
--enable-workload-identity
OIDC_ISSUER=$(az aks show -g "$RG" -n "$AKS" \
--query "oidcIssuerProfile.issuerUrl" -o tsv)
The controller authenticates as a user-assigned managed identity federated to its Kubernetes service account. Create the identity, grant it rights, and federate it:
IDENTITY=alb-controller-identity
az identity create -g "$RG" -n "$IDENTITY" -l "$LOCATION"
PRINCIPAL_ID=$(az identity show -g "$RG" -n "$IDENTITY" --query principalId -o tsv)
CLIENT_ID=$(az identity show -g "$RG" -n "$IDENTITY" --query clientId -o tsv)
MC_RG=$(az aks show -g "$RG" -n "$AKS" --query nodeResourceGroup -o tsv)
MC_RG_ID=$(az group show -n "$MC_RG" --query id -o tsv)
# The controller manages AGC inside the node resource group
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--scope "$MC_RG_ID" \
--role "AppGw for Containers Configuration Manager"
# Reader on the subnet's resource group so it can join the delegated subnet
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--scope "$(az group show -n "$RG" --query id -o tsv)" \
--role "Network Contributor"
The
AppGw for Containers Configuration Managerrole is purpose-built for AGC. Do not substitute Contributor — least privilege here is auditable and Microsoft scopes the built-in role exactly to thetrafficControllersand association operations the controller needs.
Federate the identity to the controller’s service account (namespace azure-alb-system, service account alb-controller-sa):
az identity federated-credential create \
--name alb-controller-fedcred \
--identity-name "$IDENTITY" \
-g "$RG" \
--issuer "$OIDC_ISSUER" \
--subject "system:serviceaccount:azure-alb-system:alb-controller-sa" \
--audience api://AzureADTokenExchange
Install the controller via Helm, passing the identity client ID:
az aks get-credentials -g "$RG" -n "$AKS" --overwrite-existing
helm upgrade --install alb-controller \
oci://mcr.microsoft.com/application-lb/charts/alb-controller \
--version 1.7.9 \
--namespace azure-alb-system --create-namespace \
--set albController.namespace=azure-alb-system \
--set albController.podIdentity.clientID="$CLIENT_ID"
Confirm both controller and the webhook are healthy before going further:
kubectl get pods -n azure-alb-system
kubectl get gatewayclass azure-alb-external -o yaml | grep -A5 status:
azure-alb-external is the GatewayClass the chart registers; ACCEPTED=True on it is your green light.
3. Provision the AGC (managed mode)
In managed mode you declare the AGC and its association as CRDs and let the controller build them. Create a namespace and the ApplicationLoadBalancer object, pointing at the delegated subnet:
SUBNET_ID=$(az network vnet subnet show \
-g "$RG" --vnet-name vnet-agc --name subnet-alb \
--query id -o tsv)
kubectl create namespace alb-infra
# alb.yaml
apiVersion: alb.networking.azure.io/v1
kind: ApplicationLoadBalancer
metadata:
name: alb-prod
namespace: alb-infra
spec:
associations:
- /subscriptions/<SUB_ID>/resourceGroups/rg-agc-prod/providers/Microsoft.Network/virtualNetworks/vnet-agc/subnets/subnet-alb
kubectl apply -f alb.yaml
# Watch provisioning; Deployment.Succeeded means the managed AGC + association exist
kubectl get applicationloadbalancer alb-prod -n alb-infra -o yaml | grep -A10 conditions
This step creates the actual Microsoft.ServiceNetworking/trafficControllers resource and a frontend in the node resource group. Provisioning takes a few minutes the first time.
4. Expose a Gateway and the first HTTPRoute
The Gateway references the GatewayClass and ties to your ApplicationLoadBalancer via annotation. Here is an HTTPS listener terminating a cert from a Kubernetes secret:
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gw-prod
namespace: app
annotations:
alb.networking.azure.io/alb-namespace: alb-infra
alb.networking.azure.io/alb-name: alb-prod
spec:
gatewayClassName: azure-alb-external
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "app.kloudvin.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: app-tls
allowedRoutes:
namespaces:
from: Same
kubectl apply -f gateway.yaml
# AGC publishes a generated FQDN; read it back from the Gateway address
kubectl get gateway gw-prod -n app \
-o jsonpath='{.status.addresses[0].value}{"\n"}'
Point your DNS CNAME for app.kloudvin.com at that generated FQDN. Now bind a route:
# route-basic.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: rt-app
namespace: app
spec:
parentRefs:
- name: gw-prod
hostnames:
- "app.kloudvin.com"
rules:
- backendRefs:
- name: app-svc
port: 80
kubectl apply -f route-basic.yaml, and once status.parents[].conditions shows Accepted=True and ResolvedRefs=True, traffic flows. The reconcile is seconds, not the multi-minute ARM churn AGIC inflicted.
5. Weighted traffic splitting and canary
This is where Gateway API earns its keep. Splitting is native: multiple backendRefs under one rule, each with a weight. AGC distributes requests proportionally. A 90/10 canary:
# canary.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: rt-canary
namespace: app
spec:
parentRefs:
- name: gw-prod
hostnames:
- "app.kloudvin.com"
rules:
- backendRefs:
- name: app-svc-stable
port: 80
weight: 90
- name: app-svc-canary
port: 80
weight: 10
Weights are relative, not percentages — 90/10 and 9/1 behave identically. Progress a release by editing weights and re-applying; because propagation is near-real-time, a canary ramp (95/5 -> 80/20 -> 50/50 -> 0/100) is just a sequence of applies, easily driven by Argo Rollouts or Flagger using the Gateway API provider. Set a weight to 0 to drain a backend without deleting the ref, which keeps the rollback path one edit away.
6. Backend TLS, mTLS, and health probes
By default AGC speaks HTTP to your pods. For end-to-end encryption — re-encrypt to the backend — and for mTLS where AGC presents a client cert, you use BackendTLSPolicy (Gateway API) targeting the Service.
First, server-side re-encryption with hostname validation against a CA you trust:
# backend-tls.yaml
apiVersion: alb.networking.azure.io/v1
kind: BackendTLSPolicy
metadata:
name: btls-app
namespace: app
spec:
targetRef:
group: ""
kind: Service
name: app-svc
default:
sni: backend.app.svc.cluster.local
ports:
- port: 443
clientCertificateRef:
name: alb-client-cert # omit for one-way TLS; include for mTLS
verify:
caCertificateRef:
name: backend-ca
subjectAltName: backend.app.svc.cluster.local
The clientCertificateRef is what turns this into mutual TLS: AGC presents that certificate to the backend, and a backend (an Istio sidecar, an NGINX terminating mTLS, etc.) validates it. Drop that field and you get standard one-way re-encryption. The verify block makes AGC validate the backend’s certificate against backend-ca and pin the SAN — skip it only in non-production.
Health probes default to GET / on the backend port. Override per-service with a HealthCheckPolicy:
# health.yaml
apiVersion: alb.networking.azure.io/v1
kind: HealthCheckPolicy
metadata:
name: hc-app
namespace: app
spec:
targetRef:
group: ""
kind: Service
name: app-svc
default:
interval: 5s
timeout: 3s
healthyThreshold: 1
unhealthyThreshold: 3
http:
host: app.kloudvin.com
path: /healthz
match:
statusCodes:
- start: 200
end: 299
Both policies attach by targetRef to the Service, so they travel with the workload, not the gateway — exactly the separation of concerns you want when app and platform teams own different manifests.
7. Header, path, and query routing
Gateway API matches give you composable L7 rules. Match types combine with AND semantics within a rule. A few production patterns:
# routing-advanced.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: rt-advanced
namespace: app
spec:
parentRefs:
- name: gw-prod
hostnames:
- "app.kloudvin.com"
rules:
# Beta cohort: header-based dark launch
- matches:
- headers:
- name: x-cohort
value: beta
type: Exact
backendRefs:
- name: app-svc-beta
port: 80
# API v2 by path prefix, with the prefix rewritten off
- matches:
- path:
type: PathPrefix
value: /api/v2
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: api-v2-svc
port: 8080
# Query-param routing for a debug build
- matches:
- queryParams:
- name: debug
value: "true"
type: Exact
backendRefs:
- name: app-svc-debug
port: 80
# Default
- backendRefs:
- name: app-svc-stable
port: 80
AGC evaluates more specific matches first, so the ordering above behaves intuitively: the header and query rules win over the catch-all. The URLRewrite filter strips /api/v2 before the request reaches api-v2-svc. You can also inject or strip headers with RequestHeaderModifier/ResponseHeaderModifier filters, which is the clean way to add X-Forwarded-* or correlation headers without touching app code.
8. Bring-your-own AGC and Private Link
When a central network team owns the AGC, you provision it with IaC and the cluster only references it. Create the AGC and a frontend with Bicep:
resource agc 'Microsoft.ServiceNetworking/trafficControllers@2023-11-01' = {
name: 'agc-shared'
location: location
}
resource frontend 'Microsoft.ServiceNetworking/trafficControllers/frontends@2023-11-01' = {
parent: agc
name: 'fe-prod'
location: location
}
resource assoc 'Microsoft.ServiceNetworking/trafficControllers/associations@2023-11-01' = {
parent: agc
name: 'assoc-prod'
location: location
properties: {
associationType: 'subnets'
subnet: { id: subnetId }
}
}
In BYO mode you skip the ApplicationLoadBalancer CRD and instead annotate the Gateway with the existing AGC’s frontend resource ID, granting the controller identity the Configuration Manager role on that AGC’s scope. For private exposure, the AGC frontend is reachable over Azure Private Link: create a private endpoint against the frontend and resolve its FQDN through a Private DNS zone, so the generated *.fzXX.alb.azure.com name resolves to a private IP inside the spoke. That keeps north-south traffic off the public internet while preserving the same Gateway API manifests.
Verify
Walk these in order; each isolates a layer.
# 1. Controller and GatewayClass are healthy
kubectl get pods -n azure-alb-system
kubectl get gatewayclass azure-alb-external \
-o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}'
# 2. AGC provisioned (managed mode)
kubectl get applicationloadbalancer alb-prod -n alb-infra \
-o jsonpath='{.status.conditions[*].type}{"\n"}'
# 3. Gateway has an address and Programmed=True
kubectl get gateway gw-prod -n app \
-o jsonpath='{.status.addresses[0].value} {.status.conditions[?(@.type=="Programmed")].status}{"\n"}'
# 4. Route accepted and refs resolved
kubectl get httproute rt-canary -n app \
-o jsonpath='{.status.parents[0].conditions[*].type}{"\n"}'
# 5. End-to-end, and confirm the split lands ~90/10
FQDN=$(kubectl get gateway gw-prod -n app -o jsonpath='{.status.addresses[0].value}')
for i in $(seq 1 50); do
curl -s -k "https://${FQDN}/version" -H "Host: app.kloudvin.com"
done | sort | uniq -c
# 6. Header routing reaches the beta backend
curl -s -k "https://${FQDN}/" -H "Host: app.kloudvin.com" -H "x-cohort: beta"
# 7. Backend TLS active — backend logs should show a client cert if mTLS
kubectl logs deploy/app -n app | grep -i "tls\|client cert" | tail
If a route stays Accepted=False, the controller events tell you why — usually a missing ResolvedRefs because the Service or secret is cross-namespace without a ReferenceGrant.
Enterprise scenario
A fintech platform team I worked with ran AGIC fronting roughly 40 namespaces on one shared cluster. Their pain was concrete and recurring: every Ingress change anywhere triggered a full Application Gateway config push, and a single team’s frequent deploys produced 4-7 minute propagation windows during which unrelated services saw stale routing. Worse, their PCI scope required re-encryption to the payment pods, but AGIC’s backend mTLS story was awkward enough that they had quietly settled for TLS terminating at the edge — an audit finding waiting to happen.
They migrated to AGC in BYO mode so the network team kept ownership of the AGC, its delegated subnet, and a Private Link frontend, all in Terraform. Each app namespace got its own Gateway bound to the shared AGC, which decoupled reconcile blast radius — a deploy in one namespace no longer touched another’s routing. The PCI gap closed with a BackendTLSPolicy carrying a clientCertificateRef, giving genuine mTLS from AGC to the payment service:
apiVersion: alb.networking.azure.io/v1
kind: BackendTLSPolicy
metadata:
name: btls-payments
namespace: payments
spec:
targetRef:
group: ""
kind: Service
name: payments-svc
default:
sni: payments.internal.kloudvin.com
clientCertificateRef:
name: agc-payments-client
verify:
caCertificateRef:
name: payments-ca
subjectAltName: payments.internal.kloudvin.com
The measurable outcome: routing propagation dropped from minutes to single-digit seconds, the noisy-neighbor reconcile storms disappeared, and the next QSA assessment recorded encryption all the way to the cardholder-data workload. The migration ran with both ingress paths live — AGC on a parallel hostname — and DNS weight-shifted over a week, so there was no cutover big bang.