You deployed an application. You created a Service. From inside the cluster, curl http://my-app works. Then someone asks: “How do I reach it from my browser?”
That question is where many beginners meet Ingress. Tutorials often show an Ingress YAML right after a Deployment and Service, which makes Ingress feel like the natural third step. It can be — but only if you understand what it adds on top of a Service, and why the Ingress object by itself does not open any ports.
This post is for the stage where ClusterIP Services make sense, maybe you tried kubectl port-forward or NodePort, and now you want a clearer HTTP entry point with hostnames and paths. I will stay modest about scope. Ingress is not the only way to expose HTTP workloads. Gateway API, cloud load balancers, and service meshes exist. But Ingress is still the pattern beginners meet most often, and debugging it teaches useful habits.
What a Service does — and what it does not
A Service is a stable contract over a changing set of Pods. Clients use a name and port. Kubernetes keeps track of which Pod IPs are currently ready behind that name.
For a typical web app:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 80
Inside the cluster, another Pod can call http://web or http://web.default.svc.cluster.local. That is valuable. It is also inside the cluster.
A ClusterIP Service does not, by itself, give you:
- A public URL on the internet
- Routing by hostname (
shop.example.comvsapi.example.com) - Routing by URL path (
/apito one Service,/to another) - TLS termination at a shared front door
You can expose a Service outside the cluster with kubectl port-forward, NodePort, or LoadBalancer type. Those work for learning and some production patterns. They become awkward when you want many HTTP services behind one external IP and one TLS certificate.
That gap is what Ingress tries to fill.
What Ingress adds
An Ingress is a declarative description of HTTP routing rules. It says things like:
- Requests for host
shop.example.comwith path/go to Servicewebon port 80. - Requests for host
api.example.comwith path/v1go to Serviceapion port 8080.
Example:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-ingress
spec:
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
Read that slowly. The Ingress does not run nginx or traefik. It does not listen on port 443. It is a rule object. Something else must read those rules and configure real traffic handling.
That “something else” is the Ingress controller.
Common misconception: “I applied an Ingress, so my app is on the internet.” Not yet. You applied routing intent. Until a controller implements it and you have DNS or port access pointing at that controller, nothing external changes.
Ingress rules: host, path, and backend
Most beginner Ingress manifests use spec.rules. Each rule can specify:
- host — the HTTP Host header, for example
shop.example.com - http.paths — a list of path matches and backends
Each path needs:
- path — such as
/or/api - pathType — how to match.
Prefixis common for “this path and everything under it”.Exactmatches only that exact path. - backend.service — the Service name and port number to forward to
Two Services behind one Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
spec:
rules:
- host: demo.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
Important details beginners miss:
The backend port is the Service port, not necessarily the container port. If your Service maps port 80 to targetPort 8080, the Ingress backend should reference port 80.
Labels must match. The Ingress points to a Service by name. The Service selects Pods by labels. If the Service has no ready endpoints, Ingress has nothing healthy to send traffic to.
Default backend. Some controllers support a default backend for requests that match no rule. Useful for custom error pages. Not required for first experiments.
Apply and inspect:
kubectl apply -f app-ingress.yaml
kubectl get ingress
kubectl describe ingress app-ingress
kubectl describe ingress often shows controller-specific annotations, events, and sometimes an address field once the controller assigns an IP or hostname.
You need an Ingress controller
Kubernetes ships the Ingress API. It does not ship a default Ingress controller in every cluster.
That distinction confuses almost everyone at least once.
An Ingress controller is a Pod (or set of Pods) that:
- Watches Ingress objects
- Reads Services and endpoints
- Configures a proxy or cloud load balancer (nginx, traefik, HAProxy, cloud-specific implementations, and others)
If no controller runs, your Ingress sits in etcd looking important while nothing listens.
Check whether a controller exists:
kubectl get pods -A | grep -i ingress
kubectl get ingressclass
On minikube, you can enable the addon:
minikube addons enable ingress
kubectl get pods -n ingress-nginx
On kind, you typically install a controller yourself, for example the ingress-nginx manifest from the project’s documentation. The exact install command changes over time; follow the current docs for your cluster version.
On managed cloud clusters (EKS, GKE, AKS), the platform may offer its own controller or integration. The pattern is the same: Ingress YAML plus a running controller.
IngressClass links an Ingress to a specific controller implementation:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-ingress
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
If your cluster has multiple controllers, ingressClassName prevents the wrong one from picking up your rules.
TLS: a short mention
Ingress can describe TLS certificates for hostnames. The controller terminates HTTPS and forwards HTTP to Services (usually).
Typical pattern with a TLS Secret:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-ingress-tls
spec:
ingressClassName: nginx
tls:
- hosts:
- shop.example.com
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
The Secret must exist in the same namespace as the Ingress and contain a valid cert and key. Many teams use cert-manager to obtain and renew certificates automatically from Let’s Encrypt or an internal CA. That is a whole topic on its own.
For local learning, TLS is optional. You can test with HTTP first, get routing working, then add TLS once the path is clear. Do not let certificate automation block understanding of host and path rules.
Production note: TLS at Ingress is common, but not the only design. Some architectures terminate TLS at a cloud load balancer and send plain HTTP to the cluster. Others use a service mesh. Ingress TLS is a practical starting point, not a universal law.
Debugging empty backends
The most common Ingress symptom: you open the URL and get 502 Bad Gateway, 503 Service Unavailable, or a default backend page. The Ingress exists. The controller runs. Still nothing useful.
Work from the outside in, but verify backends early.
Step 1 — Ingress exists and has an address:
kubectl get ingress -o wide
kubectl describe ingress app-ingress
Is there an ADDRESS or external hostname? If empty, the controller may still be provisioning, or the Service type and cloud integration may need attention. On local clusters, you may need to map demo.example.com to 127.0.0.1 in /etc/hosts and use port-forward or minikube tunnel depending on setup.
Step 2 — Backend Service exists:
kubectl get svc web api
kubectl describe svc web
Check selector, ports, and type.
Step 3 — Endpoints exist:
kubectl get endpoints web
kubectl get endpointslice -l kubernetes.io/service-name=web
If endpoints are empty, Ingress has no ready Pods. This is the “empty backend” case. The routing rule is fine; the Service layer is not.
Step 4 — Pods are ready:
kubectl get pods -l app=web -o wide
kubectl describe pod <pod-name>
Look for readiness probe failures, crash loops, wrong labels, or Pods in a different namespace than the Service.
Step 5 — Test inside the cluster first:
kubectl run curl-test --rm -it --image=curlimages/curl -- sh
curl -v http://web
curl -v -H 'Host: demo.example.com' http://<ingress-controller-ip>/
If in-cluster Service access works but Ingress does not, suspect controller config, path rules, or host header mismatch. If in-cluster Service access fails, fix the Deployment and Service before blaming Ingress.
Step 6 — Controller logs:
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller --tail=50
Adjust namespace and label selector for your controller.
Common root causes I see repeatedly:
| Symptom | Likely cause |
|---|---|
| Ingress never gets an address | No controller, wrong IngressClass, cloud LB pending |
| 502 / empty backend | Service selector mismatch, no ready endpoints |
| Wrong app on path | Path order or pathType; more specific paths should often come first |
| Host works in curl, not in browser | DNS or /etc/hosts not pointing at controller |
| TLS errors | Missing Secret, wrong host in cert, expired certificate |
Service vs Ingress: keep the layers separate
When debugging, name the layer:
- Deployment — are the right Pods running?
- Service — do selectors and ports match? Are there endpoints?
- Ingress — do host and path rules point at the correct Service port?
- Ingress controller — is it running and programmed?
- DNS / TLS / external access — does traffic reach the controller?
Skipping straight to “Ingress is broken” when the Service has zero endpoints wastes time. I have done it. The fix was a readiness probe, not an annotation.
Final thought
Ingress is not a replacement for Services. It is HTTP routing on top of them.
Learn Services first until endpoint debugging feels normal. Then add an Ingress controller, apply a simple rule with one host and one path, and confirm traffic flows. Add a second path, then TLS if you need it.
The object model is simpler than it feels on a bad afternoon: Ingress describes where HTTP traffic should go; Services describe which Pods receive it; the controller makes that description real. When something fails, check whether each layer has something ready to receive traffic. Usually one of them does not — and that is a fixable problem, not a reason to avoid Ingress altogether.