~/blog/terraform-drift-detection-that-opens-issues
Terraform drift detection that opens GitHub issues
Drift is not a Terraform problem, it's a feedback-loop problem. Here is the nightly job that re-plans every environment and files an issue when reality disagrees with code.
On this page
Every Terraform codebase I've inherited had the same disease: the code described the infrastructure as it was the last time somebody remembered to run apply. Between then and now, someone clicked something in the console during an incident, a security group grew a rule, an instance type changed "temporarily". The code was a historical document.
The fix is not discipline. The fix is a loop that notices.
The loop
- Every night, for every environment, run
terraform plan -detailed-exitcode. - Exit code
2means "changes pending" — drift, or unapplied code. Either way, a human should know. - Post the plan as a GitHub issue, de-duplicated per environment, and close it automatically when the next plan is clean.
That's the whole design. The rest is plumbing, and the plumbing is where it usually goes wrong.
The workflow
name: drift
on:
schedule: [{ cron: "30 22 * * *" }] # 04:00 IST
workflow_dispatch:
permissions:
id-token: write # OIDC → AWS, no stored keys
contents: read
issues: write
jobs:
plan:
strategy:
fail-fast: false
matrix: { env: [dev, staging, prod] }
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.ACCOUNT_ID }}:role/gha-plan-${{ matrix.env }}
aws-region: ap-south-1
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=envs/${{ matrix.env }} init -input=false
- id: plan
run: |
set +e
terraform -chdir=envs/${{ matrix.env }} plan -input=false -no-color -detailed-exitcode -out=tfplan > plan.txt
echo "code=$?" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.code == '2'
uses: actions/github-script@v7
with:
script: |
const body = require('fs').readFileSync('plan.txt','utf8').slice(0, 60000);
const title = `drift: ${{ matrix.env }}`;
const { data: open } = await github.rest.issues.listForRepo({ ...context.repo, state: 'open', labels: 'drift' });
const existing = open.find(i => i.title === title);
const payload = { ...context.repo, title, labels: ['drift', '${{ matrix.env }}'], body: '```\n' + body + '\n```' };
existing ? await github.rest.issues.update({ ...payload, issue_number: existing.number })
: await github.rest.issues.create(payload);Three details matter more than they look:
-detailed-exitcodeturns "did anything change" into a number you can branch on. Without it you are grepping plan output, and plan output changes between versions.permissions: id-token: writeis the OIDC hook. The runner assumes a role scoped to plan only for that environment. There are no AWS keys in the repository, which is the subject of part two.- One issue per environment, updated in place. A noisy job gets muted; a quiet one gets read.
What drift actually looked like
In the first month on the platform described in infra-ops: one Terraform + Ansible platform for dev, staging and prod the job found:
- a security group with an
0.0.0.0/0ingress rule added "for a demo" in staging, three weeks earlier; - an RDS parameter group changed by hand in prod during an incident and never backported;
- two Terraform modules with unapplied upgrades, because the PR had merged while the apply gate waited for an approver who was on leave.
Only the first one is a security problem. All three are the same class of problem: the code and the world had diverged and nobody had a mechanism to find out.
0 should close the open drift issue for that environment with a comment linking the run. Otherwise the issue list fills with stale alarms and people stop trusting it.
When drift is correct
Sometimes the console change was right and the code is wrong. The job doesn't decide; it surfaces. The issue becomes the place where someone writes "importing this into code" or "reverting". Both are reviewable. That is the difference between drift detection and a dashboard.
Cost
Three plans a night against an S3-backed state with DynamoDB locking: a few minutes of Actions time and zero AWS cost worth measuring. Compared with the hour we used to spend at the start of each incident rediscovering what was deployed, it pays for itself the first week.
Next: Zero long-lived cloud keys: GitHub Actions → AWS with OIDC, the part where we delete every access key in the account.
Series · Infra that doesn't drift · 1/2
All parts- 01Terraform drift detection that opens GitHub issues
- 02Zero long-lived cloud keys: GitHub Actions → AWS with OIDC
Linked from
Keep reading
Monthly notes
Get the next one in your inbox.
One email a month on infrastructure, backend design and applied AI. No noise, unsubscribe any time.
Comments (0)
name + comment · no login