Pipeline Orchestration for Spatial Deployments

Orchestrating plan-and-apply pipelines for a geospatial estate is harder than for a typical web stack because spatial applies are slow, stateful, and expensive: a single run may rebuild a tile pyramid, reindex a PostGIS partition, or replicate gigabytes of raster into a new region, and any of those can outlast a default CI timeout or collide with a concurrent apply against the same state path. This guide sits within CI/CD, Drift Detection & Policy Governance and designs an orchestration layer that promotes changes cleanly from dev to production while holding the line on the two things governance actually cares about — Policy as Code for Spatial Resources enforced before apply, and Drift Detection and Remediation for Geospatial Estates catching what slips past. The result is a pipeline where every merge produces a reviewed plan, every apply is serialized per state path, and long spatial operations run to completion without a runner killing them mid-write.

Environment promotion pipeline with gated applies per state path A left-to-right promotion flow. A pull request triggers a plan stage that runs terraform validate, an Infracost cost gate, and an OPA policy gate; a non-empty diff or a failed gate blocks the merge. After merge, a manual approval gate releases the change into a promotion sequence: apply to dev, then staging, then production, each apply acquiring a single-flight lock scoped to that environment's own state path so concurrent runs queue rather than interleave. Long spatial applies such as tile pyramid builds run on self-hosted or long-timeout runners. A post-apply drift check compares live resources against state and feeds any divergence back into the next plan. Pull request triggers plan Plan stage (read-only) validate + fmt cost gate (Infracost) policy gate (OPA) Approval gate human review merge Apply · dev state: dev/* Apply · staging state: staging/* Apply · prod state: prod/* promote single-flight lock one apply per state path Long-apply runner tile builds, reindex no step timeout kill Post-apply drift check live vs state → feeds next plan

Environment Parity and Configuration Drift Mitigation

A promotion pipeline is only trustworthy when the plan produced against dev is a faithful predictor of what will happen in production. That property — environment parity — is easy to lose in geospatial estates because the resources that differ between tiers are exactly the ones that dominate cost and behaviour: production runs a multi-node PostGIS cluster with read replicas while dev runs a single instance, production fronts tiles with a CDN and dev serves them directly, production holds a full raster catalog and dev holds a sampled subset. The pipeline cannot erase those differences, but it can guarantee that they are the only differences, by driving every tier from one module set parameterized by a per-environment variable file rather than by divergent copies of the code.

The mechanism that enforces this is a single pipeline definition that iterates the same plan/apply job over an ordered list of environments, each pointing at its own isolated state path. Promotion is sequential and gated: staging never applies until dev’s apply has succeeded and its post-apply checks are green, and production waits on staging in turn. This ordering is what makes drift visible early — if a provider upgrade changes how a aws_db_parameter_group is rendered, the dev apply surfaces it first, before the same diff ever reaches a customer-facing tile origin. Skipping tiers, or letting an engineer apply straight to production “just this once”, is the single most common way parity collapses and a plan stops meaning what it says.

Configuration drift in a spatial estate is not only about resources changing out of band; it is about slow changes that a fast pipeline never waits to observe. A raster lifecycle transition, a replica promotion, or an index rebuild may take minutes to converge, and a pipeline that reports success the instant the API call returns will record a state that does not yet match reality. The mitigation is to pair each apply with an explicit convergence check — poll the replica until it reports available, confirm the tile endpoint returns 200 for a known key — before the promotion advances. That discipline connects directly to how state itself is isolated, which is why the backend layout described in State Backend Selection is a prerequisite for a promotion pipeline rather than an independent concern.

CI/CD Validation and Operational Guardrails

Validation happens in two distinct phases with different authority. The pull-request phase is read-only and advisory-plus-blocking: it runs terraform fmt -check, terraform validate, a terraform plan against the target environment’s state, and then feeds that plan through the cost and policy gates. Nothing in this phase can mutate infrastructure, so it is safe to run on every push and on forked contributions with a scoped, read-only identity. The plan output is posted back to the PR as a comment so reviewers see the exact resource delta — created, updated, replaced, destroyed — before approving. For spatial changes, a replace on a stateful resource is a red flag that deserves a human: replacing a PostGIS instance or a raster bucket is not a routine update, and the plan comment is where that distinction gets caught.

The apply phase is privileged and must be gated by both machine and human authority. Machine authority is the set of gates that already passed on the PR, re-evaluated against the merged commit so a stale approval cannot smuggle in an unreviewed change. Human authority is an environment protection rule: production requires an explicit approval from a designated reviewer before the apply job starts. Between the two sits the cost gate and the policy gate. The cost gate, wired to the estimates from Cost Estimation Frameworks, fails a plan whose monthly delta exceeds a threshold — the guardrail that catches a raster pyramid depth change silently inflating PUT volume and cross-region egress. The policy gate evaluates the plan JSON against rules that encode organizational law: required tags, permitted instance classes, mandatory encryption.

Concurrency is the guardrail unique to stateful applies. Two merges to main within seconds can trigger two apply jobs against the same state path, and without serialization they interleave writes and corrupt the resource graph. The pipeline must enforce single-flight per state path: at most one apply in flight for a given environment, with the second queued rather than run. GitHub Actions expresses this with a concurrency group keyed on the environment; Atlantis and the managed platforms enforce it natively by locking the workspace. The lock scope must be the state path, not the repository — a dev apply and a production apply touch different state and should run in parallel, while two production applies must not.

Resource Architecture and Service Integration

The orchestration layer is not a resource itself; it is the control plane that provisions and reconciles every other tier, so its design is shaped by what those tiers demand. The data tier is the most demanding integration point: applies that touch PostGIS Cluster Provisioning run long and hold their state lock for the duration, so the pipeline must give those jobs generous timeouts and route them to runners that will not be reclaimed mid-apply. The tile-serving tier integrates through outputs — CDN origins, security-group identifiers, endpoint DNS — that downstream stacks read from upstream state, which means the apply order in the pipeline must respect the dependency order in the estate: network before data before compute before delivery.

Choosing the orchestrator is the central architectural decision, and for GIS teams it comes down to three families. Atlantis is a self-hosted server that listens to webhooks and runs plan/apply as PR comments; it keeps state and secrets entirely inside your own network, which suits regulated spatial estates with data-residency constraints, and it locks per workspace natively. Its cost is that you operate it — the server, its runners, and its scaling are yours. Terraform Cloud and Spacelift are managed platforms that provide the run environment, state, locking, policy hooks, and an audit trail out of the box; they shorten setup dramatically and give strong RBAC, at the cost of moving run execution (and often state) off your infrastructure and onto per-run or per-seat pricing that grows with a busy spatial estate. GitHub Actions runners put orchestration next to the code with no extra product to buy, and self-hosted runners can be sized for long spatial applies and placed inside the VPC that reaches PostGIS privately; the trade-off is that locking, promotion, and policy are things you assemble from primitives rather than features you switch on.

The pragmatic pattern for most GIS teams is GitHub Actions for the pipeline mechanics with self-hosted runners for the long, network-bound spatial applies, escalating to Spacelift or Terraform Cloud only when the estate grows enough that managed policy, RBAC, and audit outweigh the loss of in-VPC execution. Atlantis is the strongest fit when residency rules forbid state or run logs leaving your accounts. Whichever engine wins, the integration contract is identical: short-lived workload-identity credentials in, an isolated state path per environment, a lock scoped to that path, and a plan artifact that the apply phase consumes unchanged so the applied plan is provably the reviewed plan.

Runnable Configuration

The workflow below is a production-shaped GitHub Actions pipeline for a spatial estate. Actions are pinned to release tags for readability; in production, pin to the full commit SHA (for example actions/checkout@<sha> # v4.2.2) so a moved tag cannot alter what runs. The apply phase authenticates with GitHub OIDC rather than static keys, uses a concurrency group for single-flight per environment, and gives the apply step a long timeout so a tile build or reindex is not killed.

name: spatial-iac
on:
  pull_request:
    paths: ["infra/**"]
  push:
    branches: [main]
    paths: ["infra/**"]

permissions:
  contents: read
  id-token: write        # required for GitHub OIDC federation to AWS
  pull-requests: write   # to post the plan back onto the PR

concurrency:
  # single-flight per environment state path: a second run queues, never interleaves
  group: apply-${{ github.ref }}-prod
  cancel-in-progress: false

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-24.04
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4.2.2
      - uses: hashicorp/setup-terraform@v3.1.2
        with:
          terraform_version: 1.10.5   # pin the CLI so plans are reproducible
      - uses: aws-actions/configure-aws-credentials@v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/spatial-ci-plan
          aws-region: us-east-1
      - run: terraform init -backend-config=env/prod.s3.tfbackend
      - run: terraform fmt -check && terraform validate
      - run: terraform plan -var-file=env/prod.tfvars -out=plan.tfplan
      # cost gate: fail the PR if the monthly delta exceeds the threshold
      - uses: infracost/actions/setup@v3.0.1
        with: { api-key: ${{ secrets.INFRACOST_API_KEY }} }
      - run: |
          terraform show -json plan.tfplan > plan.json
          infracost breakdown --path plan.json \
            --format json --out-file infracost.json
      # policy gate: evaluate the plan JSON against OPA rules (spatial guardrails)
      - uses: open-policy-agent/setup-opa@v2.2.0
        with: { version: 0.70.0 }
      - run: opa eval --fail-defined -i plan.json -d policy/ "data.spatial.deny[_]"

  apply:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: [self-hosted, in-vpc]   # in-VPC runner reaches PostGIS privately
    environment: production          # protection rule enforces human approval
    timeout-minutes: 120             # long spatial applies (tile builds) must survive
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4.2.2
      - uses: hashicorp/setup-terraform@v3.1.2
        with:
          terraform_version: 1.10.5
      - uses: aws-actions/configure-aws-credentials@v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/spatial-ci-apply
          aws-region: us-east-1
      - run: terraform init -backend-config=env/prod.s3.tfbackend
      - run: terraform apply -var-file=env/prod.tfvars -auto-approve

Teams that prefer a self-hosted, in-network orchestrator can express the same guarantees in Atlantis. The repos.yaml below scopes apply to approved, mergeable PRs and lets a per-project workflow raise the plan/apply timeout for slow spatial projects.

# atlantis repos.yaml — server-side config, version controlled
repos:
  - id: /.*/
    apply_requirements: [approved, mergeable]   # human gate + branch protection
    allowed_overrides: [workflow]
    allow_custom_workflows: true
workflows:
  spatial:
    plan:
      steps:
        - init
        - plan:
            extra_args: ["-var-file", "env/prod.tfvars"]
    apply:
      steps:
        - apply   # Atlantis locks the project workspace: single-flight per state path

For the exact OIDC trust policy and least-privilege role behind the role-to-assume values above, see GitHub Actions OIDC for Terraform Spatial Deploys.

Guardrails Embedded in Configuration

The first guardrail is that plan and apply carry different identities. The plan job assumes a read-only role (spatial-ci-plan) that can describe resources and read state but cannot mutate anything; the apply job assumes a write-scoped role (spatial-ci-apply) that only the protected main branch and the production environment can reach. This split means a malicious or mistaken PR — even one that edits the workflow — cannot escalate to an apply, because the write role’s trust policy scopes the OIDC subject claim to the branch and environment, not the pull-request context.

The second guardrail is the applied plan being provably the reviewed plan. The plan phase writes plan.tfplan, the gates evaluate that exact artifact, and the apply phase consumes the same file rather than re-planning. Re-planning at apply time reopens the window where reality shifted between review and apply — a raster bucket resized in the console, a replica added by hand — and turns an approved change into an unreviewed one. Passing the plan artifact through closes that window.

The third guardrail is single-flight concurrency, expressed as the concurrency group in Actions or native workspace locking in Atlantis and the managed platforms. Because spatial applies are long, the probability that a second run arrives while one holds the lock is non-trivial; queuing rather than cancelling ensures the second change still applies, in order, once the first completes. Setting cancel-in-progress: false is deliberate — cancelling a running spatial apply mid-write is exactly the scenario that orphans a state lock and leaves a half-built tile pyramid.

The fourth guardrail concerns the data tier’s parameters. When the same pipeline provisions PostGIS, memory-shaped parameter-group values must be expressed in AWS integer units, not percentages, or the apply will fail validation.

Note: RDS/Aurora parameter-group memory values such as shared_buffers are set in 8 kB blocks (integer units), never as percentage strings — for example shared_buffers = 4194304 for 32 GiB, not "25%". Keep these values in the environment’s .tfvars so each tier sizes independently; see PostGIS Cluster Provisioning.

Troubleshooting and Failure Modes

Long spatial apply killed by a step timeout. A tile-pyramid build or a full raster reindex runs for 40 minutes, but the apply job carries the default 6-hour job cap with a 10-minute step assumption baked into a wrapper script, or a hosted runner with a shorter ceiling. The apply is terminated mid-write, orphaning the state lock and leaving resources half-provisioned. Fix it by setting timeout-minutes on the apply job to exceed the slowest legitimate spatial operation, routing those applies to self-hosted runners you control, and never wrapping terraform apply in a command that imposes its own shorter timeout.

Concurrent applies interleaving on one state path. Two merges to main land seconds apart and both apply jobs start, racing to write the same backend object; the second overwrites the first’s plan and the resource graph is corrupted. This is the failure single-flight exists to prevent. Confirm the concurrency group is keyed on the environment/state path (not just the workflow name), that cancel-in-progress is false, and — belt and braces — that the backend itself enforces state locking so a gap in the pipeline concurrency config cannot corrupt state.

Stale approval applies an unreviewed change. A plan is approved, then a follow-up commit is merged into the branch before the apply runs, so the apply plans against newer code than was reviewed. The fix is to make the apply consume the reviewed plan.tfplan artifact rather than re-planning, and to require approvals to reset on new commits via branch-protection settings, so an approval cannot outlive the diff it approved.

Cost or policy gate skipped on the apply path. The gates run only on the PR job, and a change that reaches main through an admin merge or a hotfix branch that bypasses PR review applies without ever being gated. Re-evaluate the gates on the merged commit inside the apply workflow, and configure branch protection so no path to main skips required status checks — including for administrators.

OIDC apply fails with AccessDenied after a branch rename. The apply role’s trust policy scopes the OIDC subject to repo:org/repo:ref:refs/heads/main; renaming the default branch or moving to environment-scoped subjects without updating the trust policy makes every apply fail to assume the role. Align the trust policy’s sub condition with the actual branch or environment claim; the full trust-policy shape and its debugging are covered in GitHub Actions OIDC for Terraform Spatial Deploys.