Overview
Someone changes infrastructure by hand in the AWS console. Terraform's code doesn't know, and the next apply quietly reverts it. terra-drift detects that drift, shows a short report, and asks you which side to trust: the code or the live infrastructure. Only if you choose "live" does it edit the Terraform to match reality and open a pull request.
It does not scan your cloud account, run its own AWS calls, or decide for you. Detection is delegated to terraform plan -refresh-only; the decision is yours.
How it works
terra-drift sync
1. detect terraform plan -refresh-only (no cloud scanner)
2. report one short line per drifted resource
3. ask trust code, live, or partial?
terminal -> prompt you
CI -> print report, exit 2 unless --trust is passed
4. act
code -> change nothing (next apply reverts the live drift)
live -> rewrite code to match reality -> open a PR
partial -> choose per resource; the rest stay code
The choice happens before any file changes. Choosing "live" is what triggers the edit + PR; you still review that PR before merge.
Install
Two binaries, built from one repo. Pick one of three ways.
1. Download a prebuilt release
Grab the archive for your OS/arch from the Releases page. Each archive contains both binaries.
# example: Linux x86-64
curl -sSL -o terra-drift.tar.gz \
https://github.com/raflyritonga/terra-drift/releases/latest/download/terra-drift_VERSION_linux_amd64.tar.gz
tar -xzf terra-drift.tar.gz
sudo install terra-drift_*/terra-drift terra-drift_*/terra-drift-mcp /usr/local/bin/
Each archive ships a .sha256 next to it; verify with sha256sum -c.
2. Install from source (Go 1.24+)
go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest
go install github.com/raflyritonga/terra-drift/cmd/terra-drift-mcp@latest
3. Build locally
go build -o terra-drift ./cmd/terra-drift
go build -o terra-drift-mcp ./cmd/terra-drift-mcp
Cutting a release (maintainers)
Pushing a version tag builds all platforms and publishes a GitHub Release automatically (.github/workflows/release.yml).
git tag v0.5.0
git push origin v0.5.0
Architecture
| Binary | What it does | Runs on |
|---|---|---|
terra-drift | the client: detects drift, edits code, opens PRs | your CI runner (or a laptop) |
terra-drift-mcp | the server: talks to your model, returns structured edits | its own box, as an HTTP service |
The client calls the server for two things: explaining drift when you pass --explain (read-only), and proposing edits for the hard cases (tier 2 — see tiers). Simple drift is edited by the client alone, no model involved.
The git host (Bitbucket, GitHub, GitLab) is reached over its REST API. The runner does not need to be hosted by it — a self-hosted Jenkins runner opening PRs against Bitbucket Cloud is fine.
For Bitbucket, the branch and commit are also published over REST (POST /src) by default — no git push at all. This matters because Atlassian API tokens work for the REST API but are rejected by git-over-HTTPS; with the default push_mode: api the same token covers everything. Set git.push_mode: git to keep the old git-push path. GitHub/GitLab always push via git and need push credentials on the checkout.
Host the server
terra-drift-mcp is one static binary. Put it on a machine that can (a) reach your model endpoint and (b) be reached by your CI runners on its listen port. It needs no AWS, Terraform, git, or repo access — only the model API key.
Pick a model
The model is a swap of two settings — provider and base_url. Nothing else in the tool changes.
| provider | Works with | base_url example | key env |
|---|---|---|---|
openai | OpenAI and any OpenAI-compatible endpoint — gateways, Ollama, vLLM, Together, Groq, x.llm.com | https://api.openai.com/v1 | LLM_API_KEY / OPENAI_API_KEY |
anthropic | Claude (native Messages API) | https://api.anthropic.com | LLM_API_KEY / ANTHROPIC_API_KEY |
mock | testing — canned edits, no network, no key | — | — |
The openai provider POSTs to <base_url>/chat/completions; anthropic POSTs to <base_url>/v1/messages. To use your own x.llm.com, set provider: openai and base_url: https://x.llm.com/v1.
Run it
TERRA_DRIFT_MCP_TRANSPORT=http \
TERRA_DRIFT_MCP_LISTEN=:8080 \
TERRA_DRIFT_MCP_MODEL_PROVIDER=openai \
TERRA_DRIFT_MCP_MODEL_ID=gpt-4o-mini \
TERRA_DRIFT_MCP_MODEL_BASE_URL=https://api.openai.com/v1 \
TERRA_DRIFT_MCP_AUTH_TOKEN=choose-a-long-random-string \
LLM_API_KEY=sk-... \
terra-drift-mcp
Set TERRA_DRIFT_MCP_MODEL_PROVIDER=mock to run with canned edits and no key for testing.
As a systemd service (option 1: env file)
The simplest way. Put the config and key in one file, read as chmod 600. The key is plaintext on disk, so file permissions are the defense — good enough on a box only you control.
sudo install -d -m 700 /etc/terra-drift
sudo install -m 600 /dev/stdin /etc/terra-drift/terra-drift-mcp.env << 'EOF'
TERRA_DRIFT_MCP_TRANSPORT=http
TERRA_DRIFT_MCP_LISTEN=:8080
TERRA_DRIFT_MCP_MODEL_PROVIDER=openai
TERRA_DRIFT_MCP_MODEL_ID=gpt-4o-mini
TERRA_DRIFT_MCP_MODEL_BASE_URL=https://api.openai.com/v1
TERRA_DRIFT_MCP_AUTH_TOKEN=choose-a-long-random-string
LLM_API_KEY=sk-...
EOF
Unit — /etc/systemd/system/terra-drift-mcp.service:
[Unit]
Description=terra-drift MCP server
After=network.target
[Service]
ExecStart=/usr/local/bin/terra-drift-mcp
EnvironmentFile=/etc/terra-drift/terra-drift-mcp.env
DynamicUser=yes
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now terra-drift-mcp
Pull the key from a secret manager (option 2)
If the box has an IAM role that can read AWS Secrets Manager, don't put the key on disk at all — let the server fetch it at startup using that role. No key in the env file, nothing to rotate on the box.
Store the key as a plaintext secret value (not JSON), then set the source and ref. In the env file:
TERRA_DRIFT_MCP_TRANSPORT=http
TERRA_DRIFT_MCP_LISTEN=:8080
TERRA_DRIFT_MCP_MODEL_PROVIDER=openai
TERRA_DRIFT_MCP_MODEL_ID=gpt-4o-mini
TERRA_DRIFT_MCP_MODEL_BASE_URL=https://api.openai.com/v1
TERRA_DRIFT_MCP_SECRET_SOURCE=aws-secrets-manager
TERRA_DRIFT_MCP_SECRET_REF=prod/terra-drift/llm-key
TERRA_DRIFT_MCP_AUTH_TOKEN=choose-a-long-random-string
# no LLM_API_KEY here — the role fetches it
The AWS region and credentials come from the standard chain (the instance/task role), so nothing else is needed. The role needs secretsmanager:GetSecretValue on that secret.
With Docker
The repo ships a Dockerfile that builds a small server image (distroless, nonroot, HTTPS-ready).
docker build -t terra-drift-mcp .
Option 1 — env file:
docker run -d --name terra-drift-mcp -p 8080:8080 \
--env-file terra-drift-mcp.env \
terra-drift-mcp
Option 2 — secret manager: put the two SECRET_* vars in the env file (no key), and give the container the role. On ECS/EKS the task/pod role is automatic; on plain EC2/Docker, pass the role's credentials the usual way (e.g. mount ~/.aws or set the standard AWS env vars).
docker run -d --name terra-drift-mcp -p 8080:8080 \
--env-file terra-drift-mcp.env \
-v $HOME/.aws:/home/nonroot/.aws:ro \
terra-drift-mcp
TERRA_DRIFT_MCP_AUTH_TOKEN; the client sends it as a bearer from the same env var. Still restrict the listen port to your CI runner IPs and put TLS in front if the traffic crosses an untrusted network. /healthz, /readyz, and /metrics are unauthenticated.Host the client
terra-drift runs wherever your terraform plan runs — a CI runner, usually. It needs:
terraformonPATHand an initialized Terraform root (terraform initalready run)- AWS credentials that can run a plan (reuse your existing plan role; nothing extra)
- network access to the MCP server's URL
- git push access to the repo and a PR API token (see secrets)
Drop a .terra-drift.yaml in the Terraform root (committed — it holds no secrets) and run terra-drift sync. Full config in config reference.
Check the environment before the first run:
terra-drift doctor --dir envs/prod
Secrets
Three kinds of secret, three homes. None of them belong in the committed config file or in git.
| Secret | Used by | Lives on | Given as |
|---|---|---|---|
| Model API key | server | the server box only | LLM_API_KEY env (600 file or the box's secret manager) |
| PR API token | client | CI runner | provider env var (below), from your CI secret store |
| git push credential | client | CI runner | only for push_mode: git (GitHub/GitLab, or Bitbucket opt-out): HTTPS app password or SSH key. Bitbucket's default api mode needs none — the API token covers publish + PR |
| AWS credentials | client | CI runner | OIDC / assumed role (avoid long-lived keys) |
PR API token per provider
# Bitbucket (default provider)
BITBUCKET_USERNAME=ci-bot
BITBUCKET_APP_PASSWORD=... # scopes: pull-request:write, repository:write
# or a single token:
BITBUCKET_TOKEN=...
# GitHub
GH_TOKEN=... # or GITHUB_TOKEN (the Actions built-in works)
# GitLab
GITLAB_TOKEN=... # scope: api
Storing the model key on the server
Two options, pick one:
- Env file — the key sits in
LLM_API_KEYin anEnvironmentFile=(systemd) or--env-file(Docker),chmod 600. Plaintext on disk; file permissions are the guard. Simple, fine on a box only you control. - Secret manager — set
secret.source: aws-secrets-managerandsecret.ref: <secret-id>. The server fetches the key at startup using the box's IAM role (needssecretsmanager:GetSecretValue). No key on disk. See Host the server.
Either way, don't use Environment=KEY=... in the unit file — that leaks into systemctl show.
Rules of thumb
- The model key stays on the server box. The client and the CI runner never see it, and it is never sent over the wire to the client.
- Runner secrets come from the CI's own secret store (GitHub Actions secrets, GitLab masked/protected variables, Bitbucket secured repository variables, Jenkins credentials) and are injected as env at run time — never written into the repo.
- For HTTPS push, the same Bitbucket app password / token usually covers both the push and the PR call. For SSH push, use a deploy key with write access.
- Prefer short-lived AWS credentials (OIDC) over static keys.
Config reference
Client — .terra-drift.yaml (in the Terraform root, committed)
protected_paths: ["modules/**"] # never edit these when a caller-settable value exists
mcp:
transport: http # http (server on its own box) | stdio (local subprocess)
url: http://mcp.internal.example.com:8080 # http transport
server_bin: terra-drift-mcp # stdio transport only
tool: propose_hcl_edits
max_retries: 2
git:
branch_prefix: drift-sync/
open_pr: true
provider: bitbucket # bitbucket (default) | github | gitlab
workspace: acme # bitbucket workspace / github owner / gitlab group
repo: infra
target_branch: main
push_mode: api # bitbucket only: api (default — REST /src, no git push) | git
# api_base: "" # self-hosted: Bitbucket Server, GitLab CE, GH Enterprise
Server — terra-drift-mcp.yaml (or the env vars shown above)
transport: http
listen: ":8080"
model:
provider: openai # openai | anthropic | mock
id: gpt-4o-mini # or claude-opus-4-8, or your model name
base_url: https://api.openai.com/v1 # or https://x.llm.com/v1, https://api.anthropic.com
secret:
source: env # env (default) | aws-secrets-manager
ref: "" # secret id/ARN when source is aws-secrets-manager
limits: # cost/abuse controls; all fail closed (defaults shown)
max_prompt_bytes: 65536
request_timeout_s: 60
rate_per_minute: 30
cache_ttl_minutes: 1440 # proposals cached by resource+attrs+value+model
validate_retry_max: 1 # bounded retry when the model's edit fails validation
# secrets are never in this file: the model key comes from LLM_API_KEY or the
# secret manager ref; the http bearer comes from TERRA_DRIFT_MCP_AUTH_TOKEN
CI setup
Two moments: a scheduled job that detects drift and prints the report (changes nothing), and an on-demand run where a human passes their decision with --trust. Optionally, a pre-apply gate that fails a deploy while drift is unresolved.
Set these on the runner (values from your secret store): the server URL TERRA_DRIFT_MCP_URL, the server bearer TERRA_DRIFT_MCP_AUTH_TOKEN (same value the server was started with), AWS creds, and the PR token for your provider. Replace envs/prod and acme/infra.
.github/workflows/drift.yml — schedule detects; "Run workflow" with a trust input acts.
name: drift
on:
schedule: [{ cron: "0 6 * * *" }]
workflow_dispatch:
inputs:
trust: { type: choice, options: [report-only, code, live, partial], default: report-only }
live: { description: "partial: comma-separated addresses", default: "" }
permissions: { contents: write, pull-requests: write }
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: "${{ vars.PLAN_ROLE_ARN }}", aws-region: "${{ vars.AWS_REGION }}" }
- uses: hashicorp/setup-terraform@v3
- uses: actions/setup-go@v5
with: { go-version: stable }
- run: go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest
- name: configure client
run: |
cat > envs/prod/.terra-drift.yaml << EOF
mcp: { transport: http, url: ${{ vars.TERRA_DRIFT_MCP_URL }} }
git:
provider: github
workspace: ${{ github.repository_owner }}
repo: ${{ github.event.repository.name }}
target_branch: ${{ github.event.repository.default_branch }}
EOF
- run: terraform -chdir=envs/prod init
- name: detect (schedule / report-only)
if: github.event_name == 'schedule' || inputs.trust == 'report-only'
run: terra-drift check --dir envs/prod
- name: act (manual with a decision)
if: github.event_name == 'workflow_dispatch' && inputs.trust != 'report-only'
run: terra-drift sync --dir envs/prod --trust ${{ inputs.trust }} --live "${{ inputs.live }}"
env: { GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
.gitlab-ci.yml — scheduled pipeline reports; run manually with TRUST=live to act.
variables:
TRUST: "report-only"
drift:
image: golang:1.24
before_script:
- apt-get update -qq && apt-get install -y -qq unzip git
- curl -sLo tf.zip https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
- unzip -q tf.zip -d /usr/local/bin
- go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest
script:
- |
cat > envs/prod/.terra-drift.yaml << EOF
mcp: { transport: http, url: ${TERRA_DRIFT_MCP_URL} }
git: { provider: gitlab, workspace: acme, repo: infra, target_branch: main }
EOF
- terraform -chdir=envs/prod init
- |
if [ "$TRUST" = "report-only" ]; then
terra-drift check --dir envs/prod
else
terra-drift sync --dir envs/prod --trust "$TRUST"
fi
Needs GITLAB_TOKEN (api scope) as a masked CI variable.
bitbucket-pipelines.yml — attach a Schedule to drift-detect; run drift-decide manually with TRUST.
image: golang:1.24
pipelines:
custom:
drift-detect: # attach a daily Schedule
- step:
name: detect and report
script: &setup
- apt-get update -qq && apt-get install -y -qq unzip
- curl -sLo tf.zip https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
- unzip -q tf.zip -d /usr/local/bin
- go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest
- export PATH=$PATH:$(go env GOPATH)/bin
- terraform -chdir=envs/prod init
- terra-drift check --dir envs/prod
drift-decide: # Run pipeline with TRUST=live|code|partial
- variables: [{ name: TRUST }, { name: LIVE }]
- step:
name: act on decision
script:
- apt-get update -qq && apt-get install -y -qq unzip
- curl -sLo tf.zip https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
- unzip -q tf.zip -d /usr/local/bin
- go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest
- export PATH=$PATH:$(go env GOPATH)/bin
- |
cat > envs/prod/.terra-drift.yaml << EOF
mcp: { transport: http, url: ${TERRA_DRIFT_MCP_URL} }
git: { provider: bitbucket, workspace: ${BITBUCKET_WORKSPACE}, repo: ${BITBUCKET_REPO_SLUG}, target_branch: main }
EOF
- terraform -chdir=envs/prod init
- terra-drift sync --dir envs/prod --trust "${TRUST}" --live "${LIVE}"
Set BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD as secured repository variables.
Jenkinsfile — self-hosted runner, repo on Bitbucket Cloud. Scheduled build reports; build with TRUST=live to act.
pipeline {
agent any
triggers { cron('H 6 * * *') }
parameters {
choice(name: 'TRUST', choices: ['report-only','code','live','partial'])
string(name: 'LIVE', defaultValue: '')
}
environment {
BITBUCKET_APP_PW = credentials('bitbucket-app-pw') // username+password credential
BITBUCKET_USERNAME = "${env.BITBUCKET_APP_PW_USR}"
BITBUCKET_APP_PASSWORD = "${env.BITBUCKET_APP_PW_PSW}"
}
stages {
stage('run') {
steps {
sh 'go install github.com/raflyritonga/terra-drift/cmd/terra-drift@latest'
sh 'terraform -chdir=envs/prod init'
sh '''
export PATH=$PATH:$(go env GOPATH)/bin
if [ "$TRUST" = "report-only" ]; then
terra-drift check --dir envs/prod
else
terra-drift sync --dir envs/prod --trust "$TRUST" --live "$LIVE"
fi
'''
}
}
}
}
The checkout's origin must have push access (credentialed clone or SSH key).
Pre-apply gate (optional)
Before terraform apply in your deploy pipeline, add one read-only step. Exit 2 means drift exists, which fails the step and blocks the deploy until it's resolved.
terra-drift check --dir envs/prod # exit 0 clean / 2 drift / 1 error
The decision
When drift is found terra-drift asks whose version to trust. In a terminal it prompts; in CI you pass --trust.
| Choice | What happens |
|---|---|
code | Change nothing. The next terraform apply reverts the live drift (normal Terraform behavior). |
live | Rewrite the code to match reality, then open a PR for a final review. |
partial | Trust live for named resources only (--live addr,addr, or per-resource prompt); the rest stay code. |
The report itself is deliberately small — one line per drifted resource with the changed attributes and where the block lives in your code, not a terraform plan diff:
drift on 1 resource(s):
module.network.aws_security_group.web cidr_blocks (modules/network/sg.tf:1)
Add --explain (to check or sync) and the model server appends a short read-only summary — what changed and the risk of reverting it — before you decide. This is the LLM's read path: it explains, it never writes.
explanation:
The security group gained an ingress rule for 203.0.113.0/24; reverting it
will cut off whatever started using that range.
One PR per drift set
Every run computes a stable hash of the drift set and embeds it in the PR body (drift-hash:). If an open drift PR already covers the same hash, the run stops — no duplicate PRs from a daily schedule. If the drift changed, the existing PR's branch gets the new commit and its body is updated instead of opening a fresh one (Bitbucket).
After acting on live, the PR body reports three buckets: fixed (drift resolved, proven by a clean re-plan), skipped-protected (the fix would land under protected_paths), and could-not-fix (tier 3, rejected proposals, still drifted after retries).
Commands
terra-drift doctor # preflight: terraform, git, config, server reachable, PR provider
terra-drift check # detect + report (file:line); exit 0 clean / 2 drift / 1 error
terra-drift check --explain # + short model summary of the drift
terra-drift sync # detect -> report -> ask -> act
terra-drift sync --dry-run --trust live # rewrite, print the diff, restore; no PR
terra-drift sync --explain # + model summary before the prompt
terra-drift sync --trust live # trust reality: rewrite + PR
terra-drift sync --trust code # trust code: no changes
terra-drift sync --trust partial --live aws_x.y # trust reality for named resources
Exit codes are stable across both commands: 0 = no drift, 2 = drift was found (whatever action was taken, including --trust code), 1 = error. A refresh failure is classified in the output (no-identity-policy, boundary-denied, trust-denied, provider-error) so a skipped root tells you why.
Tiers & safety
A drifted value often isn't a literal in the resource block — it flows through variables, module arguments, tfvars. terra-drift edits where the value originates.
| Tier | Meaning | Who edits |
|---|---|---|
| 0 | literal in the resource block | client, no model |
| 1 | passes through vars / module args to a literal | client, no model |
| 2 | transforming expression (concat, template, for_each) | server proposes, client applies |
| 3 | opaque (data sources, other resources, registry modules) | left to you, noted in the PR |
Safety rules, enforced by the client regardless of what the server proposes:
- Never edit under
protected_paths(defaultmodules/**) when the value is settable from a caller. - Minimal diff: a proposed edit may only touch files on the drifted attribute's provenance chain and the drifted/origin attributes — anything else is rejected and the resource skipped. Enforced on the client and re-checked on the server (
allowed_attrs). - The model never writes files — it returns structured edits; the client writes them.
- Verified: after applying,
terraform fmt+validaterun and a fresh re-plan must show the resource clean before it is reported as fixed; tier-2 retries against the residual up tomax_retries. - All writes go on a fresh branch through a PR.
And on the server side:
- Redaction: secrets, ARNs, IPs/CIDRs, and account ids are replaced with stable placeholders before the model call and restored in the reply — raw infra values never reach the LLM.
- The model sees only minimal HCL snippets (the target blocks), never whole files; temperature 0.
- Prompt-size, timeout, and rate limits fail closed with typed errors (
budget-exceeded,rate-limited,gateway-down,invalid-output) — the client reports them in the PR instead of failing the run. - Validated proposals are cached (default 24 h), so a daily schedule doesn't re-spend tokens on unchanged drift.