Rolling Back a Bad Spatial Dataset Publish

A keystroke-level runbook for reverting a faulty spatial dataset publish — wrong CRS, truncated features, or a broken schema — by repointing the serving layer, cache, and catalog record back to the last-good version.

This procedure is the hands-on companion to Spatial Data Versioning and Rollback Workflows and sits within the wider Metadata Catalog Automation & Ingestion Workflows practice; read the parent guide first if you need the rationale for why publishes are alias swaps and why the prior version must stay warm. Here the focus is narrow: given a bad publish already live, get back to the last-good state in minutes without a data reload.

Prerequisites

Each of these must already be true; a rollback is not the time to discover the prior version was deleted.

  • GeoServer 2.24+ reachable at $GS_URL, with an admin credential in $GS_USER/$GS_PASS scoped to the affected workspace.
  • The prior version’s datastore still registered and warm — the parent guide’s retention policy keeps at least the last version queryable. Confirm before you start.
  • The version manifest (dataset-version.yaml) for both the bad and the last-good version, so you can read the exact store and id values.
  • MapProxy 1.16+ (or your tile cache) reachable, with mapproxy-seed and the cache config available for a scoped purge.
  • PostgreSQL/PostGIS access to the catalog schema to repoint the CSW record inside a transaction.
  • A curl client and psql, plus write access to the incident log where the post-mortem note will live.

The rollback is a fixed six-step sequence. Each step is idempotent and independently verifiable, so a rollback interrupted midway can be resumed without doubling any effect.

Six-stage rollback sequence for a bad spatial dataset publish A left-to-right pipeline of six stages. Stage one, detect the bad publish from a validation alert or user report. Stage two, select the last-good version tag by walking the version history. Stage three, swap the GeoServer layer alias back to the prior versioned datastore via the REST API. Stage four, purge the MapProxy tile cache for the affected layer and extent. Stage five, repoint the catalog and CSW record to the restored version inside a transaction. Stage six, verify feature count, CRS, and metadata against the last-good manifest and write the post-incident note. 1 · Detect alert / user report 2 · Select last-good tag 3 · Swap layer REST repoint 4 · Purge cache scoped seed 5 · Repoint record CSW transaction 6 · Verify features + metadata

Step-by-step implementation

1. Confirm the bad publish and freeze further publishes

Rollback starts by confirming the failure is real and stopping the pipeline from publishing again on top of it. The trigger is usually a post-publish validation alert (feature count dropped, CRS mismatch) or a user report (“the parcels layer is offset by 100 metres”). Read the live version tag and compare it against the manifest that the publish job wrote.

# What is the alias serving right now, and what does its manifest claim?
curl -sf -u "$GS_USER:$GS_PASS" \
  "$GS_URL/rest/layers/geoportal:parcels.json" | \
  python3 -c 'import sys,json; print(json.load(sys.stdin)["layer"]["resource"]["name"])'
# -> parcels_v2026_07_13_r418:parcels    (the suspected-bad version)

# Freeze the pipeline so it does not republish while you roll back.
curl -sf -XPOST "$PIPELINE_URL/publish/parcels/pause" -H "Authorization: Bearer $PIPE_TOKEN"

Record the bad version id (parcels@2026-07-13T02-15-00Z.r418) in the incident note now; you will quarantine that exact store later.

2. Identify the last-good version tag

Walk the version history backward from the current version, following each record’s parent_version, and pick the most recent one whose status was current and whose validation verdict passed. When the catalog is in PostGIS this is a single query.

-- Find the newest retained, validation-passed version below the current one.
SELECT id, store, revision, feature_count, crs
  FROM catalog.versions
 WHERE dataset = 'parcels'
   AND status IN ('retained', 'archived')
   AND validated = true
 ORDER BY revision DESC
 LIMIT 1;
-- -> parcels@2026-07-12T02-14-00Z.r417 | parcels_v2026_07_12_r417 | 417 | 1284002 | EPSG:27700

The store value (parcels_v2026_07_12_r417) is your rollback target. Confirm that store is still warm — registered in GeoServer and present in PostGIS — before you swap; if retention archived it to cold storage you must restore it first, which is why the parent guide insists on keeping the last N versions warm.

3. Repoint the GeoServer layer to the previous store

The swap is a single REST PUT that changes only the alias layer’s underlying resource, keeping its name, style, and capabilities entry intact. This is the moment the rollback becomes visible to clients.

LAST_GOOD_STORE="parcels_v2026_07_12_r417"
curl -sf -u "$GS_USER:$GS_PASS" -XPUT \
  -H "Content-Type: application/json" \
  "$GS_URL/rest/layers/geoportal:parcels" \
  -d "{\"layer\":{\"defaultStyle\":{\"name\":\"parcels\"},
        \"resource\":{\"@class\":\"featureType\",
        \"name\":\"${LAST_GOOD_STORE}:parcels\"}}}"

# Confirm the alias now resolves to the last-good store.
curl -sf -u "$GS_USER:$GS_PASS" \
  "$GS_URL/rest/layers/geoportal:parcels.json" | grep -o "parcels_v2026_07_12_r417"

If your topology swapped the datastore’s connection parameters rather than repointing the layer to a differently-named store, the inverse is to PUT the previous connection parameters back onto the datastore; either way the alias name the clients bind to never changes.

4. Invalidate the tile cache for the affected layers

The layer now serves last-good vectors, but the tile cache still holds tiles rendered from the bad version. Purge — and pre-seed — only the affected layer and extent so the cache does not serve stale imagery over the restored data. Scope the seed to the bad publish’s changed extent, not the whole layer, to keep recovery bounded.

# rollback-seed.yaml — MapProxy seed/cleanup scoped to the affected layer + extent.
seeds:
  parcels_rollback:
    caches: [parcels_cache]
    grids: [webmercator]
    levels: { from: 0, to: 16 }
    coverages: [affected_area]
cleanups:
  parcels_purge:
    caches: [parcels_cache]
    grids: [webmercator]
    remove_all: false
    coverages: [affected_area]
coverages:
  affected_area:
    bbox: [-2.75, 51.35, -2.45, 51.55]     # the extent the bad publish touched
    srs: "EPSG:4326"
# Purge the stale tiles, then re-seed the affected extent from the restored layer.
mapproxy-seed -f mapproxy.yaml -s rollback-seed.yaml --cleanup parcels_purge
mapproxy-seed -f mapproxy.yaml -s rollback-seed.yaml --seed parcels_rollback

For large extents, bound the re-seed with concurrency and level limits as covered in tuning MapProxy cache seeding for large extents rather than reflowing the whole pyramid.

5. Repoint the catalog and CSW record to the restored version

Data and metadata roll back together. Update the catalog record’s live pointer and flip the version-status rows in one transaction so discovery clients immediately advertise the restored CRS, extent, and schema.

BEGIN;
  UPDATE catalog.records
     SET current_version = 'parcels@2026-07-12T02-14-00Z.r417',
         updated_at = now()
   WHERE dataset = 'parcels';
  -- demote the bad version, restore the last-good one to current
  UPDATE catalog.versions
     SET status = 'quarantined'
   WHERE id = 'parcels@2026-07-13T02-15-00Z.r418';
  UPDATE catalog.versions
     SET status = 'current'
   WHERE id = 'parcels@2026-07-12T02-14-00Z.r417';
COMMIT;

If your CSW is GeoNetwork or pycsw with its own record store rather than raw PostGIS, issue the equivalent CSW-T Transaction update against the record identifier; the invariant is that the record and the alias name the same version when the transaction commits.

6. Write the post-incident note

With service restored, capture what happened while it is fresh: the bad version id, the detection trigger and timestamp, the last-good version rolled back to, the wall-clock recovery time, and the root cause if known (a source extract with a truncated feature set, a CRS misdeclaration upstream). Attach it to the quarantined version so a later review can inspect the exact store that failed. This note is what tightens the pre-publish gate so the same class of bad publish is caught before promotion next time.

Verification

Confirm the rollback took at every layer before you unfreeze the pipeline.

# 1. The alias resolves to the last-good store.
curl -sf -u "$GS_USER:$GS_PASS" \
  "$GS_URL/rest/layers/geoportal:parcels.json" | grep -o "parcels_v2026_07_12_r417"
#   -> parcels_v2026_07_12_r417

# 2. Feature count matches the last-good manifest (1284002), not the truncated bad one.
curl -sf -u "$GS_USER:$GS_PASS" \
  "$GS_URL/geoserver/geoportal/ows?service=WFS&version=2.0.0&request=GetFeature&typeNames=geoportal:parcels&resultType=hits"
#   -> numberMatched="1284002"

# 3. CRS in the capabilities is the correct EPSG:27700, not the bad publish's CRS.
curl -sf "$GS_URL/geoserver/geoportal/wms?service=WMS&version=1.3.0&request=GetCapabilities" \
  | grep -A2 "<Name>parcels</Name>" | grep "CRS"
#   -> <CRS>EPSG:27700</CRS>

# 4. A freshly fetched tile in the affected extent renders restored geometry (200, not stale).
curl -sfI "$TILE_URL/parcels/webmercator/16/32100/21750.png" | grep -i "x-cache\|http/"
#   -> HTTP/1.1 200  ·  X-Cache: MISS   (re-seeded, not the stale tile)

# 5. The CSW record advertises the restored version.
psql "$PG_DSN" -c \
  "SELECT current_version FROM catalog.records WHERE dataset='parcels';"
#   -> parcels@2026-07-12T02-14-00Z.r417

When all five agree, resume the pipeline. Feature count and CRS matching the last-good manifest, plus a cache MISS that returns correct geometry, together prove data, tiles, and metadata are consistent on the restored version.

Troubleshooting matrix

Symptom Likely cause Fix
PUT to the layer returns 500 or 404 Target datastore not registered / wrong resource name Re-register the last-good store, then repeat the swap with the exact store:featureType name
Clients still see truncated features after swap Tile cache serving stale tiles from the bad version Run the scoped --cleanup then --seed; confirm a fresh tile returns X-Cache: MISS
WFS hits count still shows the bad number Alias did not actually repoint / connection pool cached Re-verify the layer resource name; bounce the GeoServer datastore connection pool
CSW still advertises the bad CRS/extent Catalog record repoint step skipped or rolled back Re-run the catalog transaction; confirm current_version equals the last-good id
Last-good store missing in PostGIS Retention archived or dropped it to cold storage Restore the archived version, re-register it, then swap; raise the warm-retention floor afterward
Rollback succeeds then pipeline republishes the bad data Pipeline was never frozen in step 1 Pause the publisher, quarantine the bad version, fix the source before unfreezing
Tiles at high zoom still stale after seed Seed levels/extent too narrow Widen the seed levels and coverages to the full affected extent; re-run the seed

Up one level: Spatial Data Versioning and Rollback Workflows.