This post records how I expose services from my Kubernetes cluster with Istio
and Gateway API.
The goal is not to make Istio terminate public TLS directly. In my home setup, the outside edge can still be handled by a reverse proxy. The Kubernetes side only needs a predictable Gateway entry point and clean routes to Services.
The flow looks like this:
external TLS reverse proxy -> Istio Gateway Service -> Gateway API HTTPRoute -> Kubernetes Service -> Pod
This post uses the same fake apps as the rest of the series:
example-api: public API atapi.example.comexample-worker: public worker UI atworker.example.comexample-admin: internal admin app, no public route
Only example-api and example-worker get public HTTPRoute objects here.
Series
This post is part of my home Kubernetes GitOps series:
- Bootstrap a new RKE cluster for GitOps
- Use Argo CD to manage my home Kubernetes cluster
- Use Vault and External Secrets in Kubernetes
- Run Istio ambient mode with waypoint proxies
- Expose Kubernetes services with Istio Gateway API
- Build an OpenTelemetry stack for Kubernetes apps
- Run Airflow on Kubernetes with GitOps-managed values
- Use Mozilla SOPS with GitOps for encrypted Kubernetes Secrets
Why Gateway API
The older Ingress model is simple, but it becomes awkward when the cluster has service mesh concerns, shared gateways, cross-namespace routing, and route ownership.
Gateway API gives me clearer objects:
GatewayClass: provided by Istio.Gateway: the shared entry point.HTTPRoute: host/path routing.ReferenceGrant: explicit permission for cross-namespace backend routing.
This makes the boundary easier to reason about. Shared infra owns the Gateway. Applications own or receive their own routes.
Gateway service as NodePort
For this setup, I keep a fixed NodePort for the Istio Gateway service. The external reverse proxy can send traffic to that one port.
apiVersion: v1
kind: ConfigMap
metadata:
name: example-istio-gateway-options
namespace: istio-ingress
data:
service: |
spec:
type: NodePort
ports:
- name: status-port
port: 15021
protocol: TCP
targetPort: 15021
- name: http
port: 80
protocol: TCP
targetPort: 80
nodePort: 32080
Then the Gateway references that infrastructure config:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: example-istio-gateway
namespace: istio-ingress
annotations:
networking.istio.io/service-type: NodePort
spec:
gatewayClassName: istio
infrastructure:
parametersRef:
group: ""
kind: ConfigMap
name: example-istio-gateway-options
listeners:
- name: http
hostname: "*.example.com"
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All
The public TLS layer stays outside this Gateway in my design. That means the external reverse proxy terminates HTTPS, then forwards HTTP to the cluster Gateway service.
Example route:
client HTTPS -> reverse proxy TLS -> Gateway NodePort -> HTTPRoute -> Service
HTTPRoute to a service
The route object maps a hostname to a backend Service.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: example-api
namespace: istio-ingress
spec:
parentRefs:
- name: example-istio-gateway
hostnames:
- api.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: example-api
namespace: example-api
port: 3000
Because the route lives in istio-ingress and the Service lives in
example-api, I also need a ReferenceGrant in the backend namespace.
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-istio-ingress-to-example-api
namespace: example-api
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: istio-ingress
to:
- group: ""
kind: Service
name: example-api
This is one of the parts I like about Gateway API. Cross-namespace routing is explicit. A route cannot silently point at a Service in another namespace unless that namespace allows it.
Add another hostname
Adding another public service is another HTTPRoute plus ReferenceGrant.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: example-worker
namespace: istio-ingress
spec:
parentRefs:
- name: example-istio-gateway
hostnames:
- worker.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: example-worker
namespace: example-worker
port: 8000
The worker namespace also grants the route permission to reference its Service:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-istio-ingress-to-example-worker
namespace: example-worker
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: istio-ingress
to:
- group: ""
kind: Service
name: example-worker
This keeps the shared Gateway stable while applications can be added gradually.
I do not add a public route for example-admin in this example set. It can
still run in the cluster and use Vault/External Secrets, but keeping it internal
is the safer default.
How this fits with ambient mode
For services enrolled in Istio ambient mode, the namespace has:
istio.io/dataplane-mode: ambient
If I want L7 processing for service traffic, I add a waypoint:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: example-api-waypoint
namespace: example-api
labels:
istio.io/waypoint-for: service
spec:
gatewayClassName: istio-waypoint
listeners:
- name: mesh
port: 15008
protocol: HBONE
The Gateway API ingress and the waypoint solve different problems. The ingress Gateway brings traffic into the cluster. The waypoint handles service-scoped mesh traffic inside the cluster.
External service visibility
For external HTTPS destinations used by pods, I add ServiceEntry resources.
This helps Istio and Kiali classify egress traffic instead of grouping it as
unknown external traffic.
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: example-external-api
namespace: example-api
spec:
hosts:
- api.vendor.example
location: MESH_EXTERNAL
ports:
- number: 443
name: tls
protocol: TLS
resolution: DNS
I use explicit FQDNs. Wildcards are tempting, but explicit hosts are easier to audit and easier to explain when traffic appears in observability tools.
Validate
First check the Gateway and generated service:
kubectl -n istio-ingress get gateway,svc,deploy
Then check routes and grants:
kubectl -n istio-ingress get httproute
kubectl -n example-api get referencegrant
kubectl -n example-worker get referencegrant
Check from outside the cluster through the public domain:
curl https://api.example.com/health
curl https://worker.example.com/
If this fails, I check in layers:
- DNS points to the external reverse proxy.
- The reverse proxy forwards to the Gateway service.
- The Gateway listener accepts the hostname.
- The HTTPRoute is accepted.
- The ReferenceGrant allows the backend.
- The backend Service has ready endpoints.
Conclusion
This setup gives me a clean split:
- external reverse proxy: public TLS
- Istio Gateway: cluster entry point
- HTTPRoute: host/path routing
- ReferenceGrant: cross-namespace permission
- Service: workload target
It is still a home-cluster friendly design. I do not need a cloud load balancer for every service, and I do not need to expose every app separately. One shared Gateway can route multiple domains while keeping the GitOps manifests readable.