4.2. Traefik Ingress Controller

In this lab you install Traefik on AKS using install_helm_chart, wiring the static public IP from Chapter 3 directly into the Helm values as a Pulumi output.

Step 1 — Declare the Traefik Release

Add the following call to __main__.py, after the DNS setup block:

aks.install_helm_chart(
    "traefik",
    chart="traefik",
    repo="https://traefik.github.io/charts",
    namespace="traefik",
    timeout=600,
    values=public_ip.name.apply(lambda pip_name: {
        "service": {
            "enabled": True,
            "type": "LoadBalancer",
            "annotations": {
                "service.beta.kubernetes.io/azure-pip-name": pip_name
            }
        }
    })
)

Explanation

The Traefik Helm chart creates a Kubernetes Service of type LoadBalancer. AKS reads the annotation on that service to attach the pre-provisioned static IP:

AnnotationValueEffect
service.beta.kubernetes.io/azure-pip-namePublic IP resource nameBinds the load balancer to the static IP

timeout=600 raises the Helm wait limit to 10 minutes. The default is 5 minutes, which is shorter than the time Azure typically takes to provision an external load balancer — without it Pulumi times out while the LB is still being created.

No azure-load-balancer-resource-group annotation is needed because the public IP lives in the AKS node resource group — the default location AKS searches when that annotation is absent.

Why the node resource group?

Microsoft’s AKS static IP guidance requires the public IP to be created in the cluster’s node resource group. AKS’s cloud-controller-manager already holds Network Contributor permissions there; placing the IP in any other resource group requires an explicit role assignment — and Azure IAM propagation can take several minutes, causing the controller to report “public IP doesn’t exist” even after the resource is created.

public_ip.name is a Pulumi Output[str] — its value is not available as a plain string at program construction time. Calling .apply(lambda pip_name: {...}) produces an Output[dict] that resolves only after the public IP is created. The Helm release accepts this as its values argument and Pulumi will not create the release until public_ip is ready.

No depends_on needed

The Output reference inside values already encodes the dependency: Pulumi will not create the Helm release until public_ip is ready. An explicit depends_on would be redundant.


Step 2 — Deploy and Verify

Apply the changes:

pulumi up

The Traefik Helm release will appear as a new resource in the diff. After the deploy completes, confirm the service has the correct external IP:

kubectl get svc -n traefik

Expected output:

NAME      TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)                      AGE
traefik   LoadBalancer   10.0.x.x       <your-static-ip> 80:xxxxx/TCP,443:xxxxx/TCP   1m

The EXTERNAL-IP column should show the same address as pulumi stack output public_ip.

Explanation

Traefik is now running as the cluster’s ingress controller. Any Ingress resource created in the cluster will be picked up by Traefik and routed to the correct backend service. In the next section, Cert-Manager will handle automatic TLS certificate issuance for those ingress routes.


Step 3 — Test HTTP Routing with an Ingress

Deploy a test pod and verify that Traefik routes HTTP traffic correctly.

Create http.yaml, replacing YOUR_USERNAME with your username:

apiVersion: v1
kind: Namespace
metadata:
  name: tests

---

apiVersion: v1
kind: Pod
metadata:
  name: hello
  namespace: tests
  labels:
    app: hello
spec:
  containers:
    - name: hello
      image: nginxdemos/hello:plain-text
      ports:
        - containerPort: 80
          protocol: TCP

---

apiVersion: v1
kind: Service
metadata:
  name: hello
  namespace: tests
spec:
  selector:
    app: hello
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
  namespace: tests
spec:
  ingressClassName: traefik
  rules:
    - host: "hello.YOUR_USERNAME.labs.netrics.dev"
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  number: 80

Apply and verify:

kubectl apply -f http.yaml
curl http://hello.$(pulumi stack output dns_zone)

Explanation

ingressClassName: traefik tells Kubernetes which ingress controller owns this resource. Traefik watches for Ingress objects with its class name and programs its routing table accordingly — no controller-specific annotations are needed for basic HTTP routing.

The wildcard A record created in Chapter 3 (*.YOUR_USERNAME.labs.netrics.dev → static IP) means hello.YOUR_USERNAME.labs.netrics.dev resolves without any additional DNS entry. Traefik matches the host header in the incoming request and forwards traffic to the hello service on port 80.

Ingress vs. Gateway API

The Ingress resource is the established, widely-supported way to expose HTTP services in Kubernetes. The newer Kubernetes Gateway API (HTTPRoute, Gateway, GatewayClass) offers a richer model — separating infrastructure concerns (Gateway) from application routing (HTTPRoute) — and is the direction the ecosystem is moving. Traefik v3 supports both.

For this workshop we use Ingress because it requires no additional CRD installation and the concepts map directly to what participants will encounter in most existing clusters. The Gateway API is a natural next step once the basics are solid.


Complete files

__main__.py (Traefik block)

aks.install_helm_chart(
    "traefik",
    chart="traefik",
    repo="https://traefik.github.io/charts",
    namespace="traefik",
    timeout=600,
    values=public_ip.name.apply(lambda pip_name: {
        "service": {
            "enabled": True,
            "type": "LoadBalancer",
            "annotations": {
                "service.beta.kubernetes.io/azure-pip-name": pip_name
            }
        }
    })
)

http.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: tests

---

apiVersion: v1
kind: Pod
metadata:
  name: hello
  namespace: tests
  labels:
    app: hello
spec:
  containers:
    - name: hello
      image: nginxdemos/hello:plain-text
      ports:
        - containerPort: 80
          protocol: TCP

---

apiVersion: v1
kind: Service
metadata:
  name: hello
  namespace: tests
spec:
  selector:
    app: hello
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

---

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
  namespace: tests
spec:
  ingressClassName: traefik
  rules:
    - host: "hello.YOUR_USERNAME.labs.netrics.dev"
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  number: 80