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.

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.

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.