cloud

Expose Kubernetes services with Istio Gateway API

How I route public domains through an Istio-managed Gateway and HTTPRoute.

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 at api.example.com
  • example-worker: public worker UI at worker.example.com
  • example-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:

  1. Bootstrap a new RKE cluster for GitOps
  2. Use Argo CD to manage my home Kubernetes cluster
  3. Use Vault and External Secrets in Kubernetes
  4. Run Istio ambient mode with waypoint proxies
  5. Expose Kubernetes services with Istio Gateway API
  6. Build an OpenTelemetry stack for Kubernetes apps
  7. Run Airflow on Kubernetes with GitOps-managed values
  8. 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:

  1. GatewayClass: provided by Istio.
  2. Gateway: the shared entry point.
  3. HTTPRoute: host/path routing.
  4. 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:

  1. DNS points to the external reverse proxy.
  2. The reverse proxy forwards to the Gateway service.
  3. The Gateway listener accepts the hostname.
  4. The HTTPRoute is accepted.
  5. The ReferenceGrant allows the backend.
  6. 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.