Syncing GeoNode Environments with Terraform

A step-by-step procedure for keeping development, staging, and production GeoNode deployments identical by provisioning every tier from one set of version-controlled Terraform modules.

This page sits under Environment Parity in Geospatial CI Pipelines and the broader Infrastructure Orchestration & Configuration Management guide; read those first if you are still deciding how spatial schemas and service configurations should be promoted between tiers, because the parity contract they define is exactly what the Terraform code below enforces.

GeoNode couples a Django web application, PostgreSQL with PostGIS, a GeoServer instance, Celery workers backed by RabbitMQ or Redis, and a reverse proxy layer. When any one of those components is provisioned by hand in one environment, drift creeps in and surfaces later as broken tiles, mismatched coordinate reference systems, or spatial queries that pass in staging and time out in production. Treating the whole topology as declarative infrastructure-as-code removes that class of failure: every tier is rebuilt from the same modules, and the only thing allowed to differ is a small, audited set of scaling and endpoint variables.

The continuous sync cycle below shows how each tier’s state is reviewed before apply, with scheduled plans surfacing drift as actionable tickets.

Continuous Terraform sync and drift-detection cycle for GeoNode tiers For each workspace, terraform plan feeds a review gate that asks whether the plan contains unexpected replacements. If yes, the change is scoped with -target or the module is revised and the plan is re-run. If no, the saved plan is applied to remote state that is partitioned into networking, database, application, and storage. A scheduled plan reads that state on a timer and, on any divergence, opens a drift alert and remediation ticket that feeds back into the next plan. no yes clean plan re-plan scheduled plan feeds next plan terraform plan per workspace Unexpected replacements? terraform apply saved plan Remote state networking · database application · storage Scope with -target or revise modules Drift alert + remediation ticket

Prerequisites

Confirm the following before running any terraform apply against a shared environment:

  • Terraform >= 1.6 with the providers your platform requires (aws, kubernetes, helm, or equivalent) pinned in a required_providers block.
  • A remote state backend with locking — S3 + DynamoDB, Terraform Cloud, or an on-premises Consul KV store. Local state is never acceptable for a shared GeoNode tier.
  • One isolated state file per environment, all consuming a shared module hierarchy so baseline parity is structural rather than hand-maintained.
  • A secrets provider (AWS Secrets Manager or HashiCorp Vault) holding django_secret_key, database passwords, and broker credentials — never plain values in *.tfvars.
  • A least-privilege execution identity for the Terraform runner, scoped to only the resources it provisions and the state/lock backend.
  • GeoNode component versions agreed across tiers: the PostGIS image tag, GeoServer release, and Celery/broker versions must be the same in dev, staging, and production.

Step-by-step implementation

1. Partition remote state by logical tier

Avoid a monolithic state file. Partition state into networking, database, application, and storage so a misconfigured storage volume in staging can never corrupt the production database state, and so targeted remediation does not trigger replacements across the whole stack. Each environment gets its own backend key:

# environments/production/backend.tf
terraform {
  backend "s3" {
    bucket         = "geonode-tfstate"
    key            = "production/database/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "geonode-tf-locks"
    encrypt        = true
  }
}

Reuse the same key layout for networking, application, and storage, and repeat the structure under environments/staging/ and environments/development/. The HashiCorp Terraform remote state documentation covers backend configuration and cross-state references in detail.

2. Build one reusable geonode_core module

Construct provider-agnostic modules that expose geospatial-specific variables rather than copying HCL between tiers. The geonode_core module encapsulates compute instances, container orchestration, and persistent volume claims, and validates the inputs that silently break spatial workloads. Enforce semantic versioning on PostGIS so a stray tag cannot introduce an incompatible extension during a sync:

# modules/geonode_core/variables.tf
variable "postgis_version" {
  type        = string
  description = "PostGIS version tag, e.g. 17-3.5"
  validation {
    condition     = can(regex("^\\d+-\\d+\\.\\d+$", var.postgis_version))
    error_message = "postgis_version must follow the pattern PGMAJOR-POSTGISMAJOR.MINOR (e.g. 17-3.5)."
  }
}

variable "celery_concurrency" {
  type        = number
  description = "Worker processes per Celery container"
}

variable "raster_processing_worker_count" {
  type        = number
  description = "Dedicated workers for raster ingestion queues"
}

Other critical inputs include geoserver_data_dir_path and django_secret_key (sourced from the secrets provider, not declared inline). Because the same module backs every tier, environment parity across CI pipelines becomes a property of the code rather than a manual checklist.

3. Protect stateful resources and externalize secrets

Government and agency deployments demand strict data lifecycle controls. Mark production data stores and critical routing tables with prevent_destroy so an accidental replacement cannot wipe a spatial database, and pull every credential from the secrets provider at plan time:

# modules/geonode_core/database.tf
resource "aws_db_instance" "postgis" {
  engine         = "postgres"
  instance_class = var.db_instance_class
  tags = {
    compliance = "fedramp-moderate"
    tier       = var.environment
  }
  lifecycle {
    prevent_destroy = true
  }
}

data "aws_secretsmanager_secret_version" "django" {
  secret_id = "geonode/${var.environment}/django_secret_key"
}

4. Promote with workspaces and variable overrides, not copied code

Synchronization is a promotion, not a rewrite. Drive each tier from the same module set and let only scaling parameters and endpoints differ through terraform.tfvars:

# environments/production/application.tfvars
postgis_version                = "17-3.5"
celery_concurrency             = 8
raster_processing_worker_count = 4
geoserver_data_dir_path        = "/mnt/geoserver_data"

Run the promotion against the target workspace so a single source of truth governs all tiers:

terraform workspace select production
terraform plan  -var-file=environments/production/application.tfvars -out=app.plan
terraform apply app.plan

Reviewing the saved plan before apply is the gate where unexpected replacements of stateful components — PostGIS instances or the GeoServer data directory — must be caught and scoped with -target or revised in the module before they reach production.

5. Schedule drift detection in CI

Parity is a continuous validation cycle, not a one-time event. Add a scheduled job that runs terraform plan against production state and fails (opening a remediation ticket) on any divergence:

# .gitlab-ci.yml (excerpt)
drift_detection:
  stage: validate
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  script:
    - terraform workspace select production
    - terraform plan -detailed-exitcode -var-file=environments/production/application.tfvars
  # exit 0 = no drift, 2 = drift detected -> alert + ticket
  allow_failure:
    exit_codes: [2]

This is the same reconciliation discipline applied to the rendering tier in Containerizing TileServer GL for High Availability and to the edge in Reverse Proxy Configuration for WMS/WFS.

What must differ between environments, and how to say so

Parity does not mean identity. Some things must differ — credentials, hostnames, data volumes, and the size of everything — and a configuration that pretends otherwise either leaks production credentials into staging or makes staging so expensive that it gets switched off. The useful discipline is to name the permitted differences explicitly, in one place, so that anything not on the list is a defect rather than a decision nobody remembers making.

Three lists that define parity Identical, must-differ and may-differ categories, each with examples and where the value is declared, and a closing statement that anything unlisted counts as drift. Must be identical software and image digests spatial library versions database schema and extensions declared once, in the shared module Must differ hostnames and certificates credentials and secret paths instance sizes and replica counts one short per-environment file May differ, with a reason data volume and extent backup retention alerting destinations the reason is written next to the value Anything not on one of these three lists is drift, and the pipeline says so which makes the lists worth keeping short enough that people read them

Keeping the middle list short is the practical goal. Every entry is a place where staging stops predicting production, and a portal whose environments differ in twenty ways has a staging environment that tests the deployment mechanism and nothing else.

Verification

Confirm that a sync actually converged before declaring an environment promoted:

  • Plan is empty after apply. Re-run terraform plan immediately after apply; a clean No changes is the only acceptable result.

    terraform plan -var-file=environments/production/application.tfvars -detailed-exitcode
    echo "exit code: $?"   # 0 = converged, 2 = residual drift
  • PostGIS extension matches across tiers. Inside each database, the reported version must be identical:

    SELECT postgis_full_version();
  • Spatial query plans are stable. Run EXPLAIN ANALYZE on a representative layer and confirm the index path matches staging, proving GiST indexes survived the sync.

  • GeoServer reads the synchronized data directory. Verify GEOSERVER_DATA_DIR resolves to the Terraform-managed volume and that layer styles and security policies are present.

  • Celery workers register. Check the broker management UI or celery -A geonode inspect active_queues to confirm the scaled worker count attached to the expected vhost.

State files are infrastructure too

The state file is the part of this system that is easiest to treat casually and hardest to recover from. It is not a cache: it holds the mapping between configuration and real resources, and losing it means a subsequent apply believes nothing exists and cheerfully proposes creating a second copy of the entire environment. A corrupted or truncated state is worse, because the proposal looks plausible.

Three properties make state safe, and all three are configuration rather than discipline. Remote storage with versioning means a bad state can be rolled back to the previous version rather than reconstructed by importing resources one at a time. Locking prevents two applies from interleaving and writing a state that describes neither outcome. And separate state per environment means an error while working on staging cannot damage production’s record of itself — which is the argument against one large state containing everything, quite apart from the plan times.

Three properties that make state recoverable Three cards — versioned storage, locking, and per-environment separation — each naming the failure it prevents and what happens without it. Versioned storage every write keeps the previous version without it: recovery means importing resources by hand roll back, then re-plan Locking one apply at a time, enforced by the backend without it: a state describing neither of two outcomes the pipeline and a laptop both apply One state per environment staging and production never share a file without it: a staging mistake reaches production's record also keeps plan times sane

Back up state on a schedule independently of the storage backend’s own versioning, and rehearse a restore once. The rehearsal takes twenty minutes and answers the only question that matters in the moment it is needed: whether the recovery path is a documented procedure or a research project.

Troubleshooting matrix

Symptom Likely cause Fix
Error acquiring the state lock on apply A previous apply was interrupted and left a stale lock After confirming no active run, terraform force-unlock <LOCK_ID>; never force-unlock production without auditing the last successful plan
Spatial indexing breaks after a Postgres version bump PostGIS extension not upgraded alongside the engine Run ALTER EXTENSION postgis UPDATE; via a null_resource provisioner, then validate with SELECT postgis_full_version();
Layer styles and security reset after sync GeoServer data_dir not mounted as a Terraform-managed persistent volume Define the volume attachment in the application tier and point GEOSERVER_DATA_DIR at the synchronized path
Workers fail to register after scaling Compute scaled without updating broker connection strings or vhost routing Scale celery_concurrency and broker queue routing in the same apply so workers and broker stay aligned
Plan wants to replace the production database A non-scaling variable diverged between tiers Reconcile *.tfvars, scope the change with -target, and rely on prevent_destroy as the safety net
Drift appears only in production Manual hotfix applied outside Terraform Import the change or revert it, then let the scheduled plan (Step 5) confirm convergence

For long-term scaling, prefer immutable infrastructure over in-place patching: deploy new compute nodes with updated configurations, drain existing queues, and terminate legacy resources. This preserves audit trails and keeps every environment fully reproducible from the Terraform codebase. Cross-reference the official GeoNode deployment documentation for environment-variable precedence and directory-structure requirements before changing the data_dir layout.

Up one level: Environment Parity in Geospatial CI Pipelines.