Kubernetes Lesson 34 – Ingress Rules | Dataplexa
Networking, Ingress & Security · Lesson 34

Ingress Rules

The Ingress controller is running. Now you write the rules that tell it where to send traffic. This lesson covers every routing pattern you'll use in production — host-based routing, path-based routing, TLS termination, rewrite rules, and the annotations that give you rate limiting, authentication, and custom headers without touching your application code.

The Anatomy of an Ingress Object

An Ingress object is a set of routing rules. It lives in a namespace, references ClusterIP Services in that same namespace, and tells the Ingress controller: "when a request comes in matching this host and path, forward it to this Service on this port." Everything else — TLS termination, header manipulation, rate limiting — is layered on top via annotations or additional spec fields.

Field Purpose Required?
spec.ingressClassName Which Ingress controller handles this object. Must match an existing IngressClass name. Yes (or use the default class)
spec.tls TLS certificates — which hosts get HTTPS and which Secret holds the cert/key. Optional (HTTP-only without it)
spec.rules List of routing rules — host + path → Service backend combinations. Yes (at least one rule)
spec.defaultBackend Catch-all backend for requests that don't match any rule — returns a 404 page or custom error. Optional

Path-Based Routing

Path-based routing is the most common Ingress pattern for API gateways and microservices. One hostname, multiple paths, each path routes to a different service. Users see a unified API at api.company.com while behind the scenes, requests are distributed to completely separate microservices.

The scenario: Your e-commerce platform exposes a single API hostname to mobile apps: api.shop.com. Behind that hostname live four independent microservices: order management, product catalogue, user accounts, and the payment processor. Each team owns their service and their Ingress path. You're writing the Ingress manifest that routes all four.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-gateway                  # Ingress object name
  namespace: production              # Must be in the same namespace as the target Services
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"   # Enable regex in path matching
spec:
  ingressClassName: nginx            # Which controller handles this — matches IngressClass name
  rules:
    - host: api.shop.com             # Only match requests with this Host header
      http:
        paths:
          - path: /orders(/|$)(.*)   # Match /orders, /orders/, /orders/123, etc.
            pathType: ImplementationSpecific   # Allows regex — controller-specific behaviour
            backend:
              service:
                name: order-svc      # Route to this ClusterIP Service
                port:
                  number: 80

          - path: /products(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: product-svc
                port:
                  number: 80

          - path: /users(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: user-svc
                port:
                  number: 80

          - path: /payments(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: payment-svc
                port:
                  number: 80

          - path: /                  # Catch-all: anything not matched above
            pathType: Prefix         # Prefix: matches any path starting with /
            backend:
              service:
                name: frontend-svc   # Serve the frontend SPA for unmatched paths
                port:
                  number: 80
$ kubectl apply -f api-gateway-ingress.yaml
ingress.networking.k8s.io/api-gateway created

$ kubectl get ingress -n production
NAME          CLASS   HOSTS          ADDRESS                                    PORTS   AGE
api-gateway   nginx   api.shop.com   a9f8e1234567.elb.us-east-1.amazonaws.com   80      8s

$ kubectl describe ingress api-gateway -n production
Name:             api-gateway
Namespace:        production
Address:          a9f8e1234567.elb.us-east-1.amazonaws.com
Ingress Class:    nginx
Rules:
  Host          Path                  Backends
  ----          ----                  --------
  api.shop.com  /orders(/|$)(.*)      order-svc:80 (10.244.1.8:8080,10.244.2.9:8080)
                /products(/|$)(.*)    product-svc:80 (10.244.1.11:8080)
                /users(/|$)(.*)       user-svc:80 (10.244.2.4:8080,10.244.3.1:8080)
                /payments(/|$)(.*)    payment-svc:80 (10.244.1.9:8080)
                /                     frontend-svc:80 (10.244.2.12:3000)

What just happened?

The three pathType valuesExact: matches only that exact path string, nothing more. Prefix: matches the path and everything after it. A Prefix rule for /orders matches /orders, /orders/, and /orders/123/items. ImplementationSpecific: interpreted by the controller — ingress-nginx uses it for regex patterns.

Path ordering matters — ingress-nginx evaluates paths from most-specific to least-specific, not in YAML order. More specific paths (longer strings, regex) take priority over less specific ones. The catch-all / Prefix rule is always last. When debugging routing issues, check the Backends column in kubectl describe ingress — it shows the actual Pod IPs that each path resolves to.

ADDRESS column — This shows the load balancer DNS name or IP that this Ingress is served behind. It comes from the LoadBalancer Service IP of the ingress controller. All Ingress objects in the cluster share this same address — they're all routed through the same entry point.

Host-Based Routing

Host-based routing sends different requests to different services based on the Host HTTP header — effectively virtual hosting. Requests for api.shop.com go to the API, requests for admin.shop.com go to the admin dashboard, requests for docs.shop.com go to the documentation site — all through the same load balancer IP.

The scenario: Your company runs three web properties on the same Kubernetes cluster: the public API, an internal admin portal, and a developer documentation site. All three should be accessible externally but through different hostnames. You also want TLS on all three — with certificates managed by cert-manager.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: multi-host-ingress
  namespace: production
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"  # cert-manager: auto-provision TLS certs
    nginx.ingress.kubernetes.io/ssl-redirect: "true"    # Force HTTPS on all three hosts
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.shop.com                # Include this hostname in the TLS certificate
      secretName: api-shop-tls       # cert-manager will create/renew this Secret automatically
    - hosts:
        - admin.shop.com
      secretName: admin-shop-tls
    - hosts:
        - docs.shop.com
      secretName: docs-shop-tls
  rules:
    - host: api.shop.com             # Rule 1: API traffic
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-svc
                port:
                  number: 80

    - host: admin.shop.com           # Rule 2: Admin portal — separate host, separate service
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: admin-svc
                port:
                  number: 80

    - host: docs.shop.com            # Rule 3: Documentation site
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: docs-svc
                port:
                  number: 80
$ kubectl apply -f multi-host-ingress.yaml
ingress.networking.k8s.io/multi-host-ingress created

$ kubectl get ingress -n production
NAME                CLASS   HOSTS                                        ADDRESS                              PORTS     AGE
multi-host-ingress  nginx   api.shop.com,admin.shop.com,docs.shop.com   a9f8e1234567.elb.us-east-1.amazonaws.com  80, 443   12s

$ kubectl get certificate -n production
NAME             READY   SECRET           AGE
api-shop-tls     True    api-shop-tls     45s   ← cert-manager provisioned and ready
admin-shop-tls   True    admin-shop-tls   44s
docs-shop-tls    True    docs-shop-tls    44s

What just happened?

cert-manager integration — The cert-manager.io/cluster-issuer: "letsencrypt-prod" annotation tells cert-manager (a separate cluster component) to automatically request Let's Encrypt certificates for each TLS host listed. cert-manager handles the ACME challenge, stores the certificate in the named Secret, and automatically renews it before expiry. You never manually manage certificates.

TLS and rules cross-reference by hostname — The spec.tls section and spec.rules are linked by hostname. When a request for api.shop.com arrives on port 443, the ingress controller looks up the TLS entry for that host, retrieves the certificate from the Secret, and performs the TLS handshake before routing to the backend. If the hostnames don't match between tls and rules, the certificate won't be served for those requests.

PORTS: 80, 443 — Now that TLS is configured, the ingress exposes both HTTP (for the redirect) and HTTPS. The ssl-redirect: true annotation means HTTP connections get a 308 redirect to HTTPS automatically. Your users always end up on HTTPS.

URL Rewriting

Sometimes the path your users or other services use externally doesn't match the path the backend application expects. URL rewriting lets the Ingress controller transform the path before forwarding the request — without any changes to the application.

The scenario: Your mobile app calls api.shop.com/orders/v1/... but the order service's internal API is at /v1/... — it has no /orders prefix. You need the Ingress to strip the /orders prefix before forwarding the request to the order service.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rewrite-ingress
  namespace: production
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    # rewrite-target: replaces the matched path with /$2 (capture group 2 from the path regex)
    # Combined with the path below:
    # Request:  /orders/v1/items → capture group 2 = v1/items → forwarded as /v1/items
    # Request:  /orders/         → capture group 2 = (empty)  → forwarded as /
    # Request:  /orders          → capture group 2 = (empty)  → forwarded as /
spec:
  ingressClassName: nginx
  rules:
    - host: api.shop.com
      http:
        paths:
          - path: /orders(/|$)(.*)   # Capture groups: group 1 = / or end, group 2 = remainder
            pathType: ImplementationSpecific
            backend:
              service:
                name: order-svc
                port:
                  number: 80

          - path: /products(/|$)(.*)  # Same pattern for products
            pathType: ImplementationSpecific
            backend:
              service:
                name: product-svc
                port:
                  number: 80
$ kubectl apply -f rewrite-ingress.yaml
ingress.networking.k8s.io/rewrite-ingress created

External request:  GET https://api.shop.com/orders/v1/items?status=pending
Order service sees: GET /v1/items?status=pending

External request:  GET https://api.shop.com/products/categories
Product service sees: GET /categories

External request:  GET https://api.shop.com/orders/
Order service sees: GET /

What just happened?

Capture group magic — The path regex /orders(/|$)(.*) has two capture groups. Group 1 ($1) captures the slash or end-of-string after /orders. Group 2 ($2) captures everything after that. The rewrite-target: /$2 annotation replaces the entire matched path with just group 2, effectively stripping the /orders prefix.

Query strings are preserved — The rewrite only affects the path component of the URL. Query strings (?status=pending) are passed through unchanged. This is the expected behaviour — the backend service receives the full query string as the caller specified it.

Production Annotations: Rate Limiting, Auth, and More

The ingress-nginx annotation system is where the real power lives. You can add rate limiting, basic auth, IP whitelisting, custom headers, CORS configuration, and connection limits — all without touching your application code, all in the Ingress manifest.

The scenario: Your payment API is being targeted by automated scanners. You need to add rate limiting to prevent abuse, restrict access to known partner IP ranges, and add CORS headers for your frontend's cross-origin requests — all without a deploy of the payment service itself.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payment-api-ingress
  namespace: production
  annotations:
    # --- Rate Limiting ---
    nginx.ingress.kubernetes.io/limit-rps: "20"
    # limit-rps: max 20 requests per second per client IP
    # Clients exceeding this receive 503 Too Many Requests

    nginx.ingress.kubernetes.io/limit-connections: "10"
    # limit-connections: max 10 simultaneous connections per client IP

    nginx.ingress.kubernetes.io/limit-burst-multiplier: "5"
    # Burst up to 5× the rate limit before throttling kicks in
    # Allows short legitimate spikes without dropping legitimate requests

    # --- IP Allowlisting ---
    nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.0/24,198.51.100.0/24"
    # Only allow traffic from these CIDRs — all other IPs get 403 Forbidden
    # Useful for partner APIs, admin portals, internal tooling

    # --- CORS (Cross-Origin Resource Sharing) ---
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/cors-allow-origin: "https://shop.com,https://admin.shop.com"
    nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
    nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type, X-Request-ID"
    nginx.ingress.kubernetes.io/cors-max-age: "86400"
    # cors-max-age: browsers cache the preflight response for 24 hours (86400 seconds)
    # Reduces OPTIONS preflight requests significantly

    # --- Custom Response Headers ---
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "X-Request-ID: $request_id";
      more_set_headers "Cache-Control: no-store";
    # configuration-snippet: inject arbitrary nginx config into the location block
    # Use for per-Ingress config that isn't covered by a dedicated annotation

    # --- Timeouts (per-Ingress override of controller defaults) ---
    nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
    # Override the controller-wide read timeout for this specific service
    # Payment processing can take longer — give it 2 minutes

spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.shop.com
      secretName: api-shop-tls
  rules:
    - host: api.shop.com
      http:
        paths:
          - path: /payments
            pathType: Prefix
            backend:
              service:
                name: payment-svc
                port:
                  number: 80
$ kubectl apply -f payment-api-ingress.yaml
ingress.networking.k8s.io/payment-api-ingress created

$ curl -I https://api.shop.com/payments/health
HTTP/2 200
x-request-id: a4f8b2d1e5c6g7h8
cache-control: no-store
access-control-allow-origin: https://shop.com

$ curl -I -H "Origin: https://evil.com" https://api.shop.com/payments/health
HTTP/2 200
(no Access-Control-Allow-Origin header — evil.com not in allowed list)

$ for i in {1..25}; do curl -s -o /dev/null -w "%{http_code} " https://api.shop.com/payments/health; done
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 503 503 503 503 503

What just happened?

Rate limiting at the edge — The burst test shows 20 successful requests followed by 503s. The rate limiter allowed the burst multiple (5×20=100... but it was already close to the limit when the burst was absorbed). This protection happens entirely in nginx — the payment service never receives the throttled requests. No load, no processing, no database queries from the abusive client.

CORS without application changes — Browsers making cross-origin requests from shop.com get the correct CORS headers. Requests from evil.com get no CORS header — the browser blocks them. The payment service code has zero CORS logic — it's entirely handled at the ingress layer.

configuration-snippet scope — The configuration-snippet annotation injects nginx config into the location block for this specific Ingress path only. It doesn't affect other Ingress rules. Use it for fine-grained control that standard annotations don't cover. Be careful: malformed nginx syntax here will cause the controller to emit an error event and potentially revert to the last valid config.

The Routing Decision Map

Here's how ingress-nginx evaluates an incoming request against all active Ingress rules — the exact precedence order:

ingress-nginx Routing Precedence

Step 1
Match by Host header. Find all Ingress rules where spec.rules[].host matches the request's Host header exactly. Wildcard hosts (*.shop.com) are supported but lower priority than exact matches.
Step 2
Match by path — most specific first. Among the matched host's paths: Exact paths beat Prefix paths beat ImplementationSpecific. Longer paths beat shorter paths. If two rules tie, the one defined first in the YAML wins.
Step 3
Route to backend Service. The matched rule's backend Service name is resolved via DNS to its ClusterIP. kube-proxy DNAT forwards to a healthy Pod endpoint. Annotations (rate limit, CORS, rewrite) are applied at this stage.
Fallback
No match found. If no rule matches, the request goes to the Ingress's spec.defaultBackend (if defined) or the controller's global default backend — typically returning a 404 "default backend - 404" response.

Debugging Ingress Routing

The scenario: A request to api.shop.com/orders/123 is returning a 404 from the ingress default backend instead of hitting the order service. Something is wrong with the routing.

kubectl describe ingress api-gateway -n production
# Full rule table: shows which paths map to which backends and their current endpoints
# If a backend shows "no endpoints" → the ClusterIP Service has no matching Pods
# If a path is missing entirely → check the YAML for typos

kubectl get ingress api-gateway -n production -o yaml
# Full YAML view: useful for checking that annotations were applied correctly

kubectl logs -l app.kubernetes.io/component=controller -n ingress-nginx --tail=100
# Controller logs: shows every request processed and any configuration errors
# Look for: "error obtaining endpoints" (service has no pods)
#           "could not get service" (service name typo or wrong namespace)
#           "configuration changed" (shows when a new Ingress rule was picked up)

kubectl exec -it -n ingress-nginx deploy/ingress-nginx-controller -- \
  nginx -T | grep -A10 "server_name api.shop.com"
# nginx -T: dump the full nginx configuration the controller is running
# Grep for your hostname to see the exact location blocks it generated
# This is the ground truth — if the nginx config looks wrong, the Ingress is broken
$ kubectl logs -l app.kubernetes.io/component=controller -n ingress-nginx --tail=20
W0310 10:44:31 Unexpected error obtaining information about the endpoints
  for service "production/order-svc": no object matching key "production/order-svc"
  in local store

$ kubectl get svc -n production | grep order
(no output — service doesn't exist!)

$ kubectl describe ingress api-gateway -n production | grep orders
  api.shop.com  /orders(/|$)(.*)  order-svc:80 (<error: endpoints "order-svc" not found>)

(root cause: the order Service was accidentally deleted — controller can't find endpoints)

$ kubectl apply -f order-service.yaml
service/order-svc created

$ kubectl logs -l app.kubernetes.io/component=controller -n ingress-nginx --tail=5
I0310 10:45:12 Configuration changes detected, backend reload required
I0310 10:45:12 Backend successfully reloaded

What just happened?

nginx -T is the ultimate truth — When standard debugging doesn't reveal the issue, dumping the actual nginx config the controller generated shows you exactly what nginx is doing with your Ingress rules. Mismatched paths, wrong proxy_pass targets, and missing location blocks are all visible here. It's verbose but definitive.

Controller logs are chatty but useful — The ingress-nginx controller logs every reload, every configuration error, and every endpoint discovery failure. During debugging, --tail=100 and grepping for your service name is often faster than a full describe.

Auto-reload on fix — The moment the missing Service was re-created, the controller detected the change via its API watch and automatically reloaded nginx. The "Backend successfully reloaded" log message confirms the fix took effect. No manual intervention needed — the controller is continuously reconciling.

Teacher's Note: Keep Ingress objects small and focused

There are two valid schools of thought on Ingress organisation. The first: one big Ingress object per cluster or per namespace with all routes in it. Easy to see everything at a glance, but any change touches the shared object and risks merge conflicts in Git. The second: one Ingress per service team, each team owns their Ingress rules. Scales better with many teams, clean ownership, no conflicts.

The second approach is almost always better past 5 services. Each team deploys their Ingress rule alongside their Service and Deployment in the same PR. The ingress-nginx controller merges all Ingress objects in the namespace into one nginx config transparently. Teams don't need to know about each other's routes.

One annotation to be careful with: nginx.ingress.kubernetes.io/configuration-snippet. It injects raw nginx config and can break the entire controller if the syntax is wrong. Some security-conscious platform teams disable this annotation entirely (--allow-snippet-annotations=false on the controller) to prevent teams from accidentally or maliciously injecting arbitrary nginx directives.

Practice Questions

1. External clients call /api/v1/users but your user service expects requests at /v1/users. Which ingress-nginx annotation strips the /api prefix before forwarding to the backend?



2. Which annotation on an Ingress object tells cert-manager to automatically provision and renew a Let's Encrypt TLS certificate for the listed hosts?



3. You suspect a routing rule isn't being applied correctly. What command dumps the full nginx configuration the ingress-nginx controller is currently running — showing the exact generated location blocks for all Ingress rules?



Quiz

1. An Ingress has two rules for the same host: pathType: Exact, path: /orders/new and pathType: Prefix, path: /orders. A request arrives for /orders/new. Which rule wins?


2. An Ingress has rules for api.shop.com and admin.shop.com. A client sends a request directly to the load balancer IP address without a Host header. What happens?


3. You add the nginx.ingress.kubernetes.io/enable-cors: "true" annotation to an Ingress. What does this mean for your backend application code?


Up Next · Lesson 35

DNS in Kubernetes

How CoreDNS resolves service names to IPs inside the cluster, the full DNS search path that makes short names work, and the patterns for cross-namespace service discovery.