Why the router is the first suspect
Users type https://shop.apps.example.com and get a grey OpenShift page: "Application is not available". Your pods are Running. Between the user and the pods sits the router, and a Route that is off by one port number is enough to break everything.
What you need to know already: Services, selectors and endpoints (16.1); Ingress, an ingress controller and host/path rules (16.19); DNS names and wildcard records (8.22); reverse proxies and load balancers (9.23); HTTP status codes 502, 503, 504 and curl -s -o /dev/null -w '%{http_code}' (9.21, 9.27); oc expose from 30.6.
The words you need first
- Route - an OpenShift object that says "serve this hostname (and optional path) from this Service". The router reads it.
- Router - the HAProxy pods (30.1) that receive all traffic for
*.apps...and forward each request to the right pods. - IngressController - the object that describes one set of router pods (the
defaultone exists on every cluster); the Ingress Operator runs it. - Backend - where the router sends a request: here, the pod IPs behind a Service.
One more hop between the user and the pod
On OpenShift every cluster ships a working ingress layer: the Ingress Operator runs an IngressController called default, which is a Deployment of HAProxy router pods in openshift-ingress. DNS has a wildcard - *.apps.<cluster>.<domain>
- that points at those routers (through a load balancer on cloud, a keepalived VIP - a
floating IP address that moves between machines - or the nodes on bare metal). Every Route and every Ingress becomes a backend in HAProxy's config.
The IngressController's status.domain is the apps domain; dig +short (8.18) shows that any name under it resolves to the router:
# as kubeadmin
oc get ingresscontroller default -n openshift-ingress-operator -o jsonpath='{.status.domain}{"\n"}'
apps.ocp.lab
oc get pods -n openshift-ingress
NAME READY STATUS RESTARTS AGE
router-default-7884t2w448-5pmj5 1/1 Running 0 13d
router-default-7884t2w448-bmbmh 1/1 Running 0 13d
dig +short anything-at-all.apps.ocp.lab
10.64.0.30
(simulator) The lab router answers on 10.64.0.30; you cannot exec into it.
The path of a request: client -> DNS *.apps -> router -> directly to a pod IP. The router reads the Service only to find its endpoints (the list of ready pod IP:port pairs, 16.1); traffic does not go through the Service's ClusterIP (no kube-proxy hop, 16.3). That detail explains most Route problems.
Route vs Ingress
Terms in the table: TLS termination = the place where the encrypted connection is decrypted (9.15); A/B, blue-green = sending some traffic to a new version and the rest to the old one (26.26); PEM = the text format of certificates and keys (9.17).
| Ingress (networking.k8s.io) | Route (route.openshift.io) | |
|---|---|---|
| standard | upstream Kubernetes, any controller | OpenShift only (older than Ingress, which it inspired) |
| TLS modes | terminate at the controller (+ controller-specific annotations for re-encrypt/passthrough) | edge, passthrough, reencrypt are first-class fields |
| plain HTTP when TLS is set | controller-specific | insecureEdgeTerminationPolicy: None / Allow / Redirect |
| weighted backends | not in the API | to + up to 3 alternateBackends with weights (A/B, blue-green) |
| host | you must write it | generated if empty: <route>-<project>.<apps domain> |
| one object = | many hosts and paths | one host (+ optional path) |
| cert per host | a Secret reference | inline PEM in the Route, or externalCertificate (a Secret) |
On OpenShift you can create Ingress objects too: the ingress-to-route controller turns each rule into a Route (owned by the Ingress, named <ingress>-<hash>), so the same router serves both. The Gateway API is also supported in recent versions. In practice most OpenShift shops use Routes directly; charts written for vanilla Kubernetes keep working through their Ingress.
Making one
oc create service clusterip web --tcp=80:8080 makes a Service whose port 80 forwards to the pods' 8080; oc expose svc/NAME then makes a Route for that Service:
$ oc create deployment web --image=docker.io/nginxinc/nginx-unprivileged:1.27 --port=8080
$ oc create service clusterip web --tcp=80:8080
$ oc expose svc/web
route.route.openshift.io/web exposed
$ oc get route web
NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD
web web-rt.apps.ocp.lab web 80-8080 None
$ oc rollout status deployment/web
deployment "web" successfully rolled out
$ curl -s -o /dev/null -w '%{http_code}\n' http://web-rt.apps.ocp.lab/
200
Curl it before the pod is ready and the router answers 503 (no endpoints behind the Service yet) - that is why the rollout comes first.
Reading the columns:
- HOST/PORT - generated: route
webin projectrt->web-rt.apps.ocp.lab. (Route and project names both go into it, so keep them short - DNS labels max 63 characters.) If admission refused the route, this column shows the reason instead. - SERVICES - the backend(s), with weights when there are several.
- PORT -
spec.port.targetPort. Here80-8080, the namekubectl create servicegave the Service port.oc exposecopies the Service port's name if it has one, otherwise itstargetPortnumber. - TERMINATION - empty = plain HTTP; otherwise
edge,passthrough,reencrypt, with/Redirector/Allowfor the insecure policy.
The object:
$ oc get route web -o yaml
apiVersion: route.openshift.io/v1
kind: Route
metadata:
annotations:
openshift.io/host.generated: "true"
labels:
app: web
name: web
namespace: rt
spec:
host: web-rt.apps.ocp.lab
port:
targetPort: 80-8080
to:
kind: Service
name: web
weight: 100
wildcardPolicy: None
status:
ingress:
- conditions:
- lastTransitionTime: "2026-09-22T20:00:24Z"
status: "True"
type: Admitted
host: web-rt.apps.ocp.lab
routerCanonicalHostname: router-default.apps.ocp.lab
routerName: default
wildcardPolicy: None
status.ingress has one entry per router that looked at the route. Admitted: True means the router serves it (a different "admission" from 30.7: here it is the router accepting the Route). weight: 100 is this backend's share of traffic; wildcardPolicy: None means the route serves exactly its host, not *.host. spec.host cannot be changed after creation (delete and recreate), and setting a custom host needs the routes/custom-host permission, which project admins and editors have.
targetPort: the trap
$ oc explain route.spec.port.targetPort
FIELD: targetPort <IntOrString>
DESCRIPTION:
The target port on pods selected by the service this route points to. If
this is a string, it will be looked up as a named port in the target
endpoints port list. Required
A number means a port on the pods (the endpoint port), not the Service's port. A name means the Service port's name (which endpoints inherit). With a Service port: 80 -> targetPort: 8080:
| route targetPort | result |
|---|---|
8080 | matches the endpoints - works |
80-8080 (the port's name) | works |
80 | no endpoint has port 80 - the router has no backend: 503 |
| (unset) | the router uses the Service's first port's endpoints - works |
--name names the Route; --port sets its targetPort:
$ oc expose svc/web --name=web-bad --port=80
route.route.openshift.io/web-bad exposed
$ curl -s -o /dev/null -w '%{http_code}\n' http://web-bad-rt.apps.ocp.lab/
503
$ oc describe route web-bad
Name: web-bad
Namespace: rt
...
Requested Host: web-bad-rt.apps.ocp.lab
exposed on router default (host router-default.apps.ocp.lab) 3s ago
Path: <none>
TLS Termination: <none>
Insecure Policy: <none>
Endpoint Port: 80
Service: web
Weight: 100 (100%)
Endpoints: 10.244.2.127:8080
The describe output hands you the answer if you read it: Endpoint Port: 80, while the endpoints are on :8080. oc expose --port sets exactly this field - it is not "the Service port", whatever it looks like.
The 503 page
$ curl -s http://web-bad-rt.apps.ocp.lab/ | grep -A1 '<h1>'
<h1>Application is not available</h1>
<p>The application is currently not serving requests at this endpoint. It may not have been started or is still starting.</p>
The page lists its own three causes, and they are the three you check, in order:
- The host doesn't exist - no admitted route with that host (typo, wrong project, route rejected).
oc get routeand its HOST/PORT column. - The host exists, but doesn't have a matching path - a route with
path: /apidoes not serve/. - Route and path match, but all pods are down - really: the router has no usable endpoint. No ready pods (
oc get endpoints), a Service selector that matches nothing, or a targetPort that matches no endpoint port.
Plus the TLS variants from the next lesson: an http:// request to a TLS route whose insecure policy is None, and a re-encrypt route whose backend does not speak TLS, both get the same page.
It is always HTTP 503 from the router, never from your pods - the router adds the header Pragma: no-cache; your application never saw the request. Contrast a 502/504 from the router (backend accepted the connection and failed or was too slow) and a 404 or 500 that your app itself returned (the route is fine).
Host claims and paths
Two routes cannot have the same host+path. The oldest wins, and routes from another project can never take a host a project already owns:
# the routes mission's state
oc get route web -n other
NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD
web HostAlreadyClaimed web 8080 None
oc describe route web -n other | grep -A1 rejected
rejected by router default: (host router-default.apps.ocp.lab)HostAlreadyClaimed (12s ago)
route web already exposes shop.apps.ocp.lab and is older
Path routes share a host between services (--hostname sets the host instead of the generated one, --path the URL prefix):
oc expose svc/api --hostname=shop.apps.ocp.lab --path=/api
oc expose svc/web --hostname=shop.apps.ocp.lab
/api/orders goes to api (longest path wins), everything else to web. The path is not stripped - the api pods see /api/orders.
Router annotations you will actually use
The router is tuned per Route with annotations; oc annotate route NAME key=value sets one (the same verb as kubectl annotate):
$ oc annotate route web haproxy.router.openshift.io/timeout=2m
$ oc annotate route web haproxy.router.openshift.io/disable_cookies=true
$ oc annotate route web haproxy.router.openshift.io/balance=roundrobin
$ oc annotate route web haproxy.router.openshift.io/ip_allowlist='10.0.0.0/8 10.64.0.0/24'
- timeout - the server timeout, default 30s. A report endpoint that takes 45 seconds gets a router 504 (
The server didn't respond in time.) until you raise it. The single most common Route annotation in enterprise apps. - cookies - for plain HTTP, edge and re-encrypt routes the router sets a cookie (
Set-Cookie: <hash>=<hash>; path=/; HttpOnly) to keep a client on the same pod: sticky sessions by default. Stateless APIs often disable it. (Passthrough routes cannot set cookies - the router never sees the HTTP.) - balance - how the router picks a pod:
random(the default),roundrobin(in turn),leastconn(fewest open connections),source(by client IP). - ip_allowlist - source IP filtering at the router, as CIDR ranges (8.3).
curl -sI fetches only the response headers (9.21) - here you can see the cookie:
curl -sI http://web-rt.apps.ocp.lab/
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 615
Set-Cookie: d4f87af586e8ff12b6606ea6df49abdd=a61a2595262d97d27c9925c2a930fbc5; path=/; HttpOnly
Cache-control: private
Weights: blue-green on one host
# a second service web-v2 behind the route
oc set route-backends web web-v1=90 web-v2=10
route.route.openshift.io/web backends updated
oc set route-backends web
NAME KIND TO WEIGHT
routes/web Service web-v1 90 (90%)
routes/web Service web-v2 10 (10%)
oc get route web
NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD
web web-rt.apps.ocp.lab web-v1(90%),web-v2(10%) 8080 None
alternateBackends (up to three) with weights 0-256: the router splits connections in that ratio. The first backend you name becomes spec.to, the rest alternateBackends; backends you leave out are dropped. --adjust web-v2=+10% shifts traffic between one backend and the primary, --zero / --equal set every weight to 0 / 100, and oc set route-backends web alone prints the current split.
What you can now do
- Expose a Service with a Route, read
oc get route/oc describe route, and share a host between services with paths. - Explain why a Route's
targetPortis the pod port, and find that bug from "Endpoint Port" in describe. - Work down the three causes of the router's 503 page, and raise the router timeout.