← 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
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
| Element | Purpose | Recommended value |
|---|---|---|
Exact tag 16-3.4 | Documents the intended PostgreSQL + PostGIS pair | Never :latest, never bare :16 |
@sha256:… digest | Content-addressed, immutable image identity | Resolve once, commit, review on change |
Client libgdal32 major | Keep API-side GDAL aligned with the DB’s GEOS/PROJ | Same major as the pinned DB build |
ALTER EXTENSION postgis UPDATE | Reconciles SQL objects with a new PostGIS .so | Run after every deliberate minor bump |
postgis_extensions_upgrade() | Upgrades raster/topology/tiger sub-extensions too | Run alongside the ALTER EXTENSION |
PostGIS_Full_Version() | Reports PostGIS, GEOS, PROJ, and GDAL versions | The single verification source of truth |
| Digest in a private mirror | Survives upstream tags being re-pushed | Mirror 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 shiftST_SimplifyPreserveTopology,ST_Buffer, andST_MakeValidresults 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/libproj25bundles a different PROJ datum grid than the database, anST_Transformcomputed server-side and apyprojtransform 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 UPDATEmoves forward only; there is noDOWNGRADE. 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 apg_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.sobut you never ranALTER 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 inspectreturns for the tag) so Docker selects the right arch, or pin per-arch digests explicitly in a build matrix — mismatches otherwise surface asexec 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.
Related
- Containerizing PostGIS & FastAPI — base image selection, the compose stack, and PostGIS-aware healthchecks this pinning strategy locks down
- Multi-Stage Docker Builds for PostGIS & FastAPI — align the builder and runtime GDAL versions so a pinned image stays reproducible
- GeoJSON vs GeoParquet serialization — where reprojection and serialization belong when client and server PROJ versions must agree
← Back to Containerizing PostGIS & FastAPI