---
title: Terraform changes via merge request
slug: terraform-changes-via-merge-request
summary: How to change infrastructure safely with Terraform or OpenTofu, with plan in the MR, apply after approval, and routines for drift, state and imports.
order: 12
updated: 2026-11-11
status: ready
---

Use this page for any infrastructure change. If a resource is not in code yet, the change starts by importing it. Clicking in the console is reserved for break-glass, and even then it is followed by a code change the same day.

## The flow

```mermaid
flowchart LR
  B[Branch] --> C[Change .tf]
  C --> F[fmt + validate + lint]
  F --> M[Open MR]
  M --> P[CI or Atlantis runs plan, posts it]
  P --> R[Human reviews plan]
  R --> A[Apply from MR or after merge]
  A --> V[Verify, close ticket]
```

Tools that run plan and apply from the MR: Atlantis, Spacelift, Terraform Cloud or Enterprise, env0, or a plain CI pipeline. Pick one and use it for every stack.

## Before opening the MR

```bash
terraform fmt -recursive
terraform init -backend=false && terraform validate
tflint --recursive          # optional linter
checkov -d . --quiet        # or trivy config . ; policy and security checks
```

- [ ] One logical change per MR. Do not mix a refactor with a behaviour change.
- [ ] MR description: what, why, blast radius, rollback, ticket link.

## Read a plan like a reviewer

- [ ] Count: `Plan: X to add, Y to change, Z to destroy`. Any destroy needs a sentence explaining it.
- [ ] `-/+` (replace) on stateful resources (databases, volumes, buckets, load balancers) is a red flag. Find out which attribute forces it.
- [ ] Changes to IAM, security groups, KMS key policies and route tables get a second reviewer.
- [ ] Unexpected changes mean drift. Stop and investigate before applying.

In Atlantis the comments are:

```text
atlantis plan -p <project>
atlantis apply -p <project>
```

## Handle drift

```bash
# Read-only drift check: exit code 2 means changes exist
terraform plan -detailed-exitcode -lock=false -refresh=true
```

| Drift cause | Fix |
| --- | --- |
| Someone changed it in the console | Decide which is right; update code or re-apply code |
| Provider default changed | Pin the attribute explicitly |
| Resource deleted outside Terraform | Remove from state or recreate via MR |
| Resource never in code | Import it |

Schedule a nightly drift plan per stack and report non-zero results to the team; that is good automation for an agent (see [automation blueprint](./automation-blueprint.md)).

## Import existing resources

```hcl
import {
  to = aws_s3_bucket.logs
  id = "<bucket-name>"
}
```

```bash
terraform plan -generate-config-out=generated.tf   # review and tidy the generated code
```

## State hygiene

- [ ] Remote state with locking (for example S3 with native lockfile or a DynamoDB table), encrypted, versioned, and access-limited.
- [ ] One state per environment per component. Small states plan fast and fail small.
- [ ] `terraform state mv` and `state rm` are changes too: do them in an MR using `moved {}` and `removed {}` blocks where possible.
- [ ] Never edit a state file by hand. Never commit state to git: it often contains secrets.

## Rollback

Revert the MR and apply the revert through the same flow. For destroys, rollback means re-creating, which may lose data: that is why destroys of stateful resources need backups and a second approval (see [backups and restore testing](./backups-and-restore-testing.md)).

## Pitfalls

- `terraform apply` from a laptop "just this once". It bypasses review and the lock may conflict with CI.
- `-target` as a habit. It hides the rest of the plan and leaves drift.
- `prevent_destroy` missing on databases and buckets that hold data.
- Secrets in `.tfvars` committed to git. Read them from a secret manager data source instead.
