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.com vs api.example.com)
  • Routing by URL path (/api to 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.com with path / go to Service web on port 80.
  • Requests for host api.example.com with path /v1 go to Service api on 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. Prefix is common for “this path and everything under it”. Exact matches 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:

  1. Watches Ingress objects
  2. Reads Services and endpoints
  3. 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:

SymptomLikely cause
Ingress never gets an addressNo controller, wrong IngressClass, cloud LB pending
502 / empty backendService selector mismatch, no ready endpoints
Wrong app on pathPath order or pathType; more specific paths should often come first
Host works in curl, not in browserDNS or /etc/hosts not pointing at controller
TLS errorsMissing 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.