Pinning PostGIS Versions in Production Images

Floating tags like postgis/postgis:latest silently change ST_ algorithm output and break reproducibility. Pin by exact tag and sha256 digest, keep client GDAL aligned, and follow the safe ALTER EXTENSION postgis UPDATE upgrade path.

← Back to Containerizing PostGIS & FastAPI

A floating postgis/postgis:latest tag turns a routine image pull into a silent geometry-algorithm change — pin by exact tag and digest so ST_ output is reproducible across every environment and rebuild.

Context & when to use

PostGIS is not a passive store; it is the engine that computes your geometry. Its ST_ functions call into GEOS and PROJ, and those libraries evolve. Between PostGIS 3.3 and 3.4, or between the GEOS versions two minor image tags bundle, functions like ST_SimplifyPreserveTopology, ST_Buffer, ST_MakeValid, and even ST_IsValid can return different but equally correct results — a slightly different vertex set, a polygon that was previously flagged invalid now repaired, a simplification that keeps one more point. When your image references :latest or a bare major like :16, a rebuild months apart pulls a newer build, and suddenly a regression test comparing serialized geometry fails, a cached tile no longer matches a freshly rendered one, or a downstream diff pipeline reports thousands of “changed” features that nobody edited.

Pin whenever an environment must be reproducible: production, staging, and CI all need to run the same PostGIS build so that a geometry computed in CI equals the one computed in production. This is the operational complement to the base-image discipline in Containerizing PostGIS & FastAPI — that guide pins the tag; this one pins the immutable digest and covers the upgrade path. The precondition is a private or trusted registry mirror if you need the pinned digest to survive an upstream tag being re-pushed.


Why floating tags drift

Floating tag drift versus pinned digest stabilityThe floating tag postgis:16 resolves in January to a GEOS 3.11 build and in June to a GEOS 3.12 build, producing two different ST_SimplifyPreserveTopology outputs. The pinned digest sha256 always resolves to the same GEOS 3.12 build and the same output regardless of when it is pulled.FLOATING · postgis:16Jan pullGEOS 3.11 buildJun pullGEOS 3.12 buildDIFFERENTST_Simplify outputreproducibility ✕PINNED · @sha256:…Jan pullGEOS 3.12 buildJun pullGEOS 3.12 buildIDENTICALST_Simplify outputreproducible ✓

A tag is a mutable pointer; a sha256 digest is the content-addressed identity of one exact image. Pinning the tag documents intent (PostGIS 3.4 on PostgreSQL 16); pinning the digest guarantees byte-for-byte the same GEOS and PROJ every pull.


Runnable implementation

Pin the database image in the Dockerfile (or the compose image:) to a tag and its digest, then align the client stack. Resolve the digest once, commit it, and treat a change to it as a deliberate upgrade.

# 1. Resolve the current digest for the exact tag you intend to run.
docker pull postgis/postgis:16-3.4
docker inspect --format '{{index .RepoDigests 0}}' postgis/postgis:16-3.4
# postgis/postgis@sha256:9c1f...e2a7   <-- commit THIS string
# Dockerfile (or compose image:) — tag documents intent, digest guarantees identity.
# The tag after the @digest is ignored by the daemon but kept for humans.
FROM postgis/postgis:16-3.4@sha256:9c1f4b2d3a5e6f7089abcdef0123456789abcdef0123456789abcdef0123e2a7
# docker-compose.yml — same pin, plus a client stack aligned to the DB's PROJ/GEOS.
services:
  db:
    image: postgis/postgis:16-3.4@sha256:9c1f4b2d3a5e6f7089abcdef...e2a7
    environment:
      POSTGRES_DB: gis
      POSTGRES_USER: gis
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gis && psql -U gis -d gis -tAc 'SELECT PostGIS_Version()'"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    build: .            # app image pins libgdal32 to the SAME GDAL major as the DB
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:
-- The SAFE upgrade path. Run ONLY after deliberately bumping the pinned
-- digest to a newer minor and letting the new binary start against the
-- existing data volume. This reconciles the SQL-level extension objects
-- with the new .so, without a dump/restore.
ALTER EXTENSION postgis UPDATE;                 -- core geometry types + functions
SELECT postgis_extensions_upgrade();            -- also updates postgis_raster,
                                                -- postgis_topology, tiger geocoder
-- Confirm the reconciliation:
SELECT PostGIS_Full_Version();

Key parameters & options

ElementPurposeRecommended value
Exact tag 16-3.4Documents the intended PostgreSQL + PostGIS pairNever :latest, never bare :16
@sha256:… digestContent-addressed, immutable image identityResolve once, commit, review on change
Client libgdal32 majorKeep API-side GDAL aligned with the DB’s GEOS/PROJSame major as the pinned DB build
ALTER EXTENSION postgis UPDATEReconciles SQL objects with a new PostGIS .soRun after every deliberate minor bump
postgis_extensions_upgrade()Upgrades raster/topology/tiger sub-extensions tooRun alongside the ALTER EXTENSION
PostGIS_Full_Version()Reports PostGIS, GEOS, PROJ, and GDAL versionsThe single verification source of truth
Digest in a private mirrorSurvives upstream tags being re-pushedMirror the digest for supply-chain safety

Gotchas & failure modes

  • A minor bump silently changes ST_ output. Upgrading the pinned digest from a GEOS 3.11 build to a 3.12 build can shift ST_SimplifyPreserveTopology, ST_Buffer, and ST_MakeValid results by a vertex or a coordinate. A geometry-diff regression suite then reports mass “changes.” This is expected, not a bug — treat every digest bump as a change that requires re-baselining golden geometry fixtures, and gate it behind the same review as a schema migration handled in CI.

  • Mismatched PROJ data between client and server. If the API container’s pyproj/libproj25 bundles a different PROJ datum grid than the database, an ST_Transform computed server-side and a pyproj transform computed client-side can disagree by centimetres to metres for datum-shifting SRIDs. Pin the client PROJ to the same major as the database, and prefer doing all reprojection in one place — see the serialization trade-offs in GeoJSON vs GeoParquet serialization.

  • Downgrade is effectively impossible. ALTER EXTENSION postgis UPDATE moves forward only; there is no DOWNGRADE. If a bumped digest breaks you, the pinned old digest still exists in the registry, but a data directory already touched by the newer binary will refuse to start under the older one (database files are incompatible with server). Always snapshot the volume (or take a pg_dump) before bumping, so rollback is a restore, not a prayer.

  • could not access file "$libdir/postgis-3" after the binary changed but the SQL did not. The container started on a new PostGIS .so but you never ran ALTER EXTENSION postgis UPDATE, so the catalog still points at the old library name. Fix: run the upgrade SQL immediately after the first start on a new minor.

  • The digest points at a multi-arch manifest list. On Apple Silicon vs x86 CI, the same tag resolves to different per-architecture digests. Pin the manifest list digest (what docker inspect returns for the tag) so Docker selects the right arch, or pin per-arch digests explicitly in a build matrix — mismatches otherwise surface as exec format error.


Verification

PostGIS_Full_Version() is the one call that reports every library the geometry engine depends on. Assert it in a smoke test so a drifted image fails the pipeline before it serves traffic:

# Confirm the running database reports the exact expected stack.
docker compose exec -T db psql -U gis -d gis -tAc "SELECT PostGIS_Full_Version();"
# POSTGIS="3.4.2 ..." [EXTENSION] PGSQL="160" GEOS="3.12.1-CAPI-1.18.1"
#   PROJ="9.3.1" LIBXML="2.9.14" LIBJSON="0.17" ...

# Assert the digest actually deployed matches what you pinned.
docker inspect --format '{{index .RepoDigests 0}}' \
  "$(docker compose images -q db)"
# postgis/postgis@sha256:9c1f...e2a7   <-- must equal the committed digest

# Pin-drift guard for CI: fail if GEOS is not the expected version.
docker compose exec -T db psql -U gis -d gis -tAc "SELECT PostGIS_Full_Version();" \
  | grep -q 'GEOS="3.12' || { echo "PostGIS/GEOS drift detected"; exit 1; }

If PostGIS_Full_Version() reports a GEOS or PROJ version you did not expect, the pinned digest changed underneath you — reconcile the committed digest before allowing the deploy to proceed.


← Back to Containerizing PostGIS & FastAPI