Containerizing GeoServer with Docker and Python Provisioning
Running GeoServer against a PostGIS database is straightforward on a developer laptop and unrecognisable in production, and that drift is where most spatial-publishing incidents are born. Pinning both services in a docker-compose stack and provisioning them from a single idempotent Python script collapses that gap: the same images, the same layer definitions, and the same REST calls run identically in dev, CI, and production.
TL;DR: Declare GeoServer and PostGIS as pinned services in docker-compose.yml with volumes and healthchecks, start the stack, then run a Python script that polls the GeoServer REST /about/version endpoint with exponential backoff until it is ready and creates the workspace, PostGIS store, and layer via requests. Inject every credential from the environment so the identical script provisions all environments.
The Core Challenge
The obstacle is not writing the REST calls — it is timing them. docker compose up returns as soon as the container process starts and the port binds, but GeoServer keeps initialising its data directory, security subsystem, and servlet context for tens of seconds afterwards. Any workspace or datastore request fired into that window fails with a connection reset, an HTTP 503, or an authentication error against a security realm that has not finished loading. A fixed sleep 30 is both too slow on a warm machine and too fast on a cold CI runner.
The second challenge is reproducibility. If the compose file pulls :latest, two runs weeks apart can resolve to different GeoServer builds with subtly different REST semantics, so a provisioning script that passed yesterday fails today for reasons unrelated to your code. And because provisioning touches admin credentials and a database password, those secrets must never be baked into the image or committed to the repository. This page addresses all three: readiness detection, pinned reproducibility, and environment-sourced secrets. Provisioning the layers themselves against a live database is covered in Syncing PostgreSQL/PostGIS Layers with GeoServer via Python.
Production-Ready Code
Two artefacts make up the stack: a docker-compose.yml that pins both images and declares healthchecks, and a Python provisioner that waits for readiness and then creates the workspace, store, and layer. Every credential is interpolated from the environment — the compose file uses ${VAR} syntax and the script uses os.environ, so no secret is ever committed or baked into a layer.
# docker-compose.yml — pinned GeoServer + PostGIS stack.
# Secrets come from an untracked .env file (or CI secret store), never from git.
services:
postgis:
image: postgis/postgis:16-3.4 # pinned: major PostgreSQL + PostGIS versions
environment:
POSTGRES_DB: ${POSTGIS_DB:-gis}
POSTGRES_USER: ${POSTGIS_USER}
POSTGRES_PASSWORD: ${POSTGIS_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data # persistent data volume
- ./seed:/docker-entrypoint-initdb.d:ro # seeded SQL runs on first init only
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGIS_USER} -d ${POSTGIS_DB:-gis}"]
interval: 10s
timeout: 5s
retries: 10
geoserver:
image: docker.osgeo.org/geoserver:2.25.2 # pinned GeoServer release
depends_on:
postgis:
condition: service_healthy # start only once PostGIS is accepting connections
environment:
GEOSERVER_ADMIN_USER: ${GEOSERVER_ADMIN_USER}
GEOSERVER_ADMIN_PASSWORD: ${GEOSERVER_ADMIN_PASSWORD}
SKIP_DEMO_DATA: "true" # deterministic, empty data directory
ports:
- "8080:8080"
volumes:
- geoserver_data:/opt/geoserver_data # persistent catalog outside the image
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/geoserver/web/ || exit 1"]
interval: 15s
timeout: 5s
retries: 20
volumes:
pgdata:
geoserver_data:
"""
provision_geoserver.py — reproducible provisioning against the compose stack.
Dependencies (pip install):
requests>=2.31
Every connection detail and credential is read from the environment, so the
same script provisions dev, CI, and production without any code change.
"""
from __future__ import annotations
import os
import sys
import time
import requests
from requests.auth import HTTPBasicAuth
# --- Configuration: sourced entirely from the environment ------------------
GEOSERVER_URL = os.environ.get("GEOSERVER_URL", "http://localhost:8080/geoserver")
GEOSERVER_USER = os.environ["GEOSERVER_ADMIN_USER"]
GEOSERVER_PASS = os.environ["GEOSERVER_ADMIN_PASSWORD"]
WORKSPACE = os.environ.get("GEOSERVER_WORKSPACE", "parity")
STORE = os.environ.get("GEOSERVER_STORE", "postgis")
LAYER = os.environ.get("GEOSERVER_LAYER", "roads")
PG_HOST = os.environ.get("POSTGIS_HOST", "postgis") # the compose service name
PG_PORT = os.environ.get("POSTGIS_PORT", "5432")
PG_DB = os.environ.get("POSTGIS_DB", "gis")
PG_USER = os.environ["POSTGIS_USER"]
PG_PASS = os.environ["POSTGIS_PASSWORD"]
AUTH = HTTPBasicAuth(GEOSERVER_USER, GEOSERVER_PASS)
XML = {"Content-Type": "text/xml"}
def wait_until_ready(base_url: str, timeout: float = 180.0) -> None:
"""Poll /rest/about/version until it answers 200, backing off exponentially."""
version_url = f"{base_url}/rest/about/version.json"
deadline = time.monotonic() + timeout
delay, attempt = 1.0, 0
while time.monotonic() < deadline:
attempt += 1
try:
resp = requests.get(version_url, auth=AUTH, timeout=5)
if resp.status_code == 200:
ver = resp.json()["about"]["resource"][0].get("Version", "unknown")
print(f"GeoServer ready after {attempt} attempt(s); version {ver}")
return
print(f"attempt {attempt}: HTTP {resp.status_code}; retry in {delay:.0f}s")
except requests.RequestException as exc:
print(f"attempt {attempt}: {exc.__class__.__name__}; retry in {delay:.0f}s")
time.sleep(delay)
delay = min(delay * 2, 15.0) # cap the backoff interval
raise TimeoutError(f"GeoServer at {base_url} not ready within {timeout:.0f}s")
def _exists(url: str) -> bool:
"""Return True if a REST resource already exists (idempotency guard)."""
return requests.get(url, auth=AUTH, timeout=10).status_code == 200
def ensure_workspace(base_url: str, name: str) -> None:
root = f"{base_url}/rest/workspaces"
if _exists(f"{root}/{name}.json"):
print(f"workspace {name!r} already exists")
return
body = f"<workspace><name>{name}</name></workspace>"
requests.post(root, data=body, headers=XML, auth=AUTH, timeout=10).raise_for_status()
print(f"created workspace {name!r}")
def ensure_postgis_store(base_url: str, workspace: str, store: str) -> None:
root = f"{base_url}/rest/workspaces/{workspace}/datastores"
if _exists(f"{root}/{store}.json"):
print(f"datastore {store!r} already exists")
return
body = f"""
<dataStore>
<name>{store}</name>
<connectionParameters>
<host>{PG_HOST}</host>
<port>{PG_PORT}</port>
<database>{PG_DB}</database>
<user>{PG_USER}</user>
<passwd>{PG_PASS}</passwd>
<dbtype>postgis</dbtype>
</connectionParameters>
</dataStore>
""".strip()
requests.post(root, data=body, headers=XML, auth=AUTH, timeout=10).raise_for_status()
print(f"created PostGIS datastore {store!r}")
def publish_layer(base_url: str, workspace: str, store: str, table: str) -> None:
root = f"{base_url}/rest/workspaces/{workspace}/datastores/{store}/featuretypes"
if _exists(f"{root}/{table}.json"):
print(f"layer {table!r} already published")
return
body = f"<featureType><name>{table}</name></featureType>"
requests.post(root, data=body, headers=XML, auth=AUTH, timeout=10).raise_for_status()
print(f"published layer {table!r}")
def main() -> int:
wait_until_ready(GEOSERVER_URL)
ensure_workspace(GEOSERVER_URL, WORKSPACE)
ensure_postgis_store(GEOSERVER_URL, WORKSPACE, STORE)
publish_layer(GEOSERVER_URL, WORKSPACE, STORE, LAYER)
print("provisioning complete")
return 0
if __name__ == "__main__":
sys.exit(main())
Step-by-Step Walkthrough
Pinned images and the seed directory. The postgis service pins postgis/postgis:16-3.4 and geoserver pins docker.osgeo.org/geoserver:2.25.2, so every environment resolves the same binaries. The ./seed bind mount maps host SQL files into /docker-entrypoint-initdb.d, which the PostGIS entrypoint executes only on first initialisation of an empty pgdata volume — giving CI a deterministic, known dataset to publish. SKIP_DEMO_DATA keeps the GeoServer catalog empty so nothing but your provisioning script defines its contents.
Ordered startup via healthchecks. The postgis healthcheck runs pg_isready, and GeoServer’s depends_on uses condition: service_healthy so the container will not start until the database accepts connections. This removes one class of race — GeoServer attempting to reach a database that is still initialising — but it deliberately does not solve the GeoServer readiness problem, which the Python poll handles.
The exponential-backoff wait loop. wait_until_ready requests /rest/about/version.json and treats only an HTTP 200 as success. On any other status or a requests.RequestException (connection refused while the servlet context loads), it sleeps delay seconds and doubles it, capped at 15 s by min(delay * 2, 15.0). Using time.monotonic() for the deadline makes the timeout immune to wall-clock adjustments. Reading the reported Version from the JSON body both confirms the API is live and records which build actually answered — useful evidence in CI logs.
Idempotent create steps. Each ensure_* function first calls _exists, which issues a GET and checks for a 200, and returns early if the resource is already present. This makes the whole script safe to re-run: a second execution against a provisioned server prints “already exists” for every step and changes nothing. Idempotency is what lets the identical script run on a fresh CI database and against a long-lived production catalog. The workspace, PostGIS dataStore, and featureType are created with minimal XML bodies through the standard GeoServer REST endpoints, the same interface described in Automating GeoServer with the Python REST API.
Environment-sourced secrets. Required secrets use os.environ["..."] so a missing value fails fast with a KeyError at startup rather than sending an empty password to the server. Non-secret settings use os.environ.get with a sensible default. The PostGIS host defaults to postgis — the compose service name resolvable on the shared Docker network — so the same script works whether it runs inside a provisioning container or from the host with POSTGIS_HOST=localhost.
Verification
Provide the secrets through an untracked .env file, bring the stack up, then run the provisioner and confirm the layer is advertised by GeoServer’s own WMS GetCapabilities.
docker compose up -d
GEOSERVER_ADMIN_USER=admin GEOSERVER_ADMIN_PASSWORD=***** \
POSTGIS_USER=gis POSTGIS_PASSWORD=***** POSTGIS_HOST=localhost \
python provision_geoserver.py
curl -s "http://localhost:8080/geoserver/wms?SERVICE=WMS&REQUEST=GetCapabilities" \
| grep -o "<Name>parity:roads</Name>"
Expected output:
GeoServer ready after 4 attempt(s); version 2.25.2
created workspace 'parity'
created PostGIS datastore 'postgis'
published layer 'roads'
provisioning complete
<Name>parity:roads</Name>
The final grep match confirms the layer is not merely registered in the catalog but genuinely published through WMS. A parallel check against WFS is worth wiring into automated pipelines — see CI/CD and Compliance Testing for Spatial Services for turning this smoke test into a gated build step.
Gotchas & Edge Cases
Why poll the GeoServer REST endpoint instead of relying on docker-compose depends_on?
A container healthcheck and depends_on only guarantee the process has started and the port is open. GeoServer continues initialising its data directory, security subsystem, and servlet context for tens of seconds after the port binds. Provisioning requests sent during that window fail with connection resets or HTTP 503. Polling /rest/about/version until it returns 200 proves the REST API is genuinely serving before any workspace or store call is issued. The GeoServer web/ healthcheck in the compose file is a coarse liveness signal; the Python poll is the precise readiness gate.
How do I keep GeoServer and PostGIS credentials out of the image and out of git?
Reference every secret with ${VAR} interpolation in docker-compose.yml and supply the values from an untracked .env file locally or from the secret store of your CI or orchestration platform in automation. The Python provisioner reads the same values with os.environ, so no password is ever written into a committed file or a Docker layer. Add .env to .gitignore, and never pass secrets as build args, which are recorded in image history.
Why pin exact image tags instead of using latest?
The :latest tag is a moving target: a rebuild months apart can pull different GeoServer or PostGIS versions with changed REST behaviour or PostGIS functions, breaking reproducibility across dev, CI, and production. Pinning docker.osgeo.org/geoserver:2.25.2 and postgis/postgis:16-3.4 guarantees every environment resolves the identical binaries. For byte-level immutability, pin the image digest (image@sha256:...) as well, and bump both deliberately as a reviewed change.
How do I make the same provisioning script run unchanged in CI and production?
Keep the script free of any environment-specific literals. The GeoServer URL, admin credentials, PostGIS connection parameters, and layer names all come from the environment, and every create step is idempotent, so a re-run against an already-provisioned server is a no-op. The only thing that differs between dev, CI, and production is the set of environment variables supplied to the run — the code path is identical, which is the whole point of environment parity.
Back to Environment Parity for Spatial Servers
Related
- Environment Parity for Spatial Servers — the wider strategy for keeping dev, CI, and production spatial stacks identical
- Syncing PostgreSQL/PostGIS Layers with GeoServer via Python — reconciling database tables with the GeoServer catalog once the stack is running
- CI/CD and Compliance Testing for Spatial Services — promoting the provisioning smoke test into a gated build pipeline