Docker-Based PostGIS Validation Containers
Some spatial checks only make sense in a database: ST_IsValid across a million rows, overlap detection with a spatial index, set-level topology predicates that GeoPandas would struggle to hold in memory. Running those checks against a shared, long-lived database in continuous integration is a reproducibility trap — versions drift and state leaks between runs. This page builds a reproducible, ephemeral PostGIS container instead: a pinned Dockerfile, init SQL that enables PostGIS and defines ST_IsValid checks, a healthcheck, and a docker-compose service that CI spins up fresh and throws away. It is the database backbone of Continuous Integration for Spatial Validation.
Prerequisites
- Docker Engine 24+ and docker-compose v2 (
docker compose version). The CI runner must allow launching service containers. - A pinned PostGIS base image. Reference
postgis/postgis:16-3.4by digest, not by the floating tag, so the PostgreSQL 16, PostGIS 3.4, and underlying GEOS versions are fixed. A GEOS change can flip anST_IsValidresult on a degenerate ring. - A dataset the container can load. A GeoPackage or GeoJSON on disk, loadable with
ogr2ogr, or SQLCOPY-friendly rows. The Geospatial Data Abstraction Library (GDAL)ogr2ogrbinary ships in the PostGIS image’s ecosystem or a companion image. - A target CRS decision. Load geometries in a known coordinate reference system (CRS) and store the SRID explicitly;
ST_IsValidis evaluated in the stored coordinate space, so a wrong SRID makes downstream metric checks meaningless. - Awareness of ephemerality. This container is disposable. Do not mount a named volume for its data directory — the whole point is that each run starts empty so init SQL re-runs deterministically.
Step-by-Step Procedure
Step 1 — Write the pinned Dockerfile
Extend the official PostGIS image only to layer in your init SQL and healthcheck. Keep it thin; the base image already contains PostgreSQL, PostGIS, and GEOS.
The Dockerfile is emitted here from Python so it can live alongside the rest of the runnable snippets, since this site’s code blocks are Python or SQL.
# scripts/write_dockerfile.py — emit validation.Dockerfile
from pathlib import Path
DOCKERFILE = r'''
# Pin by tag here for readability; pin by digest (postgis/postgis@sha256:...) in production
FROM postgis/postgis:16-3.4
# Init scripts run once, in filename order, on first (empty) startup
COPY init/00_extensions.sql /docker-entrypoint-initdb.d/00_extensions.sql
COPY init/10_validation.sql /docker-entrypoint-initdb.d/10_validation.sql
# Readiness signal so consumers wait for the DB before loading data
HEALTHCHECK --interval=5s --timeout=3s --retries=10 \
CMD pg_isready -U validator -d spatial_qc || exit 1
'''
Path("validation.Dockerfile").write_text(DOCKERFILE.lstrip())
print("wrote validation.Dockerfile")
Verification: run the script, then docker build -f validation.Dockerfile -t spatial-qc-db .. A successful build confirms the base image resolves and the init files copy. The image is not yet functional until Step 2 supplies the SQL.
Step 2 — Add init SQL that enables PostGIS and defines checks
Two files in docker-entrypoint-initdb.d run at first startup. The first enables the extension; the second defines a validation view built on ST_IsValid and ST_IsValidReason that any consumer can query.
-- init/00_extensions.sql
CREATE EXTENSION IF NOT EXISTS postgis;
-- Table the loader will populate; SRID 4326 is enforced by the column typmod
CREATE TABLE IF NOT EXISTS parcels (
parcel_id text PRIMARY KEY,
zoning_code text NOT NULL,
assessed_value numeric CHECK (assessed_value >= 0),
geom geometry(MultiPolygon, 4326) NOT NULL
);
-- init/10_validation.sql
-- One row per invalid feature, with the GEOS reason string for triage.
CREATE OR REPLACE VIEW invalid_parcels AS
SELECT
parcel_id,
ST_IsValidReason(geom) AS reason,
ST_SRID(geom) AS srid
FROM parcels
WHERE NOT ST_IsValid(geom);
-- Convenience aggregate the CI gate queries for a single pass/fail number.
CREATE OR REPLACE FUNCTION count_invalid_parcels()
RETURNS bigint
LANGUAGE sql STABLE AS $$
SELECT count(*) FROM invalid_parcels;
$$;
Verification: start the container (docker run --rm -e POSTGRES_USER=validator -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=spatial_qc spatial-qc-db) and, once healthy, run SELECT count_invalid_parcels(); — it must return 0 against the empty table, proving the extension, table, and view initialized without error.
Step 3 — Compose an ephemeral database with docker-compose
Declare the service with a tmpfs mount for the data directory so nothing persists between runs, and expose the healthcheck so dependents can wait on it.
# scripts/write_compose.py — emit docker-compose.validation.yml
from pathlib import Path
COMPOSE = r'''
services:
spatial-qc-db:
build:
context: .
dockerfile: validation.Dockerfile
environment:
POSTGRES_USER: validator
POSTGRES_PASSWORD: pw
POSTGRES_DB: spatial_qc
# Ephemeral: data lives in RAM and vanishes when the container stops
tmpfs:
- /var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "validator", "-d", "spatial_qc"]
interval: 5s
timeout: 3s
retries: 10
'''
Path("docker-compose.validation.yml").write_text(COMPOSE.lstrip())
print("wrote docker-compose.validation.yml")
Verification: run docker compose -f docker-compose.validation.yml up -d, then docker compose -f docker-compose.validation.yml ps. The service must reach state healthy. Because the data directory is tmpfs, docker compose down followed by up re-runs the init SQL from scratch — confirm by watching the init logs appear on each start.
Step 4 — Load data and run the validation gate
With the database healthy, load the dataset and query the invalid count. A tiny Python client turns the count into an exit code the CI runner reads as pass or fail. psycopg connects only after the healthcheck has passed, so no retry loop is needed in the common case.
# scripts/run_gate.py — load a GeoPackage and fail if any geometry is invalid
import subprocess
import sys
import psycopg
DSN = "host=localhost port=5432 dbname=spatial_qc user=validator password=pw"
def load(gpkg: str) -> None:
# ogr2ogr streams the layer into the parcels table, reprojecting to 4326
subprocess.run(
[
"ogr2ogr", "-f", "PostgreSQL",
f"PG:{DSN}", gpkg,
"-nln", "parcels", "-t_srs", "EPSG:4326",
"-lco", "GEOMETRY_NAME=geom", "-append",
],
check=True,
)
def gate() -> int:
with psycopg.connect(DSN) as conn:
invalid = conn.execute("SELECT count_invalid_parcels();").fetchone()[0]
if invalid:
rows = conn.execute(
"SELECT parcel_id, reason FROM invalid_parcels LIMIT 20;"
).fetchall()
print(f"{invalid} invalid geometry(ies) — validation gate failed:")
for parcel_id, reason in rows:
print(f" - {parcel_id}: {reason}")
return 1
print("All geometries valid — gate passed")
return 0
if __name__ == "__main__":
load(sys.argv[1] if len(sys.argv) > 1 else "data/parcels.gpkg")
raise SystemExit(gate())
Verification: run python scripts/run_gate.py data/parcels.gpkg. On clean data it prints the pass line and exits 0; load a GeoPackage containing a self-intersecting polygon and it exits 1, listing the offending parcel_id and the ST_IsValidReason string. That non-zero exit is what fails the CI step.
Interpreting Results
The invalid_parcels view is the diagnostic surface. Each row pairs a parcel_id with a GEOS reason string — Self-intersection, Ring Self-intersection, Holes are nested — that maps directly to a repair strategy. Querying the view rather than a bare boolean means a failing gate arrives with actionable detail already attached: which features, and why.
The count_invalid_parcels() function collapses that detail to one number for the gate decision. Zero is a pass; anything positive fails the build. Keeping the count and the detail in the same schema lets one query drive the exit code while another produces the report you attach as a CI artifact, exactly as the parent CI guide describes.
Because the container is ephemeral, every result is a statement about this run’s data on this pinned engine — never contaminated by a prior job. If a geometry that failed yesterday passes today, the cause is the data or a deliberate image bump, not accumulated state. That determinism is the entire reason to prefer a throwaway container over a shared database.
Gotchas & Edge Cases
A named volume defeats the purpose. If you mount a persistent volume at /var/lib/postgresql/data, the init SQL runs only on the very first start and never again — later runs inherit stale tables and skip your validation view updates. Use tmpfs (or no volume at all) so the data directory is empty on every run and initialization is guaranteed to re-execute.
Init SQL runs once per empty data directory, in filename order. The numeric prefixes (00_, 10_) are load-bearing: extensions must exist before views reference them. A view that queries a table created in a later-sorted file fails at startup. Order your init files deliberately.
Skipping the healthcheck causes connection-refused flakes. PostgreSQL reports ready before it truly accepts your database’s connections, and PostGIS init adds latency. Loading data without waiting for healthy produces intermittent connection refused failures that look like a network problem but are a race. Always gate the load step on the healthcheck.
SRID mismatches silently break metric checks. ST_IsValid runs regardless of SRID, but if ogr2ogr loads geometries in a different projection than the column expects, area and distance checks downstream return nonsense. Enforce the SRID in the column type (geometry(MultiPolygon, 4326)) and reproject on load with -t_srs.
ogr2ogr availability varies. The GDAL ogr2ogr binary is not part of the PostGIS server image itself. Run the loader from a companion GDAL image or install it on the runner; do not assume the database container can load its own files.
When to Escalate
An ephemeral PostGIS container is ideal for reproducible, database-backed validation in CI. Reach beyond it when:
- You need to orchestrate the container inside a hosted CI workflow. Attach it as a service and drive the load and gate steps from Running Spatial Validation in GitHub Actions, which wires the healthcheck and matrix around this database.
- Validation logic outgrows a single view. When checks multiply into interacting business rules, move the logic into a maintained rule layer — Building Rule Engines with GeoPandas shows the pattern — and call PostGIS only for the set-level predicates it is best at.
- Datasets exceed what fits in a tmpfs container. For very large layers, a RAM-backed data directory runs out of memory. Move to a dedicated, still-disposable database instance on larger infrastructure, or partition the validation, keeping the ephemeral-per-run principle intact.
- Checks span the whole validation pipeline. When database validation is one stage among ingestion, remediation, and reporting, return to the Validation Pipeline Architecture overview to see where this container fits in the larger directed acyclic graph.
Related:
- Continuous Integration for Spatial Validation — the parent guide this container backs as its database layer
- Running Spatial Validation in GitHub Actions — attaching this container as a CI service and gating merges
- Validation Pipeline Architecture — where database-backed validation sits in the wider pipeline