← 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();A floating tag is a dependency that updates itself without appearing in any diff.
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 |
Each step down this list trades reproducibility for automatic patches — and only the last row makes upgrades visible.
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.
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.
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