Caching GetCapabilities Responses at the Edge
How to cache heavy WMS and WFS GetCapabilities documents at an nginx edge with a stable, normalized cache key and a purge-on-publish path that keeps clients from ever seeing a stale layer list.
This page is a focused companion to Reverse Proxy Configuration for WMS/WFS, part of the wider Infrastructure Orchestration & Configuration Management practice; read the parent guide for the overall proxy topology, then apply this procedure to the single most expensive OGC request your backend serves. A GetCapabilities document for a portal with thousands of layers can take a GeoServer seconds to assemble; caching it at the edge turns that into a sub-millisecond HIT, but only if the cache key is stable and invalidation is tied to publishing.
Prerequisites
Confirm these before editing the proxy config, because each affects correctness of the cached document.
- nginx 1.25+ compiled with
ngx_http_proxy_moduleand theproxy_cachedirectives (stock nginx is fine). - A backend WMS/WFS endpoint — GeoServer, MapServer, or QGIS Server — reachable as an upstream, e.g.
geoserver:8080. - A writable cache path on fast local disk, e.g.
/var/cache/nginx/ogc, owned by the nginx worker user. - A publish signal you can hook: a GeoServer REST callback, a CI step, or a message you can turn into an HTTP purge request.
- Clarity on which capabilities are public and which are per-user; authenticated capabilities must never share a cache key with anonymous ones.
Step-by-step implementation
The mechanism is a normalization step that folds equivalent requests onto one key, a short-TTL cache in front of the backend, and a purge trigger fired when layers change. The diagram traces a request from arrival through key derivation to a HIT or MISS, plus the out-of-band purge.
1. Normalize the query string into a stable key
OGC clients send parameters in any case and any order — SERVICE=WMS&REQUEST=GetCapabilities and request=getcapabilities&service=wms are identical requests that must not create two cache entries. Fold them with a map that lowercases the significant parameters and drop volatile ones (_dc cache-busters, session tokens) entirely.
# Lowercase the three parameters that define a capabilities document.
map $arg_service $ogc_service { default $arg_service; ~*^(?<v>.+)$ $v; }
map $arg_request $ogc_request { default $arg_request; ~*^(?<v>.+)$ $v; }
map $arg_version $ogc_version { default "0.0.0"; ~*^(?<v>.+)$ $v; }
# Only capabilities requests are cacheable; everything else bypasses.
map $ogc_request $is_capabilities {
default 0;
~*^getcapabilities 1;
}
Build the cache key from the normalized triple plus the layer workspace, deliberately excluding volatile args so equivalent requests converge.
proxy_cache_key "$ogc_service|$ogc_request|$ogc_version|$uri";
2. Declare the cache zone and cache only capabilities
Define a shared-memory zone and disk path, then cache with a short TTL. Capabilities change only when layers are published, so a short TTL bounds staleness while the purge path (step 4) handles the common case immediately.
proxy_cache_path /var/cache/nginx/ogc levels=1:2
keys_zone=ogc_caps:20m max_size=500m inactive=1h use_temp_path=off;
server {
listen 80;
server_name maps.example.org;
location /geoserver/ows {
proxy_pass http://geoserver:8080;
proxy_cache ogc_caps;
proxy_cache_key "$ogc_service|$ogc_request|$ogc_version|$uri";
proxy_cache_valid 200 60s; # short TTL bounds worst-case staleness
proxy_cache_valid any 0; # never cache errors or redirects
# Only GetCapabilities is cacheable; skip everything else.
proxy_no_cache $arg_authkey; # never cache authenticated calls
proxy_cache_bypass $arg_authkey;
set $skip_cache 1;
if ($is_capabilities) { set $skip_cache 0; }
proxy_no_cache $skip_cache;
proxy_cache_bypass $skip_cache;
add_header X-Cache-Status $upstream_cache_status;
proxy_cache_lock on; # collapse a stampede into one backend fetch
}
}
3. Add a Vary and honor encoding
Clients negotiate gzip; caching a compressed body and serving it to a client that did not ask for gzip corrupts the response. Cache per encoding by adding Accept-Encoding to the variance, and preserve the backend’s own Vary.
proxy_set_header Accept-Encoding $http_accept_encoding;
proxy_cache_valid 200 60s;
add_header Vary "Accept-Encoding";
gzip on;
gzip_types application/xml text/xml application/vnd.ogc.wms_xml;
4. Purge or bypass on layer publish
The short TTL is a safety net, not the primary invalidation. Wire your publishing workflow to actively evict the key the moment a layer is added, renamed, or removed. With open-source nginx, the simplest robust approach is a cache-buster location the publish webhook calls, which sets proxy_cache_bypass so the next fetch repopulates.
# Publish hook: GET /_purge/caps forces a fresh fetch that repopulates the key.
location /_purge/caps {
allow 10.0.0.0/8; # internal publishers only
deny all;
proxy_pass http://geoserver:8080/geoserver/ows?service=wms&request=GetCapabilities&version=1.3.0;
proxy_cache ogc_caps;
proxy_cache_key "wms|getcapabilities|1.3.0|/geoserver/ows";
proxy_cache_bypass 1; # ignore the stored copy, re-store fresh
proxy_cache_valid 200 60s;
}
Trigger it from the same event that publishes the layer — a GeoServer REST post-hook or a CI step — so the capabilities document is refreshed within one request of any change. The tile-side analogue of this pattern, serving a slightly stale copy while a fresh one is fetched, is covered in Configuring Stale-While-Revalidate Tile Caching.
5. Keep authenticated capabilities out of the shared cache
A capabilities document filtered by a user’s permissions must never be served to another user. Any request carrying an auth key, cookie, or bearer token is forced to bypass the shared zone entirely — the proxy_no_cache $arg_authkey line in step 2 begins this, but also guard the Authorization header.
map $http_authorization $is_authenticated { default 0; "~.+" 1; }
# in the location:
# proxy_no_cache $is_authenticated;
# proxy_cache_bypass $is_authenticated;
When capabilities differ per tenant, front them with a per-user or per-tenant key rather than caching a single shared document. Load balancing across multiple backends behind this cache is handled in Configuring HAProxy for WMS Load Balancing.
Verification
Confirm the key is stable, hits are served from cache, and a publish purge forces a fresh fetch.
# 1. Two differently-cased requests must land on the same entry: MISS then HIT
curl -s -o /dev/null -D - "http://maps.example.org/geoserver/ows?SERVICE=WMS&REQUEST=GetCapabilities&VERSION=1.3.0" | grep X-Cache
# X-Cache-Status: MISS
curl -s -o /dev/null -D - "http://maps.example.org/geoserver/ows?service=wms&request=getcapabilities&version=1.3.0" | grep X-Cache
# X-Cache-Status: HIT <- normalization worked
# 2. Volatile params must not fork the key
curl -s -o /dev/null -D - "http://maps.example.org/geoserver/ows?service=wms&request=getcapabilities&version=1.3.0&_dc=99999" | grep X-Cache
# X-Cache-Status: HIT
# 3. A publish purge forces the next request back to MISS
curl -s -o /dev/null "http://maps.example.org/_purge/caps"
curl -s -o /dev/null -D - "http://maps.example.org/geoserver/ows?service=wms&request=getcapabilities&version=1.3.0" | grep X-Cache
# X-Cache-Status: MISS <- repopulated fresh
# 4. Authenticated requests must never be cached
curl -s -o /dev/null -D - -H "Authorization: Bearer x" "http://maps.example.org/geoserver/ows?service=wms&request=getcapabilities" | grep X-Cache
# X-Cache-Status: BYPASS
A HIT on the second, differently-cased request proves the key normalized; a BYPASS under an Authorization header proves per-user documents stay private.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
Every request is MISS, never HIT |
Cache key includes a volatile param (_dc, timestamp, session) |
Drop volatile args from proxy_cache_key; normalize with the map blocks |
| Clients see an old layer list after publishing | Purge not wired to the publish event; relying on TTL alone | Fire the /_purge/caps hook from the layer-publish callback |
| One user sees another tenant’s capabilities | Authenticated response cached under a shared key | Force proxy_no_cache/proxy_cache_bypass on any auth token or cookie |
| gzip clients receive garbled XML | Compressed body served to a non-gzip client | Add Accept-Encoding to Vary and to the upstream header |
| Cache never populates, disk stays empty | Wrong owner on proxy_cache_path or use_temp_path misconfigured |
Ensure the nginx worker owns the path; set use_temp_path=off |
| Backend hammered by simultaneous first requests | No request coalescing on a cold key | Enable proxy_cache_lock on; so one fetch fills the entry |
MISS returned for error responses that then get cached |
proxy_cache_valid any too permissive |
Set proxy_cache_valid any 0; and cache only 200 |
For the precise semantics of proxy_cache_key, proxy_cache_valid, and proxy_cache_bypass, the official nginx ngx_http_proxy_module reference is authoritative.
Related
- Reverse Proxy Configuration for WMS/WFS — the parent guide covering the full OGC proxy topology this cache plugs into.
- Configuring HAProxy for WMS Load Balancing — spreading GetMap and GetFeature traffic across backends behind the cache.
- Configuring Stale-While-Revalidate Tile Caching — the tile-side pattern for serving slightly stale content while refreshing.
Up one level: Reverse Proxy Configuration for WMS/WFS.