Module Design Patterns for Spatial Infrastructure as Code

Reusable modules are where spatial infrastructure either becomes a predictable platform or degenerates into copy-pasted drift. A geospatial estate spans vector and raster storage, spatial databases, tile-serving compute, and egress-heavy delivery — and each of those resources is provisioned dozens of times across regions, tenants, and lifecycle stages. Without disciplined module interfaces, every duplication is a new opportunity for configuration drift, an unpinned provider, or an unencrypted bucket. This guide sits within Spatial IaC Architecture & Fundamentals and operationalizes the module patterns that keep that estate reproducible. The decisions here compound directly with the State Backend Selection strategy your modules write into, and with the orchestration-engine trade-offs analyzed in Terraform vs Pulumi for GIS. The objective is a small library of composable components that DevOps, GIS platform, and SaaS delivery teams can promote across environments with environment parity, automated validation, and policy guardrails baked into the interface.

Environment Parity and Configuration Drift Mitigation

Spatial platforms rarely run in a single environment. The same module provisions a development tile cache, a staging GeoServer fleet, and a production PostGIS deployment, and any divergence between them surfaces as a failed migration or a silently undersized storage class. The overlay pattern is the primary defense: environment-specific values — region, account, VPC CIDR, storage class, GIS service tier — live in thin overlay layers, while the module source stays identical across stages. A single source of truth, parameterized at promotion time, is what makes a staging-validated change trustworthy in production.

Three classes of drift are unique to geospatial modules and must be locked explicitly rather than left to defaults:

  • Extension and version matrix locking. A module that provisions PostGIS must pin the PostGIS, PostgreSQL, and (where relevant) pgRouting versions as explicit inputs, not inherit whatever the provider’s latest engine resolves to. A development overlay on PostGIS 3.4 promoting against a production cluster still on 3.3 will fail spatial index rebuilds. Treat the extension matrix the way How to Structure Terraform Modules for PostGIS treats engine pinning — as a required variable with validation.
  • Parameter group synchronization. postgresql.conf values such as shared_buffers, work_mem, and max_parallel_workers materially change spatial query plans. When overlays scale these per environment, they must scale by an explicit, validated ratio so a query plan that is index-only in staging does not become a sequential scan in production.
  • Promotion cycle risk. Each overlay must consume an isolated state path so a plan against staging can never enumerate or mutate production resources. State isolation is the structural guarantee behind parity; the same path discipline carries down into nested modules so they inherit consistent locking without manual override.

Because overlays only inject variables, the module’s behavior is a pure function of its inputs. That is what makes a diff in CI a faithful preview of what apply will do — the precondition for safe promotion across an estate that may carry tens of thousands of tiles per catalog.

CI/CD Validation and Operational Guardrails

Modules must be validated before they reach a backend, not audited after. A production-grade pull request workflow for spatial modules layers four gates, each blocking on failure:

  1. Static analysis. tflint and checkov (or tfsec) catch missing encryption, public ACLs, and unpinned providers before a plan is ever generated.
  2. Plan generation and policy evaluation. A dry-run plan feeds a policy engine — Terraform Sentinel, Pulumi CrossGuard, or Open Policy Agent — that asserts the geospatial-specific invariants below. Policy must run against the plan, not the live account, so non-compliant configurations are blocked before any cloud API call.
  3. Cost forecasting. A module’s inputs are parsed by a cost estimator so storage and egress are forecast at review time. Geospatial workloads are acutely egress- and storage-sensitive, which is why this gate is wired into the same pipeline as the Cost Estimation Frameworks the platform standardizes on, with hard thresholds that fail the build on a budget overrun.
  4. Spatial integrity checks. Where a module touches a live data tier, a pre-apply probe confirms spatial index health (for example, a REINDEX dry-run cost estimate or an EXPLAIN on a representative ST_Intersects query) so a schema change does not quietly invalidate a GiST index.

Rollback triggers belong in the module, not in a runbook. Stateful spatial services declare create_before_destroy so a database or tile-server replacement never opens a downtime window, and the pipeline treats any policy denial or cost-threshold breach as a hard stop that leaves the previous state untouched.

Resource Architecture and Service Integration

A spatial module library is layered, not monolithic. Foundational primitives sit at the base and stateful spatial services compose on top of them, each layer exposing a minimal, documented interface.

Layered Spatial Module Composition Stack Three stacked module layers. The base layer exposes networking primitives (VPC, subnets, NAT), IAM roles, and KMS keys. The data layer above it composes a PostGIS module and an object-storage module for raster and vector data, consuming the base layer's VPC ids, subnet ids, role ARNs, and KMS key ids. The delivery layer at the top composes GeoServer or tile compute and a CDN origin, consuming the data layer's endpoints and ARNs. Every upward edge carries only typed outputs, never a hard-coded resource reference. Delivery layer · stateful spatial services GeoServer / tile compute consumes tile-cache origin CDN origin consumes delivery endpoint endpoints · queue ARNs Data layer · stateful storage PostGIS cluster pinned extension matrix Object storage raster · vector buckets VPC id · subnet ids · role ARN · KMS key id Base layer · cloud primitives Networking VPC · subnets · NAT IAM roles scoped least-privilege KMS keys encryption at rest

Composable base modules

Base modules encapsulate the cloud primitives every geospatial workload needs: networking (VPCs, subnets, NAT gateways), storage (object buckets, managed databases, tile caches), and compute (serverless functions, container clusters, GPU instances). Each exposes a narrow contract. A raster ingestion module, for instance, should accept only a storage endpoint, a scoped IAM role, and a concurrency limit — abstracting provider differences so the implementation can be swapped without rewriting downstream configurations. This composability is what lets a PostGIS Cluster Provisioning module, a GeoServer Deployment Patterns module, and an Object Storage for Raster & Vector module be developed and unit-tested in parallel, then wired together by a thin composition root.

The upstream/downstream contract matters most at the seams. A raster ingestion module emits a bucket name and a queue ARN; a tile-generation module consumes them and emits a tile-cache origin; a delivery module consumes that origin. Each edge is a typed output, never a hard-coded resource reference reaching across module boundaries.

Concurrency is itself a geospatial-specific input. The wall-clock time to materialize a tile pyramid is governed by the worker pool the module provisions:

$$T_{\text{ingest}} \approx \frac{N_{\text{tiles}} \cdot t_{\text{tile}}}{W}$$

where N_tiles is the pyramid tile count, t_tile the mean per-tile render time, and W the worker concurrency. Because N_tiles grows geometrically with pyramid depth, the max_concurrent_workers bound is a first-class module variable with validated limits, not a runtime surprise — set it too low and a deep pyramid blows the pipeline timeout; too high and the storage API throttles.

State-aware dependency resolution

Spatial pipelines generate deep dependency graphs: a vector indexing service depends on a database cluster, which depends on a subnet group, which depends on a VPC. Implicit references across module boundaries cause state drift or provisioning deadlocks. The orchestration engine shapes the remedy — Terraform leans on explicit depends_on and implicit reference chaining, while Pulumi uses native Output chaining for asynchronous dependency tracking. To keep the provisioning graph acyclic in production:

  1. Flatten deep nesting by promoting shared networking and IAM primitives to a foundational layer.
  2. Use terraform_remote_state or Pulumi Stack References to decouple state consumption from resource provisioning.
  3. Declare create_before_destroy on stateful GIS databases to prevent downtime during schema migrations.
  4. Validate graph acyclicity (terraform graph | dot -Tsvg > graph.svg) before merging.

Shared primitives sit at the base of the graph; stateful spatial services sit at the top:

Spatial Module Dependency Graph (bottom-up) Shared cloud primitives sit at the base of the graph and stateful spatial services sit at the top. A VPC feeds a subnet group; the subnet group and IAM roles both feed a database cluster; the database cluster and an object-storage bucket both feed the vector indexing service. All edges point upward, keeping the provisioning graph acyclic. Vector indexing service Database cluster Subnet group VPC IAM roles Object storage stateful services (top) shared primitives (base)

Runnable Configuration

The following module interface is production-grade: providers are version-pinned, the concurrency bound is validated, and the contract exposes only what downstream modules need. Provider pins are mandatory — an unpinned aws or pulumi-aws provider is the single most common cause of an overlay drifting between environments.

# modules/spatial_raster_ingestion/versions.tf
terraform {
  required_version = ">= 1.10.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60" # pin the major/minor so overlays stay identical
    }
  }
}

# modules/spatial_raster_ingestion/variables.tf
variable "storage_endpoint" {
  type        = string
  description = "Target object storage URI for raw and processed rasters"
}

variable "processing_role_arn" {
  type        = string
  description = "IAM role with scoped S3 read/write and compute execution permissions"
}

variable "max_concurrent_workers" {
  type        = number
  default     = 16
  description = "Parallel processing limit for tile generation"
  validation {
    condition     = var.max_concurrent_workers >= 4 && var.max_concurrent_workers <= 64
    error_message = "Concurrency must remain within 4-64 bounds to prevent API throttling."
  }
}

The Pulumi equivalent expresses the same contract as a ComponentResource, emitting typed outputs that downstream analytics modules consume directly:

// providers pinned in package.json: "@pulumi/aws": "6.47.0", "@pulumi/pulumi": "3.130.0"
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

export interface RasterIngestionArgs {
  storageEndpoint: pulumi.Input<string>;
  processingRoleArn: pulumi.Input<string>;
  maxConcurrentWorkers?: pulumi.Input<number>;
}

export class RasterIngestion extends pulumi.ComponentResource {
  public readonly bucket: aws.s3.BucketV2;
  public readonly queue: aws.sqs.Queue;

  constructor(name: string, args: RasterIngestionArgs, opts?: pulumi.ComponentResourceOptions) {
    super("spatial:module:RasterIngestion", name, {}, opts);
    // Implementation abstracts provider-specific resource creation
    // while exposing standardized outputs for downstream analytics modules.
    this.registerOutputs({ bucket: this.bucket, queue: this.queue });
  }
}

Guardrails Embedded in Configuration

Guardrails enforced at the module boundary cannot be bypassed by a careless overlay. Four belong in every spatial module.

Policy-as-code at the boundary. Geospatial platforms process sensitive location data, so data-residency, encryption, and segmentation rules must be asserted before apply. A Sentinel rule that fails any unencrypted storage resource is the minimum bar:

# Example Sentinel Policy (Terraform)
import "tfplan/v2" as tfplan

# Enforce encryption on all spatial storage resources.
main = rule {
  all tfplan.resource_changes as _, rc {
    rc.type is "aws_s3_bucket" implies
    rc.change.after.server_side_encryption_configuration != null
  }
}

Equivalent module-boundary assertions should also require a kms_key_id on every raster and vector bucket, force VPC endpoint routing for internal GIS API traffic, mandate a tagging schema (project, data_classification, cost_center) for spatial resource attribution, and prohibit public ACLs on tile-cache origins.

State locking. Modules write to a shared backend, and concurrent applies against a deep spatial graph can corrupt the resource ledger. The module’s backend block must enable locking (native S3 lockfiles on Terraform 1.10+, or the Pulumi service backend) so two pipeline runs serialize rather than race. The rationale and timeout tuning are covered in Managing Terraform State Locks for Spatial Data.

Secret management. Connection strings for PostGIS, geocoding API keys, and signing secrets for tile URLs must never be module literals. Resolve them at runtime from a secrets manager or the Pulumi secret provider, and scope the consuming IAM role to read only its own secret path — the network-side counterpart is described in IAM Role Mapping for GIS.

Network isolation and parameter sizing. Internal GIS traffic should route over VPC endpoints, and database parameter groups must be sized explicitly per environment rather than inherited.

Parameter sizing note: RDS/Aurora memory parameters such as shared_buffers are expressed in 8 kB blocks as integer AWS units — not percentage strings. To allocate 4 GiB of shared_buffers, set the value to 524288 (4 GiB ÷ 8 kB), not "25%" or "4GB". Encode the block-count arithmetic in the module so overlays scale memory by a validated ratio and a staging plan never resolves to a parameter the production engine rejects.

Troubleshooting and Failure Modes

1. Circular dependency deadlock. Two modules each reference an output of the other (for example, a security-group module reading a database endpoint while the database module reads a security-group id). The plan fails with a cycle error or hangs to timeout. Resolve by promoting the shared primitive — usually the security group or subnet — into the base layer and passing it down as an input to both consumers.

2. State drift across overlays. A manual console change to a staging tile bucket makes the next plan propose destructive changes the team did not author. This almost always traces to overlays sharing a state path or to drift detection not running at the PR stage. Enforce one isolated state path per overlay and run a scheduled drift check that fails on out-of-band edits.

3. Extension version skew. An overlay provisions PostGIS 3.4 while the promotion target runs 3.3; a spatial index rebuild or a ST_* function signature change fails on apply. Pin the full extension matrix as required inputs with validation, and gate promotion on a version-compatibility check.

4. S3 prefix scope mismatch. A raster ingestion module is granted an IAM policy scoped to bucket/raw/* but the tile writer emits to bucket/processed/*, producing intermittent AccessDenied errors only on the processed path. Align the IAM resource prefixes in the module contract with the actual write prefixes, and assert the mapping in policy-as-code.

5. Concurrency throttling. Setting max_concurrent_workers above the storage API’s request ceiling triggers SlowDown/503 responses mid-pyramid, leaving a partially materialized tile cache. Keep the validated upper bound aligned with the provider’s per-prefix request rate, and back the writer with a queue so throttled writes retry instead of failing the run.