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

BinaryWhat it doesRuns on
terra-driftthe client: detects drift, edits code, opens PRsyour CI runner (or a laptop)
terra-drift-mcpthe server: talks to your model, returns structured editsits 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.

providerWorks withbase_url examplekey env
openaiOpenAI and any OpenAI-compatible endpoint — gateways, Ollama, vLLM, Together, Groq, x.llm.comhttps://api.openai.com/v1LLM_API_KEY / OPENAI_API_KEY
anthropicClaude (native Messages API)https://api.anthropic.comLLM_API_KEY / ANTHROPIC_API_KEY
mocktesting — 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
Auth is required on HTTP. The server refuses to start over HTTP without 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:

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.

SecretUsed byLives onGiven as
Model API keyserverthe server box onlyLLM_API_KEY env (600 file or the box's secret manager)
PR API tokenclientCI runnerprovider env var (below), from your CI secret store
git push credentialclientCI runneronly 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 credentialsclientCI runnerOIDC / 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:

  1. Env file — the key sits in LLM_API_KEY in an EnvironmentFile= (systemd) or --env-file (Docker), chmod 600. Plaintext on disk; file permissions are the guard. Simple, fine on a box only you control.
  2. Secret manager — set secret.source: aws-secrets-manager and secret.ref: <secret-id>. The server fetches the key at startup using the box's IAM role (needs secretsmanager: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

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.

ChoiceWhat happens
codeChange nothing. The next terraform apply reverts the live drift (normal Terraform behavior).
liveRewrite the code to match reality, then open a PR for a final review.
partialTrust 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.

TierMeaningWho edits
0literal in the resource blockclient, no model
1passes through vars / module args to a literalclient, no model
2transforming expression (concat, template, for_each)server proposes, client applies
3opaque (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:

And on the server side: