| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3 |
| Applies to | Any host with kubectl configured; Kubernetes 1.27+ cluster with an Ingress controller installed |
| Cert prep | CKA · CKAD |
| Lab environment | Multi-node kubeadm cluster with containerd and an Ingress controller — install Kubernetes with kubeadm |
| Privilege | Normal user (no sudo required on the workstation) |
| Scope | networking.k8s.io/v1 Ingress rules with ingressClassName, host and path routing, pathType, defaultBackend, TLS Secrets, and verification with curl. Assumes ClusterIP Services and a running controller. Does not cover controller installation, TCP/UDP Ingress, cert-manager, controller-specific rewrite annotations, public DNS administration, or Gateway API tutorials. |
A Kubernetes Service gives workloads a stable cluster address. Ingress sits in front of those Services and decides which backend receives each HTTP or HTTPS request based on hostname and URL path. This walkthrough builds two small backends in ingress-lab, confirms they answer inside the cluster, then layers Ingress rules from a catch-all route through path routing, host routing, and TLS for one hostname.
Understand Ingress and Verify the Controller
What Ingress does
Ingress is an API object that describes how HTTP and HTTPS traffic should reach Services inside the cluster. It is not a replacement for Services and it is not a load balancer by itself.
Ingress covers:
- Hostname-based virtual hosting (
app.example.testversusapi.example.test) - Path-based routing on one host (
/versus/api) - TLS termination configuration that references a TLS Secret
Ingress does not cover:
- Arbitrary TCP or UDP port exposure (that requires a different controller feature or Service type)
- Backend Pod health (that remains readiness probes and EndpointSlices on the Service)
- Automatic routing just because you applied YAML
An Ingress resource only takes effect when an Ingress controller watches it and programs the dataplane. Creating the manifest alone does nothing until a controller claims the object.
The Ingress API is stable but effectively frozen for new features. For richer L7 routing, Kubernetes documents Gateway API as the forward path. Ingress remains common in production clusters and is not planned for removal.
Client request (Host + path + TLS SNI)
↓
Ingress controller entry point
↓
Ingress rule match (host, path, pathType)
↓
Service port
↓
Ready Pod endpointsVerify the controller and entry Service
Before you write rules, confirm a controller is running. Clusters do not ship with one by default. This article assumes yours is already installed.
Search controller Pods cluster-wide. Label names differ by implementation, so a broad filter is enough for discovery:
kubectl get pods -A | grep -i ingressSample output:
ingress-nginx ingress-nginx-controller-59d9744bfb-jzwb9 1/1 Running 0 7m49sA Running controller Pod in a dedicated namespace is the signal you need. Admission Jobs that show Completed are normal after install.
List controller Deployments the same way:
kubectl get deploy -A | grep -i ingressSample output:
ingress-nginx ingress-nginx-controller 1/1 1 1 7m50sFind the Service that fronts HTTP and HTTPS. Note its type, ClusterIP, and any NodePort or LoadBalancer address your platform assigns:
kubectl get svc -A | grep -i ingressSample output:
ingress-nginx ingress-nginx-controller LoadBalancer 10.103.217.90 <pending> 80:31143/TCP,443:30923/TCP 6m26sOn a bare-metal kubeadm lab, EXTERNAL-IP may stay <pending> while NodePorts still accept traffic. Cloud clusters usually populate an external hostname or IP in Ingress status once the controller syncs.
Capture the controller NodePorts instead of using a bare IP without a port. On a bare-metal NodePort setup, clients must include the allocated NodePort:
INGRESS_NAMESPACE=ingress-nginxINGRESS_SERVICE=ingress-nginx-controllerChoose a node address reachable from the workstation:
NODE_NAME=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')NODE_IP=$(kubectl get node "$NODE_NAME" -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}')Capture the controller ports:
HTTP_NODE_PORT=$(kubectl get service "$INGRESS_SERVICE" \
-n "$INGRESS_NAMESPACE" \
-o jsonpath='{.spec.ports[?(@.port==80)].nodePort}')HTTPS_NODE_PORT=$(kubectl get service "$INGRESS_SERVICE" \
-n "$INGRESS_NAMESPACE" \
-o jsonpath='{.spec.ports[?(@.port==443)].nodePort}')Build the HTTP entry URL:
INGRESS_HTTP_URL="http://${NODE_IP}:${HTTP_NODE_PORT}"printf 'HTTP=%s\nHTTPS=https://%s:%s\n' "$INGRESS_HTTP_URL" "$NODE_IP" "$HTTPS_NODE_PORT"Sample output:
HTTP=http://192.168.56.108:31143
HTTPS=https://192.168.56.108:30923For a controller exposed through a LoadBalancer, use its external address on ports 80 and 443 instead.
If rules never appear to work, check controller logs in its namespace after you apply an Ingress. Rejected annotations or invalid backends show up there before kubectl describe ingress tells the full story.
Select an IngressClass
IngressClass tells Kubernetes which controller implementation should reconcile a given Ingress. A cluster can run more than one controller, each with its own class.
List installed classes:
kubectl get ingressclassSample output:
NAME CONTROLLER PARAMETERS AGE
nginx k8s.io/ingress-nginx <none> 6m24sDescribe the class you plan to reference in manifests:
kubectl describe ingressclass nginxSample output:
Name: nginx
Labels: app.kubernetes.io/component=controller
app.kubernetes.io/instance=ingress-nginx
app.kubernetes.io/name=ingress-nginx
app.kubernetes.io/part-of=ingress-nginx
app.kubernetes.io/version=1.12.0
Controller: k8s.io/ingress-nginx
Events: <none>spec.ingressClassName is optional in the API, but setting it explicitly avoids relying on cluster defaults or controller-specific handling of classless Ingresses. These examples use nginx because the lab has an IngressClass named nginx; replace it with the class installed on your cluster.
Prefer ingressClassName over the older kubernetes.io/ingress.class annotation.
Prepare and Verify Backend Services
Ingress forwards to Services, not directly to Pods. Create two backends that return different plain-text bodies so routing mistakes are obvious.
Apply this manifest for web-service and api-service in ingress-lab:
apiVersion: v1
kind: Namespace
metadata:
name: ingress-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: ingress-lab
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: echo
image: hashicorp/http-echo:1.0.0
args: ["-text=web-service"]
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: web-service
namespace: ingress-lab
spec:
selector:
app: web
ports:
- port: 80
targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: ingress-lab
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: echo
image: hashicorp/http-echo:1.0.0
args: ["-text=api-service"]
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: api-service
namespace: ingress-lab
spec:
selector:
app: api
ports:
- port: 80
targetPort: 5678Save the manifest as ingress-backends.yaml, then apply it:
kubectl apply -f ingress-backends.yamlSample output:
namespace/ingress-lab created
deployment.apps/web created
service/web-service created
deployment.apps/api created
service/api-service createdWait until both Deployments report available replicas:
kubectl -n ingress-lab rollout status deployment/web --timeout=120skubectl -n ingress-lab rollout status deployment/api --timeout=120sConfirm each Service has ready EndpointSlice backends before you add Ingress rules:
kubectl -n ingress-lab get endpointslices -l kubernetes.io/service-name=web-serviceSample output:
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-service-645st IPv4 5678 192.168.5.37 3m37skubectl -n ingress-lab get endpointslices -l kubernetes.io/service-name=api-serviceSample output:
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
api-service-977kn IPv4 5678 192.168.5.20 3m36sTest from inside the cluster with a short-lived curl Pod. Send the hostname clients will use; the curl command covers -H and --resolve for Ingress smoke tests:
kubectl -n ingress-lab run curl-web --rm -i --restart=Never --image=curlimages/curl:8.12.1 --command -- curl -s http://web-serviceSample output:
web-servicekubectl -n ingress-lab run curl-api --rm -i --restart=Never --image=curlimages/curl:8.12.1 --command -- curl -s http://api-serviceSample output:
api-serviceIf either call fails or returns no endpoints, fix the Service and Pod layer first. See troubleshoot a Service that is not working before debugging Ingress.
Create and Test a Basic Ingress
Start with one catch-all HTTP rule that sends every request to web-service. Use networking.k8s.io/v1, name the Ingress class, and reference an existing Service port in the same namespace.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80Apply the manifest:
kubectl apply -f ingress-basic.yamlList the Ingress and read the class column:
kubectl -n ingress-lab get ingressSample output:
NAME CLASS HOSTS ADDRESS PORTS AGE
web-ingress nginx * 80 5sDescribe the object to see the programmed backends:
kubectl -n ingress-lab describe ingress web-ingressSample output:
Name: web-ingress
Namespace: ingress-lab
Address:
Ingress Class: nginx
Rules:
Host Path Backends
---- ---- --------
*
/ web-service:80 (192.168.5.37:5678)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Sync 6s nginx-ingress-controller Scheduled for syncThe backend Service must exist in the same namespace as the Ingress. Cross-namespace Service references are not valid in standard Ingress rules.
Send traffic to the controller entry point. Controller reconciliation is asynchronous, so retry the first request after kubectl apply:
curl --retry 10 --retry-all-errors --retry-delay 2 -s "$INGRESS_HTTP_URL/"Sample output:
web-serviceThe body web-service confirms the catch-all rule reached the intended backend.
Route Traffic by Path and Host
Path-based routing
Update the same Ingress to match one hostname with two paths. List paths in a readable order. Kubernetes selects the longest matching path, and an Exact path takes precedence over a Prefix path when both matches have equal length.
Target URLs:
example.test/
example.test/apiSave the path-based rules in ingress-paths.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: example.test
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80Apply the updated manifest:
kubectl apply -f ingress-paths.yamlSample output:
ingress.networking.k8s.io/web-ingress configuredAfter the controller syncs, describe should show both paths on example.test:
kubectl -n ingress-lab describe ingress web-ingressSample output:
Rules:
Host Path Backends
---- ---- --------
example.test
/api api-service:80 (192.168.5.20:5678)
/ web-service:80 (192.168.5.37:5678)Test the root path with an explicit Host header:
curl --retry 10 --retry-all-errors --retry-delay 2 -s -H 'Host: example.test' "$INGRESS_HTTP_URL/"Sample output:
web-serviceTest the API prefix on the same host:
curl --retry 10 --retry-all-errors --retry-delay 2 -s -H 'Host: example.test' "$INGRESS_HTTP_URL/api"Sample output:
api-servicePath matching happens at the Ingress controller before the request reaches the application. Controllers do not automatically strip the matched prefix unless an implementation-specific rewrite is configured, which is outside portable Ingress YAML.
Ingress path types
Every path rule requires pathType. The field tells the controller how to compare the request URL to the path string.
| Path type | Behaviour |
|---|---|
Exact |
Case-sensitive exact path match |
Prefix |
Element-by-element URL path prefix match |
ImplementationSpecific |
Interpretation belongs to the IngressClass or controller |
Examples with path: /foo and pathType: Prefix:
/foomatches/foo/barmatches/foobardoes not match (the next path segment isbar, not a continuation offoo)
Prefer Exact or Prefix in manifests you expect to run on different controllers. Reserve ImplementationSpecific for features documented by your chosen IngressClass.
Host-based routing
Host rules switch backends based on the HTTP Host header (and TLS SNI for HTTPS). Replace the single-host path example with two hostnames:
app.example.test → web-service
api.example.test → api-serviceSave the host rules in ingress-hosts.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
rules:
- host: app.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80
- host: api.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80Apply the updated manifest:
kubectl apply -f ingress-hosts.yamlSample output:
ingress.networking.k8s.io/web-ingress configuredList hosts on the Ingress object:
kubectl -n ingress-lab get ingress -o wideSample output:
NAME CLASS HOSTS ADDRESS PORTS AGE
web-ingress nginx app.example.test,api.example.test 80 74sDNS or a local hosts-file entry must resolve both names to the Ingress entry address. For lab tests before DNS exists, send the hostname with curl:
curl --retry 10 --retry-all-errors --retry-delay 2 -s -H 'Host: app.example.test' "$INGRESS_HTTP_URL/"Sample output:
web-servicecurl --retry 10 --retry-all-errors --retry-delay 2 -s -H 'Host: api.example.test' "$INGRESS_HTTP_URL/"Sample output:
api-serviceA request sent only to the IP without a matching Host header may hit a catch-all rule or the controller's own default backend instead of these host rules.
Wildcard hosts such as *.example.test are supported in the API. The wildcard matches one DNS label, not arbitrary nested subdomains (*.example.test matches app.example.test but not deep.app.example.test).
Configure an Ingress Default Backend
Two different "default backend" ideas show up in troubleshooting:
spec.defaultBackendon the Ingress object handles requests that match no rule on that Ingress- The controller may expose its own global backend for requests that match nothing at all
The default-backend example must preserve the earlier host routes. Save this complete manifest in ingress-default-backend.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
defaultBackend:
service:
name: web-service
port:
number: 80
rules:
- host: app.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80
- host: api.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80Apply the manifest:
kubectl apply -f ingress-default-backend.yamlSample output:
ingress.networking.k8s.io/web-ingress configuredTest an unmatched hostname:
curl --retry 10 --retry-all-errors --retry-delay 2 -s -H 'Host: unmatched.example.test' "$INGRESS_HTTP_URL/"Sample output:
web-serviceKubernetes routes requests that do not match configured rules to spec.defaultBackend; without one, unmatched handling belongs to the controller.
Use defaultBackend when you want a friendly fallback for unmatched paths on one Ingress. The controller-level unmatched backend is implementation-specific and is not configured the same way on every platform.
Configure TLS
Generate a self-signed certificate
HTTPS on Ingress references a TLS Secret in the same namespace. The Secret type is kubernetes.io/tls with tls.crt and tls.key data keys.
Generate certificate files for the lab hostname. Self-signed lab material uses OpenSSL req and x509 before you load the files into a TLS Secret:
openssl req \
-x509 \
-nodes \
-newkey rsa:2048 \
-days 365 \
-keyout tls.key \
-out tls.crt \
-subj '/CN=app.example.test' \
-addext 'subjectAltName=DNS:app.example.test'Confirm the certificate hostname:
openssl x509 -in tls.crt -noout -subject -ext subjectAltNameSample output:
subject=CN=app.example.test
X509v3 Subject Alternative Name:
DNS:app.example.testCreate the TLS Secret
kubectl create secret tls requires an existing PEM certificate and matching private key.
kubectl create secret tls example-tls -n ingress-lab --cert=tls.crt --key=tls.keySample output:
secret/example-tls createdConfirm the Secret type:
kubectl -n ingress-lab get secret example-tlsSample output:
NAME TYPE DATA AGE
example-tls kubernetes.io/tls 2 0sFor Secret fields, encoding, and rotation patterns, see Kubernetes Secrets. This walkthrough only needs a TLS Secret the Ingress can reference.
Add TLS to the Ingress
Add a tls stanza that names the host and the Secret. The hostname must also appear under rules for routing to stay consistent. Retain the same defaultBackend stanza from the previous step.
Save the complete manifest in ingress-tls.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-ingress
namespace: ingress-lab
spec:
ingressClassName: nginx
defaultBackend:
service:
name: web-service
port:
number: 80
tls:
- hosts:
- app.example.test
secretName: example-tls
rules:
- host: app.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80
- host: api.example.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80Apply the manifest:
kubectl apply -f ingress-tls.yamlSample output:
ingress.networking.k8s.io/web-ingress configuredDescribe the Ingress and read the TLS section:
kubectl -n ingress-lab describe ingress web-ingressSample output:
TLS:
example-tls terminates app.example.test
Rules:
Host Path Backends
---- ---- --------
app.example.test
/ web-service:80 (192.168.5.37:5678)
api.example.test
/ api-service:80 (192.168.5.20:5678)TLS normally terminates at the Ingress entry point. The certificate must cover the hostname clients request. Multiple TLS hosts can share port 443 through SNI when the controller supports it. Encrypting traffic again to Pods is controller-specific and is not part of the portable Ingress spec.
Test SNI with curl --resolve
Test HTTPS before public DNS exists with hostname-aware resolution so SNI and the Host header both use app.example.test. For the documented NodePort lab:
curl \
--retry 10 \
--retry-all-errors \
--retry-delay 2 \
-sk \
--resolve "app.example.test:${HTTPS_NODE_PORT}:${NODE_IP}" "https://app.example.test:${HTTPS_NODE_PORT}/"Sample output:
web-serviceThis sends app.example.test as both the TLS SNI hostname and HTTP host while connecting to the node's HTTPS NodePort. Ingress TLS uses the Secret referenced by secretName, and multiple hosts can share the TLS listener through SNI when the controller supports it.
For a LoadBalancer that publishes an IP:
INGRESS_IP=$(kubectl get service "$INGRESS_SERVICE" -n "$INGRESS_NAMESPACE" -o jsonpath='{.status.loadBalancer.ingress[0].ip}')Then test:
curl -sk --resolve "app.example.test:443:${INGRESS_IP}" https://app.example.test/When the LoadBalancer publishes a hostname instead of an IP, use working DNS for that hostname rather than passing it to --resolve.
The -k flag skips certificate validation, which is acceptable only for a self-signed lab certificate. It is not proof that a production certificate is trusted by clients.
Verify and Troubleshoot Ingress Routing
Use kubectl and HTTP clients together. API status shows what the controller accepted; curl shows what clients experience.
kubectl -n ingress-lab get ingresskubectl -n ingress-lab describe ingress web-ingressRead these fields together:
CLASSmatches theIngressClassyou intendedADDRESSor the controller Service external fields show where clients should connectRuleslist hosts, paths, and resolved Pod endpointsTLSnames the Secret and hostnames when HTTPS is configuredEventsreport sync errors or rejected configuration
For hostname-based rules, always send the intended Host header or resolve the name to the Ingress address. Bare IP requests are a common reason routing looks "wrong" during testing.
When routing fails but backends are healthy, walk the Service layer with Kubernetes DNS troubleshooting only after you confirm EndpointSlices and in-cluster Service curls succeed.
| Symptom | Likely cause | Fix |
|---|---|---|
| Ingress exists but no traffic routes | No controller or controller not Ready | Confirm controller Pods and Service; check controller logs |
| Ingress never syncs | ingressClassName does not match an installed class |
kubectl get ingressclass; fix the class name |
| 502/503 from entry point | Backend Service missing or wrong port | Verify Service name, port.number, and EndpointSlices |
| Rule present but wrong backend | Host header or path does not match | Test with curl -H 'Host: …'; review pathType and path length |
| TLS handshake errors | Secret missing, wrong namespace, or hostname mismatch | Confirm kubernetes.io/tls Secret and tls.hosts alignment |
| ADDRESS stays empty | Platform still provisioning or NodePort-only exposure | Use controller Service NodePort or platform docs for entry address |
Deeper backend failures (empty EndpointSlices, failing readiness probes, selector mismatches) belong to Service troubleshooting, not Ingress YAML edits alone.
What's Next
- Kubernetes Gateway API with HTTPRoute Examples
- kubectl port-forward with Pods, Deployments and Services
- Fix FailedCreatePodSandBox and Kubernetes CNI Errors
References
- Ingress — Kubernetes documentation
- Ingress Controllers — Kubernetes documentation
- Gateway API — Kubernetes SIG Network
Summary
Ingress is the HTTP and HTTPS routing layer on top of Kubernetes Services. You prepared two ClusterIP backends, confirmed they respond inside the cluster, then built one Ingress object step by step: a catch-all route, path rules on example.test, host rules for app.example.test and api.example.test, and TLS termination for app.example.test with a kubernetes.io/tls Secret.
The details that usually decide whether routing works are easy to skim past: a running Ingress controller, an ingressClassName that matches an installed IngressClass, pathType on every path, and backend Service ports in the same namespace as the Ingress. Path matching happens in the controller before your application sees the request, and controllers do not automatically rewrite paths unless you add implementation-specific configuration.
For verification, read kubectl describe ingress beside real HTTP tests. Send the hostname clients will use (curl -H 'Host: …' or curl --resolve for HTTPS) instead of curling a bare IP when host rules are in play. When Ingress status looks correct but responses are still wrong, validate EndpointSlices and in-cluster Service access before you chase DNS or TLS.
Gateway API is the long-term home for richer L7 features, but Ingress remains the practical choice on many clusters today. With host rules, path rules, and TLS configured, you can expose multiple Services through one controller entry point while keeping Pod networking behind ClusterIP Services.

