Detecting Terraform Drift in GeoNode Infrastructure
How to catch out-of-band changes to GeoNode’s cloud infrastructure by running a scheduled terraform plan in CI, comparing state to reality, and alerting the moment they diverge.
This page is a hands-on companion to Environment Parity in Geospatial CI Pipelines within the broader Infrastructure Orchestration & Configuration Management practice; read the parent guide for how environments are kept identical, then use this procedure to prove they stay that way. Drift — a security group edited in the console, an RDS parameter tuned by hand, a load balancer someone deleted — silently breaks the guarantee that your code describes your running GeoNode stack. Scheduled drift detection turns that silent divergence into a loud, actionable signal.
Prerequisites
Have these ready before wiring the pipeline, because each is a precondition for a reliable drift signal.
- Terraform 1.6+ (or OpenTofu 1.6+) with a remote backend — S3 + DynamoDB lock, Terraform Cloud, or GCS — holding the GeoNode stack’s state.
- A CI runner with read-only cloud credentials scoped to
plan; drift detection must never be able to mutate infrastructure. - The GeoNode Terraform configuration in version control, applied at least once so state reflects a known-good baseline.
- Optionally driftctl 0.40+ to catch resources that exist in the cloud but are absent from state (out-of-band creations Terraform cannot see).
- A notification sink (Slack webhook, PagerDuty, or an issue-tracker token) and permission for CI to open issues or post alerts.
Step-by-step implementation
The core trick is terraform plan -detailed-exitcode, whose exit code is the drift signal: 0 means no changes, 2 means the plan found differences, and 1 means the command errored. A scheduled job runs the plan against locked state, interprets the exit code, and routes a report. The diagram shows that loop.
1. Run a read-only plan with a detailed exit code
The whole detection hinges on one flag. -detailed-exitcode promotes the “changes present” case to exit 2, distinct from a genuine error (1). Run the plan with -refresh-only semantics against locked state, using credentials that cannot apply.
terraform init -input=false -backend-config=backend.hcl
set +e
terraform plan -detailed-exitcode -lock=true -input=false \
-refresh=true -out=drift.tfplan
code=$?
set -e
case "$code" in
0) echo "No drift: state matches infrastructure" ;;
2) echo "DRIFT DETECTED: plan found differences" ;;
1) echo "ERROR: plan failed" >&2; exit 1 ;;
esac
The -refresh=true step is what actually reads live resource attributes and reconciles them against state, so a hand-edited security group surfaces as a proposed change even though no code changed.
2. Store and compare state artifacts
Capture the machine-readable plan on every run so a human can see what drifted, not just that something did. Convert the binary plan to JSON and keep it as a build artifact keyed by timestamp.
terraform show -json drift.tfplan > drift.json
# Count resources with a non-no-op change (create/update/delete/replace)
drift_count=$(jq '[.resource_changes[]
| select(.change.actions != ["no-op"])] | length' drift.json)
echo "drifted_resources=$drift_count" >> "$GITHUB_OUTPUT"
Comparing successive drift.json artifacts turns a single alert into a trend — you can see whether the same resource drifts repeatedly, which usually points at automation fighting Terraform.
3. Schedule the check in CI
Run the detection on a cron trigger, not only on pushes, so drift introduced between deploys is caught within the hour. The workflow below is valid GitHub Actions YAML; the schedule and the exit-code interpretation are the load-bearing parts.
name: terraform-drift-detection
on:
schedule:
- cron: "0 * * * *" # hourly drift sweep
workflow_dispatch: {}
permissions:
contents: read
issues: write # allow opening a drift issue
jobs:
drift:
runs-on: ubuntu-latest
env:
AWS_REGION: eu-west-1
TF_IN_AUTOMATION: "true"
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.5"
- name: Init
run: terraform init -input=false -backend-config=backend.hcl
- name: Plan with detailed exit code
id: plan
run: |
set +e
terraform plan -detailed-exitcode -lock=true -input=false -out=drift.tfplan
echo "exitcode=$?" >> "$GITHUB_OUTPUT"
- name: Report drift
if: steps.plan.outputs.exitcode == '2'
run: |
terraform show -no-color drift.tfplan | tee drift-report.txt
echo "::warning::Terraform drift detected in GeoNode infrastructure"
- name: Fail on drift
if: steps.plan.outputs.exitcode == '2'
run: exit 1
- name: Fail on error
if: steps.plan.outputs.exitcode == '1'
run: exit 1
4. Catch out-of-band resources with driftctl
terraform plan only sees resources Terraform manages. A database or bucket created entirely outside Terraform is invisible to it. driftctl scans the cloud account and diffs it against state, surfacing unmanaged resources — the drift Terraform structurally cannot report.
driftctl scan \
--from tfstate+s3://geonode-tfstate/prod/terraform.tfstate \
--output json://driftctl-report.json \
--output console://
# Non-zero exit if unmanaged or drifted resources exceed the tolerance
Feed the same guardrails you use for environment promotion; the parent workflow that applies this Terraform for GeoNode environments is described in Syncing GeoNode Environments with Terraform.
5. Publish a report, alert, and block promotion
Turn exit 2 into action: post the human-readable plan to chat, open an issue so the drift is tracked, and — for protected environments — make the drift job a required check so a promotion cannot proceed while infrastructure disagrees with code. The GeoNode module whose drift you are watching is typically packaged as HCL like this.
module "geonode" {
source = "./modules/geonode"
environment = "prod"
rds_instance_class = "db.r6g.xlarge"
rds_parameter_group = aws_db_parameter_group.geonode.name
alb_security_groups = [aws_security_group.geonode_alb.id]
desired_worker_count = 4
# Lock drift-prone attributes so a console edit is flagged, not absorbed.
lifecycle_ignore_tags = false
}
Whether this drift gate belongs alongside a Helm-based release or a Kustomize overlay depends on your packaging model, weighed in Helm vs Kustomize for GeoNode Deployments.
Not all drift deserves the same response
A drift detector that treats every difference as an incident trains its audience to ignore it, because most differences are benign: a cloud provider adds a default tag, an autoscaler changes a replica count that the configuration deliberately leaves unmanaged, a certificate is renewed by the mechanism designed to renew it. Sorting drift by cause before deciding what to do with it is what makes the report readable at all.
The value of the sorting is that the bottom band becomes rare enough to take seriously. A drift report that lists three hundred provider-authored differences hides a single unexplained security-group change perfectly; one that lists nothing but the unexplained change makes it impossible to miss.
Verification
Prove the detector reports 0 on a clean stack and 2 after a deliberate out-of-band change.
# 1. Baseline: a freshly applied stack reports no drift (exit 0)
terraform plan -detailed-exitcode -lock=true; echo "exit=$?"
# No changes. Your infrastructure matches the configuration.
# exit=0
# 2. Introduce drift out of band, e.g. edit a security group rule by hand
aws ec2 authorize-security-group-ingress \
--group-id sg-0geonode --protocol tcp --port 9999 --cidr 0.0.0.0/0
# 3. The next plan must report drift (exit 2) and name the resource
terraform plan -detailed-exitcode -lock=true; echo "exit=$?"
# ~ aws_security_group.geonode_alb will be updated in place
# exit=2
# 4. driftctl also catches resources that were never in state
driftctl scan --from tfstate+s3://geonode-tfstate/prod/terraform.tfstate
# Found N unmanaged resource(s)
An exit=0 on the clean baseline followed by exit=2 after the console edit confirms the pipeline reacts to exactly the changes it should.
Detecting drift without holding a lock all day
A scheduled drift check runs the same machinery as a deployment, which means it can take the state lock, and a lock held by a cron job at the moment of an incident is a genuinely bad afternoon. It also means a misconfigured job can apply rather than plan, which is worse.
Run drift detection with a read-only credential and a refresh-only plan, in a workspace that cannot apply, and give it a lock timeout short enough that it yields to a human immediately. If the check cannot obtain the lock, that is a successful outcome — somebody is deploying, and the drift question can wait for the next run.
Automatic remediation is tempting and should be a separate decision taken per resource type, not a global setting. Reverting an unexplained firewall change automatically is defensible; reverting an unexplained database parameter while an engineer is mid-incident is how a detector becomes the cause.
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Plan always reports drift on every run | Provider defaults or computed attributes differ from config | Add lifecycle { ignore_changes = [...] } for genuinely computed fields; align defaults |
Exit code always 0 despite real changes |
-detailed-exitcode omitted, so exit collapses to 0/1 |
Always pass -detailed-exitcode; treat 2 as drift |
| Scheduled job errors with state-lock timeout | A concurrent apply holds the DynamoDB/backend lock | Stagger the cron away from deploy windows; use -lock-timeout |
| Console-created resources never flagged | terraform plan only sees managed resources |
Add driftctl (or import) to surface unmanaged resources |
| Drift job can mutate infrastructure | Runner credentials scoped for apply, not read-only |
Issue plan-only credentials; deny apply in CI IAM policy |
| Noise from tag drift on every resource | Cost-allocation tags injected out of band | Normalize tags via default_tags; ignore the automated tag keys |
| Alert fires but no one can see what drifted | Only the exit code is captured, not the plan | Persist terraform show -json as an artifact and post the human-readable plan |
For the exact exit-code contract and refresh semantics, the official Terraform plan command reference is authoritative.
Related
- Environment Parity in Geospatial CI Pipelines — the parent guide on keeping GeoNode environments identical, which drift detection enforces.
- Syncing GeoNode Environments with Terraform — the workflow that applies the configuration this job watches for drift.
- Helm vs Kustomize for GeoNode Deployments — choosing the packaging model this drift gate guards.
Up one level: Environment Parity in Geospatial CI Pipelines.