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 WaitForFirstConsumer binding (e.g. ebs.csi.aws.com, pd.csi.storage.gke.io, or Ceph rbd.csi.ceph.com).
  • kubectl access 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.4 image (or a hardened mirror), which runs as UID/GID 999:999.
  • Cluster RBAC that permits creating StorageClass, StatefulSet, Service, ConfigMap, and PodDisruptionBudget objects. 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.

PostGIS bootstrap sequence from storage binding to graceful shutdown A StorageClass with WaitForFirstConsumer binding mints a PVC with ReadWriteOnce access. The PVC is carried into a scheduled pod whose securityContext runs as uid and gid 999. On an empty data directory the init scripts create the postgis and postgis_raster extensions, load the spatial reference systems, and lock down roles. The readiness probe then gates traffic: only when healthy does the pod serve spatial queries. On termination, a SIGTERM triggers the preStop hook which runs pg_ctl stop in fast mode and finalizes WAL archiving before SIGKILL. binds 1:1 empty PGDATA only healthy StorageClass WaitForFirstConsumer · Retain PVC bound ReadWriteOnce · volumeClaimTemplates Pod scheduled securityContext 999:999 · fsGroup Init scripts postgis · postgis_raster · SRS · roles Readiness probe pg_isready gates the endpoint Serve spatial queries added to headless Service endpoints SIGTERM preStop hook pg_ctl stop -m fast WAL archive flush before SIGKILL

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.

Why WaitForFirstConsumer keeps pod and volume in the same zone On the left, with WaitForFirstConsumer binding, the scheduler places the pod first, then the CSI driver provisions the volume in the pod's zone. Pod, PVC with Retain reclaim policy, and CSI volume all sit inside availability zone A, bound one to one, so the PVC status is Bound. On the right, with Immediate binding, the volume is provisioned in zone A before scheduling while the pod lands in zone B. The cross-zone node affinity cannot be satisfied, so the PVC never binds and the pod stays Pending with the event waiting for first consumer. WaitForFirstConsumer storage follows the pod into its zone Availability zone A Pod postgis-0 scheduled first PVC · Retain ReadWriteOnce CSI volume provisioned in zone A PVC Bound Immediate binding volume pinned before the pod is placed Zone B Pod postgis-0 lands here Zone A CSI volume pre-provisioned node affinity unsatisfiable PVC Pending

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.

Up one level: Kubernetes StatefulSets for PostGIS Databases.