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.
Prerequisites
Confirm the following before running any terraform apply against a shared environment:
- Terraform
>= 1.6with the providers your platform requires (aws,kubernetes,helm, or equivalent) pinned in arequired_providersblock. - 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 planimmediately after apply; a cleanNo changesis 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 ANALYZEon 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_DIRresolves 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_queuesto 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.
Related
- Environment Parity in Geospatial CI Pipelines — the promotion contract this Terraform workflow enforces tier to tier.
- Kubernetes StatefulSets for PostGIS Databases — deterministic storage binding for the spatial database this module provisions.
- Containerizing TileServer GL for High Availability — applying the same immutable-image discipline to the rendering tier.
Up one level: Environment Parity in Geospatial CI Pipelines.