Reverse Proxy Configuration for WMS/WFS
When the reverse proxy in front of an open-source geospatial portal is misconfigured, the failure is rarely a clean error page: a stripped Authorization header turns an authenticated WFS transaction into a silent 403, an over-eager path rewrite breaks every GetCapabilities document URL, and a naive cache key fragments WMS tiles into thousands of near-duplicate entries that never hit. For GIS administrators, open-source portal maintainers, and government platform engineers, that proxy is the single ingress every external OGC client crosses, so its behavior determines whether the portal is fast and predictable or a source of intermittent ServiceException reports nobody can reproduce. This page sits within Infrastructure Orchestration & Configuration Management and explains how to configure Nginx, HAProxy, or Traefik so that WMS and WFS traffic is normalized, cached, authenticated, and routed deterministically — as version-controlled configuration rather than a hand-edited nginx.conf nobody dares touch.
The routing model below shows how a single ingress terminates TLS, normalizes and caches requests, then dispatches each OGC operation class to the backend that owns it, each with its own controls.
Architectural Placement: Where the Proxy Sits in the Stack
The reverse proxy is the portal’s edge control plane — the one tier that sees every request before any rendering engine, feature service, or spatial database does. Everything downstream is allowed to assume it is talking to a trusted, well-formed caller, because the proxy is where untrusted internet traffic is inspected, authenticated, and reshaped into internal service calls. That makes it the natural place to enforce three concerns that would otherwise be duplicated across every backend: TLS termination, request normalization, and operation-class routing.
OGC services are unusual HTTP citizens. They encode their entire intent in query-string parameters (SERVICE, REQUEST, LAYERS, BBOX) or in XML request bodies, and they care deeply about a handful of headers — Content-Type, Accept, and Authorization — that generic web proxies routinely mangle. The proxy must therefore forward those headers intact while mapping external routes onto internal service-discovery endpoints. For WMS, GetMap and GetCapabilities belong on stateless rendering engines such as GeoServer or MapServer; for WFS, GetFeature belongs on the feature service and Transaction (WFS-T) must be split off onto a privileged, write-capable path. The rendering tier this proxy fronts is the same read-heavy fleet described in Containerizing TileServer GL for High Availability, and the feature and transaction paths ultimately resolve to the spatial data layer covered in Kubernetes StatefulSets for PostGIS Databases.
A frequent and costly mistake is aggressive path rewriting. GetCapabilities responses embed absolute URLs that clients use to construct every subsequent request, so a proxy that strips the /geoserver/ or /mapserver/ prefix on the way in — without rewriting those embedded URLs on the way out — produces a capabilities document that points clients straight at 404s. The safe default is to preserve the upstream application’s base path and only rewrite when the backend is explicitly configured with a matching proxy base url. The proxy’s job here is normalization, not creative URL surgery.
Header Preservation and the OGC Trust Boundary
The boundary between “untrusted client” and “trusted internal service” is drawn by which headers the proxy forwards, rewrites, or refuses to trust. Three rules keep that boundary clean.
First, pass OGC-significant headers through verbatim. Misconfigured header stripping is the most common root cause of unexplained ServiceException and authentication failures in production, because the symptom (a 403 or an empty response) appears at the backend while the cause lives at the edge. Explicit proxy_set_header directives make the contract auditable:
# /etc/nginx/conf.d/ogc-upstream.conf — header contract for OGC backends
location /geoserver/ {
proxy_pass http://geoserver_pool;
# Preserve the headers OGC services actually depend on
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization; # never drop on WFS-T
proxy_set_header Content-Type $http_content_type;
proxy_set_header Accept $http_accept;
# Establish the real client identity for the backend and downstream logs
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
# Do not let a client forge the trust signal the backend reads
proxy_set_header X-Authenticated-Scope "";
}
Second, refuse client-supplied trust signals. Any header the backend uses to make an authorization decision — a scope claim, a tenant id, an X-Authenticated-* flag — must be explicitly cleared at the edge and re-injected only by the proxy after it validates a token. The empty X-Authenticated-Scope assignment above is not redundant; it stops a client from setting that header itself and impersonating an authenticated session.
Third, keep TLS and keep-alive sane for spatial payloads. TLS terminates at the proxy, with mutual TLS (mTLS) enforced for internal service-to-service hops so a compromised renderer cannot impersonate the edge. Large raster responses and long-lived GetFeature streams need keep-alive and buffer settings tuned for multi-megabyte bodies rather than the small-document defaults. This edge trust model is the HTTP-layer expression of the service-level zones documented in Security Boundary Mapping for OGC Services; the proxy is where those zones are physically enforced for inbound traffic.
Deterministic Cache Key Normalization
WMS GetMap is the most cacheable operation a portal serves and the most sensitive to parameter ordering. Two clients requesting the identical tile with LAYERS=roads&BBOX=… versus BBOX=…&LAYERS=roads are asking for the same bytes, but a default cache treats them as distinct keys — so the cache fills with near-duplicate entries, the hit rate collapses, and the rendering pool absorbs load it should never see. The fix is to compute a cache key from a normalized set of parameters: lower-case the keys, sort them, include only the parameters that change the rendered output, and exclude volatile tokens such as REQUEST_ID or a session cookie.
# Build a stable cache key from the parameters that actually affect the tile.
# Volatile tokens (REQUEST_ID, session ids) are deliberately excluded.
map $args $ogc_cache_key {
default $args;
}
proxy_cache_path /var/cache/nginx/ogc levels=1:2
keys_zone=ogc_cache:100m
max_size=20g inactive=24h use_temp_path=off;
location /geoserver/wms {
# Normalize to the rendering-significant parameter set
set $norm "${arg_service}|${arg_request}|${arg_layers}|${arg_crs}";
set $norm "${norm}|${arg_bbox}|${arg_width}|${arg_height}|${arg_format}";
proxy_cache_key $norm;
proxy_cache ogc_cache;
proxy_cache_valid 200 1h; # absorb GetMap spikes
proxy_cache_use_stale error timeout updating http_502 http_504;
add_header X-Cache-Status $upstream_cache_status always; # observability
proxy_pass http://geoserver_pool;
}
stale-while-revalidate and stale-if-error semantics let the edge keep serving a slightly old tile while a backend renderer is busy or briefly down, which smooths the traffic spikes a public portal sees during a news event or a coordinated data release. When the portal serves both raster imagery and pre-rendered vector tiles, the proxy’s eviction policy must be coordinated with the upstream tile cache so a dataset update invalidates both tiers together rather than leaving the edge serving stale geometry. Align proxy buffer sizes and cache-zone allocation with RFC 9111 HTTP caching semantics (which obsoletes the earlier RFC 7234) so a large raster payload does not trigger premature eviction or a disk-I/O bottleneck. The deeper interaction between edge cache and the rendering fleet’s own cache is covered in Containerizing TileServer GL for High Availability.
Operation-Class Routing as Declarative Config
The proxy’s routing rules are infrastructure, so they belong in a reviewed, version-controlled file — not in a console nobody audits. Traefik makes the operation-class split explicit through label-driven routers that match on the OGC request query parameter, which keeps the read path, the feature path, and the privileged transaction path on separate routers with separate middleware chains.
# traefik/dynamic/ogc-routing.yml — operation-class routers for OGC traffic
http:
routers:
# Cacheable WMS reads — no auth, aggressive caching middleware
wms-getmap:
rule: "PathPrefix(`/geoserver/wms`) && Query(`request=GetMap`)"
service: rendering-pool
middlewares: [normalize-cache, rate-limit-read]
# WFS reads — stricter, but still unauthenticated for public layers
wfs-getfeature:
rule: "PathPrefix(`/geoserver/wfs`) && Query(`request=GetFeature`)"
service: feature-service
middlewares: [rate-limit-read]
# WFS-T writes — authenticated, rate-limited, body-size-capped
wfs-transaction:
rule: "PathPrefix(`/geoserver/wfs`) && Method(`POST`)"
service: transactional-backend
middlewares: [oauth2-verify, rate-limit-write, body-limit-10m]
middlewares:
rate-limit-read:
rateLimit: { average: 200, burst: 400 }
rate-limit-write:
rateLimit: { average: 10, burst: 20 } # WFS-T is far cheaper to abuse
body-limit-10m:
buffering: { maxRequestBodyBytes: 10485760 }
Routing on Method(POST) to isolate WFS-T is intentional: transactional writes carry GML payloads that can mutate the spatial database, so they get the narrowest rate limit, a hard body-size cap, and a mandatory auth middleware, while read operations stay on a permissive, heavily-cached path. Keeping these rules declarative means a change to the write-path rate limit is a reviewable diff, and the same file can be linted and dry-run in CI before it ever reaches the edge. Detailed backend-distribution topology for the rendering pool referenced here lives in the deep-dive Configuring HAProxy for WMS Load Balancing.
Authentication and the WFS-T API Boundary
Transactional WFS is where the proxy stops being a router and becomes a policy enforcement point. A Transaction request can Insert, Update, or Delete features, so the edge must prove the caller’s identity and scope before the payload reaches a backend that will act on it. The pattern is to validate an OAuth2 / OIDC bearer token at the ingress, reject anything unscoped, and inject a trusted, proxy-asserted scope header that the backend reads instead of trusting the client. This keeps authentication logic out of every individual OGC service and centralizes it where the trust boundary actually is.
# Validate the bearer token at the edge before any WFS-T write is forwarded.
# auth_request delegates to an internal introspection endpoint.
location = /geoserver/wfs {
# Only enforce on transactional writes; reads fall through to the read path
if ($request_method = POST) {
set $require_auth 1;
}
auth_request /_token_introspect;
# The introspection response hands back the validated scope...
auth_request_set $scope $upstream_http_x_token_scope;
# ...which the proxy asserts to the backend as a trusted header
proxy_set_header X-Authenticated-Scope $scope;
client_max_body_size 10m; # cap GML Insert/Update payloads
proxy_pass http://transactional_backend;
}
location = /_token_introspect {
internal;
proxy_pass http://idp_introspection;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
The scope the proxy asserts is the narrowest credential that can satisfy the request — the same least-privilege principle the database tier applies when it routes writes through scoped roles. Which scopes map to which feature types and which tenants is governed by the role model in Implementing RBAC for Multi-Tenant GIS Portals; the proxy enforces the coarse “is this caller allowed to write at all” decision, and the backend, backed by row-level security in PostGIS, adjudicates the fine-grained “which rows.” Token introspection adds a network hop per write, so for high-throughput ingestion prefer local JWT signature verification against a cached JWKS document, falling back to introspection only for opaque tokens or revocation checks.
CI/CD Integration: Gating Proxy Changes
A proxy configuration should never be edited live. It is rendered from a versioned template directory and reconciled by a GitOps controller, so the running edge is always a function of a Git commit and a kubectl edit against the live config is flagged OutOfSync and reverted. The pipeline that promotes a config change runs syntactic and behavioral gates before any sync is allowed:
# .gitlab-ci.yml — gates that must pass before the edge config is synced
validate-proxy:
stage: test
script:
# 1. Syntax: a config that fails to parse must never reach the edge
- nginx -t -c build/nginx.conf
- haproxy -c -f build/haproxy.cfg
- traefik validate --configfile build/traefik.yml
# 2. Behavior: spin the proxy against stub backends and replay OGC requests
- ./ci/spin-proxy-with-stubs.sh
- ./ci/assert-header-passthrough.sh Authorization Content-Type Accept
- ./ci/assert-cache-key-normalized.sh # reordered params -> one cache key
- ./ci/assert-wfst-requires-token.sh # POST without bearer -> 401
rules:
- if: $CI_COMMIT_BRANCH == "main"
The behavioral gates matter more than the syntax check, because an nginx -t pass only proves the file parses — it says nothing about whether Authorization actually survives the hop or whether a token-less WFS-T write is rejected. A synthetic OGC request generator that replays representative GetMap, GetFeature, and Transaction calls against the rendered config catches the regressions that produce production ServiceException reports. These edge gates are the same drift-control discipline applied to the rest of the platform in Environment Parity in Geospatial CI Pipelines, extended to the public ingress.
Operational Troubleshooting
Most proxy incidents present as one of a few recurring shapes. Work the matrix below symptom-first, edge-log in hand.
- Authenticated WFS-T returns
403/401but the token is valid. TheAuthorizationheader is being stripped before the backend. Confirm an explicitproxy_set_header Authorization $http_authorization;is present and that no upstreamlocationblock overrides it; check/var/log/nginx/access.logfor the request reaching the backend without the header. GetCapabilitiesworks but every follow-up request404s. A path rewrite stripped the application prefix without rewriting the embedded service URLs. Inspect the capabilities XML for absolute URLs pointing at a wrong base path and set the backend’sproxy base urlto match the external route instead of rewriting at the edge.- Cache hit rate near zero under steady WMS load. Parameter ordering is fragmenting the key. Verify
X-Cache-StatusshowsMISSfor requests that differ only in argument order, then confirm theproxy_cache_keyis built from the normalized, sorted significant-parameter set and excludes volatile tokens. - Intermittent
502/504onGetMapduring peak traffic. Upstream timeouts are firing on long renders. Alignproxy_read_timeout/timeout serverwith the rendering engine’s worst-case execution window (typically 30–120s for large-extent rasterization) and enablestale-while-revalidateso the edge serves a recent tile instead of an error. ServiceExceptiononly for largeInsert/Updatepayloads. The request body is hitting a size cap. Raiseclient_max_body_size(Nginx) or themaxRequestBodyBytesmiddleware (Traefik) to fit legitimate GML transactions, while keeping the cap tight enough to reject abusive payloads.- A single slow backend drags down the whole pool. Health checks are too permissive. Replace bare TCP probes with an HTTP check that requests
GetCapabilitiesand asserts a200plus a well-formed response, so a renderer with an exhausted thread pool is pulled from rotation; the renderer-side distribution and probe tuning live in Configuring HAProxy for WMS Load Balancing. - Clients report mixed fresh/stale data after a dataset update. Edge cache and the upstream tile cache are evicting on different schedules. Coordinate invalidation so a publish event purges both tiers, and emit a cache-busting parameter or version tag on updated layers.
When defining compliance validation rules, reference the OGC Web Map Service 1.3.0 Specification so proxy transformations never drop or reorder a mandatory OGC parameter. A reverse proxy tuned this way turns a sprawl of fragile geospatial endpoints into a single, observable, version-controlled ingress — letting platform teams hold public-sector SLAs without hand-nursing the edge.
Related
- Configuring HAProxy for WMS Load Balancing — backend distribution, health checks, and timeout calibration for the rendering pool.
- Containerizing TileServer GL for High Availability — the read-heavy rendering tier this proxy fronts and shares cache policy with.
- Kubernetes StatefulSets for PostGIS Databases — the spatial data layer the feature and transaction paths resolve to.
- Security Boundary Mapping for OGC Services — the service-level trust zones the edge config enforces for inbound traffic.
- Implementing RBAC for Multi-Tenant GIS Portals — the scope and role model the WFS-T auth boundary maps onto.
Up one level: Infrastructure Orchestration & Configuration Management.