Deploying PostGIS on Kubernetes with Persistent Volumes
A step-by-step procedure for provisioning durable, correctly-permissioned Persistent Volumes for a PostGIS instance on Kubernetes, from storage class selection through initialization to graceful shutdown.
This guide is a hands-on companion to Kubernetes StatefulSets for PostGIS Databases and sits within the wider Infrastructure Orchestration & Configuration Management practice; read the parent page first if you need the controller-level rationale for why a spatial database must never run from a stateless Deployment. Here the focus is narrow and operational: how to bind storage that survives rescheduling, how to hand it to PostgreSQL with the right ownership, and how to verify the result before any spatial data lands on it.
Prerequisites
Before applying any manifest, confirm the following are in place. Each is a common root cause of a failed first boot.
- Kubernetes 1.27+ with a CSI driver that supports dynamic provisioning and
WaitForFirstConsumerbinding (e.g.ebs.csi.aws.com,pd.csi.storage.gke.io, or Cephrbd.csi.ceph.com). kubectlaccess to a namespace you can write to — examples below use-n geoportal.- A fast block
StorageClass(NVMe/SSD-backed); GiST index builds rely on synchronous writes and degrade badly on throttled storage. - The official
postgis/postgis:16-3.4image (or a hardened mirror), which runs as UID/GID999:999. - Cluster RBAC that permits creating
StorageClass,StatefulSet,Service,ConfigMap, andPodDisruptionBudgetobjects. If you manage tenancy with database roles, align them with the model in Implementing RBAC for Multi-Tenant GIS Portals. - Decide the volume size up front (
resources.requests.storage); most CSI drivers allow expansion but never shrinking.
The bootstrap sequence below shows the deterministic path from storage binding through initialization to readiness, and the graceful-shutdown hook that protects data on termination.
Step-by-step implementation
1. Define a topology-aware StorageClass
Selecting volumeBindingMode: WaitForFirstConsumer prevents premature volume allocation in the wrong availability zone — the volume is provisioned only once the scheduler has placed the pod, so compute and storage always co-locate. allowVolumeExpansion: true leaves room to grow as spatial tables accumulate.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: postgis-fast
provisioner: ebs.csi.aws.com # swap for your CSI driver
parameters:
type: gp3
iops: "6000"
throughput: "500"
fsType: ext4
reclaimPolicy: Retain # keep the volume if the PVC is deleted
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain is deliberate: it stops an accidental kubectl delete pvc from destroying spatial data. If you provision storage classes from infrastructure code, keep them in the same workflow you use for the rest of the platform — see Syncing GeoNode Environments with Terraform.
2. Carry the volume in volumeClaimTemplates
The StatefulSet’s volumeClaimTemplates mints one PVC per ordinal and rebinds the same volume whenever the pod reschedules. Use ReadWriteOnce; reserve ReadOnlyMany for snapshot-backed read replicas only when your CSI driver explicitly supports concurrent spatial reads.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgis
namespace: geoportal
spec:
serviceName: postgis-headless
replicas: 1
podManagementPolicy: OrderedReady # sequential bootstrap of the primary
selector:
matchLabels: { app: postgis }
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: postgis-fast
resources:
requests:
storage: 100Gi
3. Run the container as the postgres user
A securityContext that matches the image’s UID/GID stops the classic initdb: could not access directory and data directory has invalid permissions crash loops. fsGroup makes the kubelet chown the mounted volume so PostgreSQL owns its own data directory.
template:
metadata:
labels: { app: postgis }
spec:
securityContext:
runAsUser: 999
runAsGroup: 999
fsGroup: 999 # kubelet chowns the PVC to gid 999
runAsNonRoot: true
terminationGracePeriodSeconds: 300
containers:
- name: postgis
image: postgis/postgis:16-3.4
ports: [{ containerPort: 5432, name: postgresql }]
env:
- name: POSTGRES_DB
value: geoportal
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: postgis-secret, key: password }
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: initdb
mountPath: /docker-entrypoint-initdb.d
readOnly: true
Setting PGDATA to a subdirectory of the mount (/pgdata) rather than the mount root sidesteps the lost+found directory that some ext4 volumes create, which otherwise makes initdb refuse to run.
4. Mount extension bootstrap as a ConfigMap
Scripts in /docker-entrypoint-initdb.d run once, on an empty data directory. Install the spatial extensions and lock down the public schema before any data is loaded.
apiVersion: v1
kind: ConfigMap
metadata:
name: postgis-initdb
namespace: geoportal
data:
01-extensions.sql: |
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_raster;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
Reference it from the pod’s volumes: list, mounting the ConfigMap with defaultMode: 0440. For first-run precedence and environment-variable handling, the official PostgreSQL database initialization reference is authoritative.
5. Probe for readiness and shut down cleanly
The readiness probe must gate traffic on the database actually accepting spatial queries, not merely on the TCP port being open. The preStop hook issues a fast checkpoint so SIGTERM never interrupts a half-flushed WAL segment.
readinessProbe:
exec:
command: ["pg_isready", "-U", "postgres", "-d", "geoportal"]
initialDelaySeconds: 15
periodSeconds: 10
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- pg_ctl -D "$PGDATA" stop -m fast
Combined with terminationGracePeriodSeconds: 300, this gives PostgreSQL time to checkpoint, flush dirty buffers, and finalize WAL archiving before the kubelet escalates to SIGKILL — avoiding the corrupted heap pages that otherwise force a manual pg_resetwal.
6. Guard availability during maintenance
A PodDisruptionBudget stops a node drain from evicting the only primary mid-transaction.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: postgis-pdb
namespace: geoportal
spec:
maxUnavailable: 0
selector:
matchLabels: { app: postgis }
Connection routing belongs in front of this tier: front every client with a pooler such as PgBouncer rather than opening direct 5432 sessions, and size the pool against the server’s ceiling as described in Optimizing PostgreSQL/PostGIS Connection Limits.
Verification
Confirm each layer bound and initialized correctly before handing the database to an application.
# 1. The PVC is Bound, not Pending, and uses the expected StorageClass
kubectl get pvc -n geoportal -l app=postgis
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# data-postgis-0 Bound pvc-... 100Gi RWO postgis-fast
# 2. The volume was provisioned in the same zone the pod landed in
kubectl get pv -o wide | grep postgis
# 3. The pod is Ready and the readiness probe passed
kubectl get pod postgis-0 -n geoportal -o wide
# 4. PostGIS extensions are installed and the SRS table is populated
kubectl exec -n geoportal postgis-0 -- \
psql -U postgres -d geoportal -c "SELECT postgis_full_version();"
kubectl exec -n geoportal postgis-0 -- \
psql -U postgres -d geoportal -c "SELECT count(*) FROM spatial_ref_sys;"
# 5. The data directory is owned by uid/gid 999
kubectl exec -n geoportal postgis-0 -- stat -c '%u:%g' /var/lib/postgresql/data/pgdata
A non-empty postgis_full_version() string and a spatial_ref_sys count in the thousands confirm the extensions and reference systems initialized. Wiring these checks into a pipeline gate keeps every promotion honest — the pattern lives in Environment Parity in Geospatial CI Pipelines.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
PVC stuck Pending, event waiting for first consumer never clears |
StorageClass zone topology cannot satisfy the pod’s placement | Confirm the CSI driver provisions in the pod’s zone; check kubectl describe pvc for VolumeBindingMode and node affinity |
Pod CrashLoopBackOff, log shows initdb: could not access directory |
Volume ownership does not match the container UID/GID | Set securityContext.fsGroup: 999; verify with stat -c '%u:%g' on $PGDATA |
initdb refuses: directory not empty |
Mounting at the volume root that already holds lost+found |
Point PGDATA at a subdirectory, e.g. /var/lib/postgresql/data/pgdata |
| Extensions missing after boot | Init scripts skipped because the data directory was already initialized | Init scripts run only on an empty PGDATA; run CREATE EXTENSION manually or reprovision a fresh PVC |
| Spatial queries time out under load | work_mem / maintenance_work_mem too low, or throttled storage |
Tune memory for geometry ops; confirm the PVC uses the fast StorageClass and the CSI driver honours fsync |
| WAL archive bloat fills the disk | archive_command failing or unset |
Set archive_mode = on with an S3/MinIO archive_command; watch pg_stat_archiver for consecutive failures |
| Data lost after pod deletion | reclaimPolicy: Delete on the StorageClass |
Recreate with reclaimPolicy: Retain; recover the orphaned PV by re-binding it to a new PVC |
For the controller-level guarantees these manifests depend on — ordinal identity, ordered startup, and one-to-one volume binding — consult the Kubernetes StatefulSet controller reference.
Related
- Kubernetes StatefulSets for PostGIS Databases — the controller-level rationale and replication topology this procedure plugs into.
- Optimizing PostgreSQL/PostGIS Connection Limits — sizing the pooler that fronts these volumes.
- Syncing GeoNode Environments with Terraform — provisioning storage classes and namespaces as code.
Up one level: Kubernetes StatefulSets for PostGIS Databases.