Header-Based Routing for Spatial Domains
Header-based routing is the cheapest deterministic way to steer a spatial request to its owning domain without parsing the payload or trusting DNS. When each request advertises its domain, coordinate reference system, and tile matrix set in explicit headers, an Istio VirtualService can match those values and hand the request to exactly one backend — every time, for the same inputs. This operation lives under Cross-Domain Routing Strategies, part of the Federated Ownership & Routing Architecture: it implements the synchronous steering leg with header match blocks and a guaranteed default route, so that a request missing its domain header never silently lands on the wrong owner. Once a domain is migrating, this same match surface pairs with Weighted Traffic Shifting for Spatial Domain Migration to split a matched slice across old and new owners.
Figure — A request’s domain, CRS, and tile-matrix headers are matched in order; an unmatched request falls through to the default route rather than a guessed backend.
Prerequisites
| Requirement | Value / Constraint |
|---|---|
| Service mesh | Istio 1.20+ with sidecar injection enabled on the spatial namespace |
| CLI tools | istioctl 1.20+, kubectl, curl 7.80+ (for -H header injection) |
| CRS assumption | Vector routes advertise x-crs: EPSG:4326; tiled raster routes may advertise x-crs: EPSG:3857 with a matching tile matrix set |
| Header contract | x-spatial-domain (owning domain id), x-crs (CRS URN or EPSG code), x-tile-matrix-set (registered set name for tiled routes) |
| Access roles | mesh-routing-admin to apply VirtualService; GIS Data Steward to register a domain id in the routing catalog |
| Env vars | MESH_HOST=spatial.mesh.enterprise.internal, GATEWAY=mesh-ingress-gateway |
Header matching is a pure function of the request headers, so applying the same VirtualService twice yields the same routing table — the operation is idempotent and safe to re-apply from CI. Keep the header names lowercase; Istio compares HTTP/2 header keys case-insensitively but your catalog and diagnostics should be canonical.
Step-by-Step Implementation
1. Route on the owning-domain header with an exact match
Bind the ingress gateway and add one match per registered domain. Order matters: Istio evaluates rules top-down and takes the first match, so the most specific rules come first and the default falls through last.
# spatial-domain-routing.yaml — apply with: kubectl apply -f
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: spatial-domain-router
namespace: spatial
spec:
hosts:
- "spatial.mesh.enterprise.internal"
gateways:
- mesh-ingress-gateway
http:
- name: terrain-domain
match:
- headers:
x-spatial-domain:
exact: "terrain" # owning domain id from the routing catalog
route:
- destination:
host: terrain.spatial.svc.cluster.local
port:
number: 8080
Verify the matched route resolves to the terrain backend by injecting the header explicitly:
curl -sD - -o /dev/null \
-H "x-spatial-domain: terrain" \
"https://${MESH_HOST}/v1/features/collections" | grep -Ei "HTTP/|x-envoy-upstream"
2. Add CRS and tile-matrix-set constraints to the match
A domain can own several products at different CRS or tiling schemes. Combine header matches so a request for web-mercator tiles cannot land on the EPSG:4326 vector backend. All headers in one match block are ANDed.
- name: terrain-tiles-3857
match:
- headers:
x-spatial-domain:
exact: "terrain"
x-crs:
exact: "EPSG:3857"
x-tile-matrix-set:
exact: "WebMercatorQuad" # registered set name
route:
- destination:
host: terrain-tiles.spatial.svc.cluster.local
port:
number: 8080
Confirm that only the fully-specified header triple reaches the tile backend, and that a CRS mismatch does not:
# Should hit the tile backend (200).
curl -so /dev/null -w "%{http_code}\n" \
-H "x-spatial-domain: terrain" -H "x-crs: EPSG:3857" \
-H "x-tile-matrix-set: WebMercatorQuad" \
"https://${MESH_HOST}/v1/tiles/1/0/0"
3. Guarantee a deterministic default route
Never let an unmatched request pick a backend by accident. The final rule has no match block, so it catches everything that fell through and returns a structured 404 rather than steering to a default backend that does not own the data.
- name: default-no-domain
route:
- destination:
host: routing-diagnostics.spatial.svc.cluster.local
port:
number: 8080
headers:
response:
add:
x-routing-outcome: "no-domain-match" # diagnostic for the runbook
Verify that a request with no domain header falls through to diagnostics, not a real domain:
curl -sD - -o /dev/null \
"https://${MESH_HOST}/v1/features/collections" \
| grep -Ei "HTTP/|x-routing-outcome"
4. Freeze the routing table under version control
Apply the manifest through GitOps so the live boundary is reproducible, then use istioctl to prove the effective route order matches the committed file.
kubectl apply -f spatial-domain-routing.yaml
istioctl proxy-config routes deploy/istio-ingressgateway \
--name http.8080 -o json | jq '.[].virtualHosts[].routes[].match.headers'
Configuration Reference
| Field | Layer | Required | Purpose / Constraint |
|---|---|---|---|
hosts |
VirtualService | Yes | The mesh hostname the gateway serves; must match the ingress Gateway |
gateways |
VirtualService | Yes | Binds the route table to mesh-ingress-gateway; omit for mesh-internal only |
http[].match.headers.x-spatial-domain |
Match | Yes | Exact owning-domain id; the primary steering key |
http[].match.headers.x-crs |
Match | Tiled/multi-CRS | EPSG:4326 for vector, EPSG:3857 for web-mercator tiles |
http[].match.headers.x-tile-matrix-set |
Match | Tiled routes | Registered set name, e.g. WebMercatorQuad; blocks CRS/tiling mismatch |
route[].destination.host |
Route | Yes | Fully-qualified in-cluster service of the owning domain |
| Rule order | VirtualService | Yes | First-match wins; specific rules first, unmatched default last |
| Default rule | Route | Yes | The match-less final rule returning 404 with x-routing-outcome |
Common Failure Modes & Fixes
- Symptom: every request lands on the first domain regardless of header. Root cause: the default (match-less) rule was placed above the specific rules, so it wins for everything. Fix: reorder so all
matchrules precede the default; re-apply and re-run the step 4istioctl proxy-config routescheck. - Symptom: tile requests reach the vector backend and return
EPSG:4326geometry. Root cause: the tile rule matched onlyx-spatial-domainand ignoredx-crs/x-tile-matrix-set. Fix: add the CRS and tile-matrix headers to the samematchblock so all three are ANDed, per step 2. - Symptom: header match never fires even with the correct
-Hvalue. Root cause: an upstream proxy stripped or lower-cased the header, or the rule usedprefixwhereexactwas intended. Fix: confirm propagation withcurl -sD -and switch the match type toexact; register the header in the ingress allow-list. - Symptom: unmatched requests hang instead of returning
404. Root cause: no default rule exists, so Envoy has no route and holds the connection. Fix: append thedefault-no-domainrule from step 3 and re-apply.
FAQ
Why match on a header instead of the URL path or hostname?
Path and host encode the product, not the owner. A spatial product can migrate between owning domains without its public URL changing, and a single domain can serve many paths. Matching x-spatial-domain keeps ownership routing orthogonal to the API surface, which is exactly what makes a weighted migration possible without rewriting client URLs.
Is header matching deterministic and idempotent?
Yes. Given the same request headers, Istio’s first-match evaluation always selects the same route, and applying the same VirtualService produces the same table. That determinism is why this pattern is safe to drive from GitOps and safe to retry — no request is ever routed by chance.
How do I stop a client from spoofing x-spatial-domain to reach another domain?
Header matching decides where, not whether. Pair it with contract and identity checks at the same edge so a forged domain header still fails policy — see OPA vs Envoy WASM for Spatial Contract Validation for the validation leg. The x-spatial-domain value should be asserted against the JWT’s spatial scope claims, not trusted blindly.
What CRS header value should vector routes carry?
Vector exchange advertises x-crs: EPSG:4326 (lon/lat). Reserve EPSG:3857 for tiled raster and always pair it with x-tile-matrix-set: WebMercatorQuad so a mismatched CRS/tiling pair cannot match a route and instead falls through to the default 404.
Related
- Up to the parent: Cross-Domain Routing Strategies — the steering layer this match surface implements
- Weighted Traffic Shifting for Spatial Domain Migration — splits a matched slice across old and new owners during migration
- OPA vs Envoy WASM for Spatial Contract Validation — the validation leg that stops a spoofed domain header
- API Gateway Mapping for GIS Services — the ingress contract a matched request clears first
- Federated Ownership & Routing Architecture — the parent reference establishing decentralized routing ownership