Provisioning Spatial Infrastructure with Terraform
Spatial infrastructure has one property that ordinary application infrastructure does not: a PostGIS instance without the right extensions, a bucket without the right lifecycle rules, or a worker image without the right PROJ grid package will provision successfully and then produce silently wrong coordinates. This guide provisions a domain’s spatial stack declaratively, with the spatial-specific assertions that catch those cases at apply time rather than in a consumer’s data. It is the infrastructure layer beneath Self-Serve Platform Capabilities for Spatial Teams within Geospatial Data Mesh Fundamentals, and it is what the template in Building a Golden Path Template for New Domains runs on make apply.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | terraform ≥ 1.7, psql ≥ 14, projinfo (PROJ ≥ 9) |
projinfo verifies the grid package |
| PostGIS | ≥ 3.3 with postgis, postgis_raster, postgis_topology |
Extensions are not installed by default |
| PROJ grids | The datum grid package the domain’s transformation needs | Shipped in the image, never host-installed |
| State backend | Remote, locked, per domain | Two domains sharing state is a coordination bottleneck |
| Access roles | domain-owner applies; platform-engineer owns the module |
Domains consume the module, not the resources |
| Environment | TF_VAR_domain, TF_VAR_region, TF_VAR_storage_crs |
Residency comes from the region variable |
Per-domain state is not optional. Shared state means two domains applying concurrently block each other, which reintroduces exactly the coordination cost federation removed.
Step-by-Step Implementation
1. Declare the domain’s store with its extensions asserted
Provisioning a database is easy; provisioning one that is actually usable for spatial work requires the extensions, and their absence is not detectable until a query fails.
# main.tf — one domain's spatial store, with the spatial prerequisites asserted.
variable "domain" { type = string }
variable "region" { type = string }
variable "storage_crs" { type = string, default = "EPSG:4326" }
resource "postgres_instance" "domain_store" {
name = "gis-${var.domain}"
region = var.region # residency is an input, not a default
engine_version = "16"
storage_gb = 512
backup_retention = 30
# Spatial workloads are memory- and IO-bound rather than CPU-bound; the shared
# buffer and work_mem settings below are what make a GiST index actually resident.
parameters = {
shared_buffers = "8GB"
work_mem = "128MB"
maintenance_work_mem = "2GB"
max_parallel_workers = "8"
random_page_cost = "1.1" # SSD-backed; the default assumes spinning disks
}
}
resource "postgres_extension" "spatial" {
for_each = toset(["postgis", "postgis_raster", "postgis_topology"])
instance = postgres_instance.domain_store.id
name = each.value
}
Verify the extensions are present and the version supports the functions the quality gate uses:
psql -h "$(terraform output -raw store_host)" -c \
"SELECT extname, extversion FROM pg_extension WHERE extname LIKE 'postgis%';"
psql -h "$(terraform output -raw store_host)" -c "SELECT PostGIS_Full_Version();" \
| grep -q 'PROJ' && echo "PROJ is linked into PostGIS"
2. Pin the PROJ grid package into the worker image
This is the assertion that most infrastructure code omits and that most silently-wrong-coordinate incidents trace back to.
# workers.tf — the pipeline image, with its datum grids pinned as a build input.
variable "proj_grid_package" {
type = string
description = "Versioned datum grid package. Never resolved at runtime."
# A floating tag here makes reprojection results depend on when the image was built.
default = "proj-grids:2024.2"
}
resource "container_image" "pipeline" {
name = "gis-${var.domain}-pipeline"
build_args = {
PROJ_GRID_PACKAGE = var.proj_grid_package
PROJ_VERSION = "9.4.0"
}
# The build fails if the declared transformation cannot be resolved from the
# packaged grids — catching at build time what would otherwise be a field offset.
postbuild_check = "projinfo -s EPSG:32633 -t EPSG:4326 --grid-check known | grep -q 'available'"
}
Verify the packaged grids resolve the domain’s declared transformation, inside the image rather than on your laptop:
docker run --rm "$(terraform output -raw pipeline_image)" \
projinfo -s EPSG:32633 -t EPSG:4326 --grid-check known
# Every candidate transformation must report the grid as available, not "not found".
3. Provision object storage with lifecycle and residency
Spatial artifacts are large, tiered, and jurisdictionally constrained, and all three are storage-layer decisions.
# storage.tf — artifact storage, with tiering and a residency assertion.
resource "object_bucket" "artifacts" {
name = "gis-${var.domain}-artifacts"
region = var.region
versioning = true # immutable versions require it
lifecycle_rule {
id = "tier-cold-rasters"
prefix = "raster/"
transition { days = 90, storage_class = "COLD" }
# No expiration: a published version stays addressable for reproducibility.
}
lifecycle_rule {
id = "expire-intermediates"
prefix = "intermediate/"
expiration { days = 30 } # result-persistence artifacts, not products
}
}
# Residency is asserted, not assumed: a bucket outside the declared region is a breach.
check "residency" {
assert {
condition = object_bucket.artifacts.region == var.region
error_message = "artifact bucket region must equal the domain's declared residency"
}
}
Verify the intermediate expiry actually applies, since a misprefixed rule silently retains everything:
aws s3api get-bucket-lifecycle-configuration \
--bucket "$(terraform output -raw artifact_bucket)" \
| jq '.Rules[] | {id: .ID, prefix: .Filter.Prefix, expiry: .Expiration.Days}'
4. Plan, apply, and prove the apply is idempotent
# Plan is read-only and always safe; a domain runs it freely.
terraform plan -detailed-exitcode
# exit 0 = no changes, 2 = changes pending, 1 = error
terraform apply -auto-approve
# The second apply must be a no-op. A non-empty plan on unchanged input means a
# resource is computing a value at apply time rather than declaring it.
terraform plan -detailed-exitcode && echo "provisioning is idempotent"
Configuration Reference
| Variable | Scope | Default | Effect |
|---|---|---|---|
domain |
Naming, isolation | none | Prefixes every resource; the isolation boundary |
region |
Residency | none | Store, bucket and workers all inherit it |
storage_crs |
Contract | EPSG:4326 |
Recorded on the store; the quality gate asserts it |
proj_grid_package |
Reprojection | Pinned version | A floating tag makes coordinates build-time dependent |
shared_buffers |
PostGIS | 8GB |
GiST indexes resident in memory |
work_mem |
PostGIS | 128MB |
Spatial joins spill to disk below this |
random_page_cost |
PostGIS | 1.1 |
The default assumes spinning disks and suppresses index use |
| Intermediate expiry | Storage | 30 days |
Result-persistence artifacts, never products |
| Raster cold transition | Storage | 90 days |
Check retrieval latency against the product SLO first |
Common Failure Modes & Fixes
Queries that used an index in staging do a sequential scan in production.
Root cause: random_page_cost left at the default of 4.0, which tells the planner that random IO is four times as expensive as sequential — true for spinning disks, badly wrong for SSD-backed storage, and enough to suppress GiST index use on large tables. Fix: set it to 1.1 as above and re-check the plan.
Reprojection results differ between two workers. Root cause: the grid package is resolved at runtime rather than baked into the image, so workers built at different times hold different grids and PROJ selects different transformations. Fix: pin the package as a build argument and assert its presence in a post-build check.
Terraform apply succeeds; the first spatial query fails.
Root cause: the instance provisioned without the PostGIS extensions, which are not installed by default on most managed engines. Fix: the postgres_extension resources above, plus the verification query — a store without them is not a spatial store.
The second apply is never a no-op. Root cause: a resource whose value is computed at apply time — a timestamp, a generated password, an unpinned image tag. Fix: pin it or move it to a data source; non-idempotent provisioning makes every re-run a change and destroys the drift signal.
Cold-tiered rasters break a product’s latency SLO. Root cause: a lifecycle transition applied without checking retrieval latency against the product’s commitment. Fix: transition only products whose SLO tolerates first-byte latency in seconds, and record the tier in the catalog so consumers can see it.
FAQ
Why per-domain state rather than one workspace for the estate?
Because shared state serialises applies, and serialised applies are a coordination bottleneck with a queue. Two domains provisioning concurrently against one state file block each other on the lock, and a long apply by one domain stalls every other. Per-domain state removes that entirely, at the cost of some duplication in the module call — which is the right trade, because the duplication is mechanical and the blocking is organisational. It also means one domain’s broken state cannot prevent another from deploying, which matters more than it sounds like it should.
Should the module create the store, or should domains bring their own?
Both, and the escape hatch matters. The module should be the easy path and should produce a correctly configured store — extensions installed, planner tuned, residency asserted — because most domains have no reason to differ and every reason not to spend a week discovering random_page_cost. A domain with a genuine constraint, an existing instance or a specialised engine, should be able to register their store’s connection details and skip provisioning entirely, provided the output port and contract are unchanged. What must not happen is the module being mandatory, because a domain that cannot decline it will work around it in ways nobody can see.
How do I handle a datum grid package update?
As a version bump with a re-measurement, not as a routine dependency update. A new grid package can change coordinates by centimetres to metres, which means artifacts produced before and after are not interchangeable — so the package version is part of the pipeline version, bumping it invalidates the cache keys of everything downstream, and the affected partitions re-run deliberately. Where positional accuracy is published, it is re-measured against the control set afterwards, since the change may improve or degrade it and neither should be assumed.
Does the region variable really cover residency?
It covers the infrastructure a domain provisions and nothing else. Edge caches, cross-region replicas, backup destinations and monitoring exports are all storage too, and each has its own region setting that a naive module leaves at a global default. The check block above asserts one bucket; a complete residency posture asserts every resource that holds data, and the honest position is that this needs a per-resource review rather than one variable that appears to cover it.
Related
- Self-Serve Platform Capabilities for Spatial Teams — the parent topic and the capability set
- Building a Golden Path Template for New Domains — the template that runs this on
make apply - CRS Governance and Reprojection Standards — why the grid package is pinned rather than installed