Skip to content
Documentation Background

Ingress API (Legacy)

Every web-facing Kubernetes workload needs a way in from the internet. A LoadBalancer Service works, but costs you one cloud load balancer per service - 30 apps means 30 public IPs, 30 billing items, 30 quota slots. Ingress collapses all of that behind a single entry point by operating at Layer 7, where it can read hostnames and URL paths and route accordingly.

Load Balancer Problems

Two generations of Kubernetes ingress infrastructure:

  • Ingress API - the original L7 routing mechanism, GA since Kubernetes 1.19. Stable and widely deployed, but monolithic and annotation-heavy for anything beyond basic routing.
  • Gateway API - the next-generation replacement, developed by the Kubernetes Network SIG. Role-oriented, multi-protocol (L4-L7), and extensible without vendor annotations.
Kubernetes Ingress

Key terms:

  • Ingress (capital I) is the Kubernetes API resource (networking.k8s.io/v1). Lowercase ingress just means incoming traffic.
  • Ingress controller is the reconciliation process that watches Ingress objects and configures the underlying proxy. Kubernetes ships none by default - you install one.
  • Layer 7 aware: both Ingress and Gateway API operate at the HTTP application layer, routing on hostnames, paths, headers, and cookies - things a Layer 4 TCP balancer cannot see.

AspectNodePortLoadBalancerIngress
OSI layerL4 Transport (TCP/UDP)L4 Transport (TCP/UDP)L7 Application (HTTP/HTTPS)
Entry pointHigh port (30000-32767) on every Node IPDedicated cloud LB IP per ServiceShared cloud LB IP - port 80/443 for all Services
Public IPsNone (use node IP + port)One per ServiceOne shared across all Services
Routing logicIP + port onlyIP + port onlyHost header, URL path, cookies, headers
Traffic targetService ClusterIP (via kube-proxy)Service ClusterIP (via kube-proxy)Pod IPs directly (bypasses kube-proxy)
TLS terminationNot supportedNot supportedNative via spec.tls
Session affinityClient IP onlyClient IP onlyCookie-based (L7 inspection)
Cost & frictionLowest cost - high client friction (raw ports)High cost - 1:1 mapping limitsHighly efficient - 1-to-many mapping
Best use caseLocal dev, testing, internal accessSingle high-traffic or non-HTTP serviceMicroservices cluster with multiple web apps

Three distinct layers work together:

  • Ingress API Object - the declarative spec: hostnames, paths, TLS secrets, backend Service references. Stored in the API server, processed by nothing on its own.
  • Ingress Controller - the control-plane reconciliation loop. Watches the API server for Ingress, Service, and EndpointSlice changes, synthesizes proxy config, and applies it to the reverse proxy.
  • L7 Reverse Proxy - the data-plane engine (Nginx, Envoy, HAProxy, cloud ALB). Physically terminates client connections and forwards HTTP requests to Pod IPs.
flowchart TD
    subgraph ControlPlane["Control Plane"]
        API["☸️ Kubernetes API Server<br/><code>Ingress</code> · <code>Service</code> · <code>EndpointSlice</code>"]
        IC["⚙️ Ingress Controller<br/><i>Reconciliation loop · synthesizes config</i>"]
        API -->|"watch stream"| IC
    end
    subgraph DataPlane["Data Plane"]
        Proxy["🔀 L7 Reverse Proxy<br/><i>Nginx / Envoy / HAProxy / Cloud ALB</i>"]
        Pods["📦 Target Pod(s)<br/><i>Direct Pod IP routing</i>"]
        Proxy -->|"bypasses ClusterIP & kube-proxy"| Pods
    end
    Client["👤 Client"] -->|"HTTP / HTTPS request"| Proxy
    IC -->|"generates / reconfigures"| Proxy

The controller continuously watches three API resources:

  1. Ingress - routing rules and TLS config
  2. Service - port mapping and selector metadata
  3. EndpointSlice - live Pod IP list

On any change, it merges the Ingress spec with current Pod IPs and reconfigures the proxy. When an Ingress is deleted, the controller removes the corresponding proxy rules and any associated resources.

  • Co-located (common open-source setup): Controller and proxy run as two processes in the same Pod (e.g., community Nginx Ingress Controller).
  • Cloud-managed (external topology): An in-cluster controller interface talks to cloud infrastructure to provision external load balancers (GKE GLBC, AWS ALB Controller, Azure AGIC).

Unlike Deployments or ReplicaSets, Kubernetes does not ship a built-in Ingress controller. You must install one:

Cloud / ToolController
GKEGLBC (GCE L7 Load Balancer)
AWS EKSAWS Load Balancer Controller
Azure AKSAGIC (Application Gateway Ingress Controller)
Self-managedNginx Ingress (k8s.io/ingress-nginx), Traefik, Contour, Ambassador, HAProxy

kind:

Terminal window
# kind cluster must map host ports 80/443 and label the control-plane node ingress-ready=true
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

minikube:

Terminal window
minikube addons enable ingress
minikube tunnel # required to reach LoadBalancer/Ingress IPs from host OS
Journey of a Request in Kubernetes Ingress

IngressClass is a cluster-scoped resource that links an Ingress object to a specific controller implementation. It replaced the legacy kubernetes.io/ingress.class annotation (deprecated).

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: nginx
spec:
controller: k8s.io/ingress-nginx # unique string identifying the controller process
spec:
ingressClassName: nginx # set in the Ingress spec, not metadata

Controllers ignore Ingress objects that don’t match their class - critical when running multiple controllers in the same cluster.

An Ingress without ingressClassName uses the cluster default:

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: nginx
annotations:
ingressclass.kubernetes.io/is-default-class: "true" # marks as default
spec:
controller: k8s.io/ingress-nginx

Controllers that need structured configuration (beyond annotations) use spec.parameters to reference a CRD instance:

# IngressClass referencing AWS-specific parameters
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: aws-alb
spec:
controller: ingress.k8s.aws/alb
parameters:
apiGroup: elbv2.k8s.aws
kind: IngressClassParams
name: my-alb-params
---
# The CRD holding AWS ALB settings
apiVersion: elbv2.k8s.aws/v1beta1
kind: IngressClassParams
metadata:
name: my-alb-params
spec:
scheme: internal # internal vs internet-facing ALB
ipAddressType: dualstack
tags:
- key: org
value: my-org
Terminal window
kubectl get ingressclasses
kubectl describe ingressclass nginx

The Ingress controller routes to ClusterIP Services. The Service does not need to be NodePort or LoadBalancer - it just needs to exist:

apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP # Ingress handles external exposure
selector:
app: web
ports:
- name: http
port: 80
targetPort: 8080
- name: https
port: 443
targetPort: 8443
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress # name it after the host/domain, not the app
spec:
ingressClassName: nginx # selects the controller; see IngressClass section
rules:
- host: app.example.com # matched against HTTP Host header
http:
paths:
- pathType: Prefix
path: /
backend:
service:
name: web
port:
number: 80

Fastest way to create an Ingress - useful as a starting point that you then edit for annotations:

Terminal window
# Rule format: <host>/<path>=<service>:<port>
kubectl create ingress web-ingress \
--rule="app.example.com/=web:80" \
--rule="app.example.com/api=api-service:8080"
# Generate YAML template without creating (add annotations before applying)
kubectl create ingress web-ingress \
--rule="app.example.com/=web:80" \
--dry-run=client -o yaml > ingress.yaml
# Edit ingress.yaml to add annotations, then:
kubectl apply -f ingress.yaml
  • HTTP port 80 is implied when no TLS Secret is referenced.
  • Specifying tls=mysecret in the rule adds port 443 automatically.
  • Annotations (e.g. rewrite-target, affinity) cannot be set via --rule flags - always use dry-run + edit for those.
Terminal window
# List all Ingresses (ing is the official shorthand)
kubectl get ing
# NAME CLASS HOSTS ADDRESS PORTS AGE
# web-ingress nginx app.example.com 11.22.33.44 80 30s
# Full rule breakdown - shows live Pod IPs when healthy
kubectl describe ing web-ingress
# Rules:
# Host Path Backends
# ---- ---- --------
# app.example.com / web:80 (10.244.1.4:8080, 10.244.2.3:8080)
# Events:
# Normal Sync 5m nginx-ingress-controller Scheduled for sync
# Extract the public IP
kubectl get ing web-ingress -o jsonpath='{.status.loadBalancer.ingress[0].ip}'

The ADDRESS field may take a few minutes to appear in cloud environments while the load balancer provisions. If it stays empty, the controller is not picking up the Ingress - check ingressClassName and that the controller pod is running.

Endpoint error - most common misconfiguration:

Rules:
Host Path Backends
---- ---- --------
app.example.com
/ web:80 (<error: endpoints "web" not found>)

Causes and fixes:

  • Service doesn’t exist - kubectl create service clusterip web --tcp=80:8080
  • No matching pods - check Service selector matches Pod labels: kubectl get ep web
  • Port mismatch - Service port in Ingress rule must match the Service’s exposed port, not the container port

Always check the Events section at the bottom of kubectl describe ing - Warning events show sync failures.


Route different URL paths under one hostname to different backend Services:

spec:
rules:
- host: api.example.com
http:
paths:
- path: /orders # Exact: /orders only
pathType: Exact
backend:
service:
name: orders
port:
name: http
- path: /catalog # Prefix: /catalog, /catalog/items, etc.
pathType: Prefix
backend:
service:
name: catalog
port:
name: http
pathTypeBehaviourExample
ExactCase-sensitive exact character match/foo matches /foo only, not /foo/ or /foobar
PrefixElement-by-element match split on //foo matches /foo, /foo/bar, /foo/ but NOT /foobar
ImplementationSpecificController-defined logic (regex, wildcards)GKE: /foo/* wildcard; Citrix: custom HTTPRoute

Prefix matching detail - element splitting:

Rule: /foo elements: ["foo"]
/foo/bar elements: ["foo", "bar"] -> MATCH (first element = "foo")
/foobar elements: ["foobar"] -> NO MATCH ("foobar" != "foo")
/foo/ trailing slash ignored -> MATCH

A catch-all prefix rule uses / - matches every path.

Combine multiple virtual hosts into one Ingress to share a single public IP:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
spec:
ingressClassName: nginx
rules:
# Virtual host 1
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
name: http
# Virtual host 2 - path-based
- host: api.example.com
http:
paths:
- path: /orders
pathType: Exact
backend:
service:
name: orders
port:
name: http
- path: /catalog
pathType: Prefix
backend:
service:
name: catalog
port:
name: http

The host field accepts *.domain.tld wildcards - one DNS label only:

Rule hostMatchesDoes NOT match
app.example.comapp.example.comapi.example.com, foo.app.example.com
*.example.comapp.example.com, api.example.comexample.com, foo.app.example.com
(omitted)Any host header-

Precedence: exact host rules always win over matching wildcard rules.

Traffic that matches no host or path rule goes to the default backend. Without one, the proxy returns HTTP 404:

spec:
defaultBackend: # catch-all for unmatched requests
service:
name: fallback
port:
name: http
rules:
- host: app.example.com
# ...
Default Backend Ingress

A rules-free Ingress (only defaultBackend, no spec.rules) exposes a single service with full L7 features (TLS, cookie affinity, header rewrites) - useful if you need those without a LoadBalancer Service.


Because Ingress routing is virtual-host-based, querying the raw IP without a Host header will not match any rule.

Injects a hostname-to-IP mapping into curl’s local DNS cache without touching system files:

Terminal window
# Replace 11.22.33.44 with output of: kubectl get ing -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}'
curl --resolve app.example.com:80:11.22.33.44 http://app.example.com -v
# Path-based routes
curl --resolve api.example.com:80:11.22.33.44 http://api.example.com/orders
curl --resolve api.example.com:80:11.22.33.44 http://api.example.com/catalog/items
# /etc/hosts (Linux/macOS) or C:\Windows\System32\drivers\etc\hosts (Windows)
11.22.33.44 app.example.com api.example.com
Cluster typeAddress behaviourFix
minikube (VM)ADDRESS may show localhost - only valid inside VMRun minikube ip for actual VM IP; run minikube tunnel
kind (Docker)Host ports 80/443 mapped via control-plane node configUse 127.0.0.1 in --resolve or /etc/hosts
Cloud (GKE/EKS/AKS)Real public IP, may take 1-3 minutes to appearWait; verify controller pod is running

wget is a useful alternative to curl for quick Ingress path testing, particularly for verifying Exact vs. Prefix pathType behaviour:

Terminal window
# Exact match - returns 200 OK
wget app.example.com/api --timeout=5 --tries=1
# Trailing slash with Exact pathType - returns 404 Not Found
# Fix: change pathType to Prefix in the Ingress manifest
wget app.example.com/api/ --timeout=5 --tries=1

If app.example.com doesn’t resolve locally, add it to /etc/hosts first (see section above).


TLS Termination Ingress

The Ingress proxy decrypts HTTPS at the entry point and forwards plain HTTP to backend Pods. Backend apps need no TLS logic.

Client (HTTPS :443) -> Ingress Proxy (terminates TLS) -> HTTP -> Backend Pod
Terminal window
# Generate a self-signed cert (dev/testing only)
openssl req -x509 -newkey rsa:2048 -keyout tls.key -out tls.crt \
-sha256 -days 365 -nodes \
-subj '/CN=*.example.com' \
-addext 'subjectAltName = DNS:*.example.com'
# Store in Kubernetes as type kubernetes.io/tls
kubectl create secret tls tls-example-com --cert=tls.crt --key=tls.key

The Secret contains tls.crt (PEM certificate) and tls.key (PEM private key).

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
spec:
tls:
- secretName: tls-example-com # references the Secret
hosts: # must match cert CN/SAN
- app.example.com
- api.example.com
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
name: http

spec.tls is an array - attach multiple Secrets for different domain/cert combinations in one Ingress.

Terminal window
curl https://app.example.com --resolve app.example.com:443:11.22.33.44 -k -v
# -k skips cert validation for self-signed; remove -k in production
TLS TerminationTLS Passthrough
Decryption atIngress proxyBackend Pod
Pod trafficUnencrypted HTTPEncrypted HTTPS
Cert managementCentralized in K8s SecretPer-pod (volumes, app code)
L7 inspectionFull (path, cookies, headers)None (proxy can’t see payload)
API supportStandard spec.tls fieldVendor annotations only (e.g. nginx.ingress.kubernetes.io/ssl-passthrough: "true")
Controller flagDefault enabledMust enable --enable-ssl-passthrough in Nginx

Use TLS termination unless you specifically need end-to-end encryption to the Pod.


In a cloud cluster, two tools eliminate manual certificate and DNS management entirely:

  • ExternalDNS - watches Ingress and Service resources, automatically creates and updates A/CNAME records in your DNS provider (Route53, Cloud DNS, Cloudflare, etc.) to point to the load balancer IP.
  • cert-manager - manages the full lifecycle of TLS certificates: requests, DNS-01/HTTP-01 ACME challenges, renewal, and storing certs as Kubernetes Secrets.
Terminal window
# Install cert-manager (Helm)
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true

Define a ClusterIssuer for Let’s Encrypt (shared across all namespaces):

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01: # HTTP-01: valid for non-wildcard, publicly reachable
ingress:
ingressClassName: nginx
# For wildcard certs use dns01 solver instead:
# - dns01:
# route53:
# region: us-east-1
# hostedZoneID: ZXXXXXXXXXXXXX

Annotate an Ingress to trigger automatic certificate issuance:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod # triggers cert-manager
spec:
ingressClassName: nginx
tls:
- hosts:
- app.example.com
secretName: app-example-com-tls # cert-manager populates this
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80

cert-manager detects the annotation, creates a Certificate resource, completes the ACME challenge, and stores the cert in app-example-com-tls. The Ingress controller picks it up automatically.

Terminal window
# Monitor certificate lifecycle
kubectl get certificates
kubectl describe certificate app-example-com-tls
kubectl get certificaterequests

Automates DNS record creation when Ingress/Service resources appear:

apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: external-dns
spec:
template:
spec:
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.15.1
args:
- --source=ingress # also: service, gateway-httproute
- --domain-filter=example.com
- --provider=aws # or: google, cloudflare, azure
- --policy=upsert-only # never delete records
- --registry=txt # TXT records for ownership tracking
- --txt-owner-id=my-cluster
  • ExternalDNS reads the spec.rules[].host values from Ingress resources and creates A records pointing to the load balancer IP.
  • Use --source=gateway-httproute to support Gateway API HTTPRoute resources.
  • Scope it with --domain-filter to avoid touching DNS zones you don’t own.
  • Use --policy=upsert-only to prevent accidental deletion in production.

Automated Workflow (cert-manager + ExternalDNS together)

Section titled “Automated Workflow (cert-manager + ExternalDNS together)”
flowchart TD
    S1["1️⃣ Developer creates Ingress<br/><i>Includes hostnames & cert-manager annotation</i>"]
    S2["2️⃣ ExternalDNS reads hostnames<br/><i>Creates DNS A record pointing to Load Balancer IP</i>"]
    S3["3️⃣ cert-manager starts ACME HTTP-01 challenge<br/><i>Provisions temporary challenge path on Ingress</i>"]
    S4["4️⃣ Let's Encrypt validates challenge<br/><i>Verifies domain ownership & issues certificate</i>"]
    S5["5️⃣ cert-manager stores certificate<br/><i>Writes cert + private key to Kubernetes TLS Secret</i>"]
    S6["6️⃣ Ingress controller mounts Secret<br/><i>TLS terminated · HTTPS live 🔒</i>"]

    S1 --> S2
    S2 --> S3
    S3 --> S4
    S4 --> S5
    S5 --> S6

Total manual steps: zero after initial setup.


The core Ingress spec is intentionally minimal (spec.rules, spec.tls, spec.defaultBackend, spec.ingressClassName). Advanced proxy features use metadata.annotations with vendor-prefixed keys - controllers ignore annotations not meant for them.

CapabilityNginx annotationUse case
Cookie session affinitynginx.ingress.kubernetes.io/affinity: "cookie"Stateful sessions without client IP dependency
Session cookie namenginx.ingress.kubernetes.io/session-cookie-name: "SID"Custom cookie name
URL rewritingnginx.ingress.kubernetes.io/rewrite-target: /Strip /api/v1 prefix before forwarding
SSL passthroughnginx.ingress.kubernetes.io/ssl-passthrough: "true"End-to-end TLS to pod
HTTP authnginx.ingress.kubernetes.io/auth-type: basicProtect endpoints at proxy level
HTTPS redirectnginx.ingress.kubernetes.io/ssl-redirect: "true"Force HTTP -> HTTPS
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "SESSION"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80

How it works:

  1. First request: proxy picks a Pod, returns Set-Cookie: SESSION=<hash>; Path=/
  2. Client sends Cookie: SESSION=<hash> on all subsequent requests
  3. Proxy reads cookie and pins to the same Pod

CRD-Based Configuration (Cloud Controllers)

Section titled “CRD-Based Configuration (Cloud Controllers)”

Some controllers (GKE GLBC, AWS ALB) use Custom Resource Definitions instead of annotations for strongly-typed configuration:

GKE BackendConfig: attach to a Service via annotation, configure session affinity or health checks in the CRD:

# Service references BackendConfig by name
apiVersion: v1
kind: Service
metadata:
name: web
annotations:
cloud.google.com/backend-config: '{"default": "web-backend-cfg"}'
spec:
type: ClusterIP
selector:
app: web
ports:
- name: http
port: 80
targetPort: 8080
---
# BackendConfig holds the actual policy
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
name: web-backend-cfg
spec:
sessionAffinity:
affinityType: GENERATED_COOKIE

This decouples backend policy from the Ingress manifest - any Ingress routing to web automatically picks up the config.


Instead of routing to a standard Service, an Ingress rule can route to a Custom Resource - used by controllers like Citrix that implement advanced routing logic in their own CRDs:

spec:
ingressClassName: citrix
rules:
- host: example.com
http:
paths:
- pathType: ImplementationSpecific # required for custom backends
backend:
resource: # replaces backend.service
apiGroup: citrix.com
kind: HTTPRoute
name: my-route-config

The referenced HTTPRoute CRD holds the actual routing rules and capabilities specific to the Citrix controller.


Complete L7 Blueprint

The Ingress API works well for basic HTTP/HTTPS routing but hits a ceiling in production multi-team environments:

  • Vendor annotation proliferation: Advanced features (traffic splitting, rate limiting, header manipulation, canary routing) are only available via controller-specific annotations. These annotations are non-portable - switching controllers breaks configuration.
  • Weak multi-tenancy: A single monolithic Ingress object mixes infrastructure config (listeners, TLS certs) with application routing rules. There’s no clean RBAC boundary between cluster operators and app teams.
  • Protocol lock-in: Ingress is designed for HTTP/HTTPS only. gRPC, TCP, and UDP routes require custom extensions or a separate service mesh.
  • Monolithic design: All routing logic for an application lives in one object. At scale, Ingress objects become large, difficult to own across teams, and error-prone to edit.

The Gateway API was created to solve all four of these problems.



The Gateway API is the official successor to Ingress and solves the limitations described above – role-oriented ownership, multi-protocol support (L4–L7), and standardised filters replacing vendor annotations.