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();

A floating tag is a dependency that updates itself without appearing in any diff.

What a floating tag actually gives you over six monthsA horizontal timeline. deploy: PostGIS 3.4.0. rebuild: silently 3.4.1. rebuild: silently 3.4.2. rebuild: 3.5.0 — new GEOS. incident: a plan changed; nothing in the diff explains it. Nothing in the repository changed across these five points. That is precisely the problem a digest pin solves.What a floating tag actually gives you over six monthsdeployPostGIS 3.4.0rebuildsilently 3.4.1rebuildsilently 3.4.2rebuild3.5.0 — new GEOSincidenta plan changed; nothing in the diff explains itNothing in the repository changed across these five points. That is precisely the problem a digest pin solves.

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

Each step down this list trades reproducibility for automatic patches — and only the last row makes upgrades visible.

Pinning strength, from weakest to strongestA comparison table. postgis/postgis:latest: Reproducible no, Gets fixes yes. never do this postgis/postgis:16: Reproducible no, Gets fixes yes. major only — still drifts postgis/postgis:16-3.4: Reproducible partly, Gets fixes yes. patch versions still move …@sha256:…: Reproducible yes, Gets fixes no. exact, and updates are a deliberate PR The digest is the only line that makes a rebuild byte-identical, and the only one where an upgrade is reviewable.Pinning strength, from weakest to strongestReproducibleGets fixespostgis/postgis:latestnever do thispostgis/postgis:16major only — still driftspostgis/postgis:16-3.4~patch versions still move…@sha256:…exact, and updates are a deliberate PRThe digest is the only line that makes a rebuild byte-identical, and the only one where an upgrade is reviewable.

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.


Pinning is only half the discipline; the other half is a scheduled review. A digest that is never updated becomes an image that never receives a security patch, which is a different kind of risk from the one pinning was adopted to solve. Put the upgrade on a calendar, treat it as an ordinary pull request with the version diff visible, and run the same startup assertions against the new image before it reaches production.

Pinning is only half the discipline; the other half is a scheduled review. A digest that is never updated becomes an image that never receives a security patch, which is a different kind of risk from the one pinning was adopted to solve. Put the upgrade on a calendar, treat it as an ordinary pull request with the version diff visible, and run the same startup assertions against the new image before it reaches production.

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