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_module and the proxy_cache directives (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.

Normalized cache key drives HIT or MISS while a publish webhook purges on layer change An incoming GetCapabilities request enters a normalization step that lowercases the service, request, and version parameters, drops volatile parameters, and sorts what remains into a stable cache key. The key is looked up in the nginx proxy cache. A cache hit returns the stored document immediately. A cache miss forwards to the GeoServer backend, which assembles the capabilities document; nginx stores it with a short time to live and a Vary on Accept-Encoding, then returns it. Separately, a layer publish event calls a purge webhook that deletes the matching key from the cache out of band, so the next request is a miss that repopulates fresh content. GetCapabilities client request normalize lowercase params drop volatile · sort nginx cache key = svc|req|ver|layers short TTL · Vary HIT served in ~0 ms MISS GeoServer assembles doc store + return publish webhook PURGE on layer change invalidate key

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.

Up one level: Reverse Proxy Configuration for WMS/WFS.