Automated Apply & Approvals
Once a plan has been reviewed in a pull request, the natural next step is to apply it automatically when the change merges. Automated apply removes the manual terraform apply ritual, eliminates “it works on my laptop” drift, and makes the merge button the single source of truth for what runs in production. The hard part is doing it safely: production needs human approval, the applied plan must match the reviewed plan, and you need a way to detect and recover from drift. This page covers the apply-on-merge workflow, environment protection rules, scheduled drift checks, and rollback strategy.
The apply-on-merge model
The core idea is to split the workflow into two halves. On pull requests you run terraform plan and post the output for review (see Plan in PR). On merge to the default branch, you apply the exact plan that was approved. The reliable way to guarantee the apply matches the review is to persist the plan as an artifact and apply that file rather than re-planning.
# In the PR job: produce a binary plan and save it
terraform plan -out=tfplan -input=false
# In the merge job: apply the saved plan with no re-planning
terraform apply -input=false tfplan
Applying a saved plan file means apply runs without prompting and without recomputing the diff — if state has changed since the plan was generated, Terraform refuses to apply, which is exactly the safety property you want. This works identically with OpenTofu (tofu plan -out / tofu apply).
Never run a bare
terraform apply -auto-approveagainst a freshly computed plan in CI. It applies whatever the diff happens to be at that moment, which may differ from what a reviewer saw. Always apply a saved plan file.
A GitHub Actions job that applies on merge to main:
name: terraform-apply
on:
push:
branches: [main]
jobs:
apply:
runs-on: ubuntu-latest
environment: production # gated by protection rules
permissions:
id-token: write # OIDC for AWS auth
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-apply
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform apply -input=false -auto-approve
Output:
aws_s3_bucket.logs: Creating...
aws_s3_bucket.logs: Creation complete after 3s [id=devcraftly-prod-logs]
aws_cloudwatch_log_group.app: Creating...
aws_cloudwatch_log_group.app: Creation complete after 1s [id=/devcraftly/app]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Environment protection and manual approvals
Non-production environments can apply with no human in the loop. Production should not. Most CI platforms expose an environment-gating primitive that pauses a job until an authorized reviewer approves it.
| Platform | Approval mechanism | Where configured |
|---|---|---|
| GitHub Actions | Environment protection rules (required reviewers) | Settings → Environments → production |
| GitLab CI | Protected environments + manual jobs (when: manual) | Settings → CI/CD → Protected environments |
| Terraform Cloud | Run tasks + manual apply confirmation | Workspace settings → Apply method |
| Atlantis | apply_requirements: [approved, mergeable] | atlantis.yaml / server config |
In GitHub Actions, the environment: production line above is what makes approval work. Configure the production environment with Required reviewers, and the apply job will queue until a reviewer clicks Approve — without changing a single line of YAML.
For GitLab, model production apply as a manual job tied to a protected environment:
apply:prod:
stage: deploy
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # requires a maintainer to click "play"
script:
- terraform apply -input=false tfplan
See GitHub Actions and GitLab CI for full pipeline wiring, and Terraform Cloud for managed approval gates.
Scheduled drift detection
Even with apply-on-merge, real infrastructure drifts: someone hot-fixes a resource in the console, a third-party process mutates a tag, or an out-of-band change slips through. Run a scheduled, read-only plan to catch this and alert when the live state no longer matches code.
on:
schedule:
- cron: "0 6 * * *" # daily at 06:00 UTC
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- id: plan
run: terraform plan -detailed-exitcode -input=false
continue-on-error: true
- if: steps.plan.outputs.exitcode == '2'
run: echo "::warning::Drift detected — see plan output"
The -detailed-exitcode flag is the key: exit code 0 means no changes, 2 means a non-empty diff (drift), and 1 means an error. Wire exit code 2 to a Slack/PagerDuty alert so drift surfaces the same day it appears.
Output:
aws_security_group.web: Refreshing state... [id=sg-0a1b2c3d]
Note: Objects have changed outside of Terraform
~ ingress {
~ cidr_blocks = ["10.0.0.0/16"] -> ["0.0.0.0/0"] # changed in console
}
Plan: 0 to add, 1 to change, 0 to destroy.
Rollback strategy
Terraform has no git revert for infrastructure — “rollback” means applying the previous known-good configuration. Because every change flows through version control, the rollback path is to revert the offending commit and let the normal apply-on-merge pipeline reconcile state.
# Revert the bad change and push; the apply pipeline restores prior state
git revert <bad-commit-sha>
git push origin main
A few rollbacks are not safely reversible by code alone:
- Destroyed stateful resources (databases, EBS volumes) cannot be recreated with their data. Protect them with
prevent_destroyand restore from snapshots/backups instead. - Data migrations triggered by a change are not undone by reverting the resource definition.
Guard irreversible resources with lifecycle rules so a bad plan can never delete them:
resource "aws_db_instance" "primary" {
identifier = "devcraftly-prod"
engine = "postgres"
instance_class = "db.r6g.large"
allocated_storage = 100
lifecycle {
prevent_destroy = true
}
}
Keep state in a versioned, locked backend (S3 with versioning + DynamoDB lock, or Terraform Cloud). If an apply corrupts state, a previous state version is your last-resort recovery point.
Best Practices
- Apply a saved plan file (
-out/apply tfplan) so the applied change is byte-for-byte what was reviewed. - Auto-apply non-prod; require human approval for production via environment protection rules.
- Authenticate CI with short-lived OIDC roles, never long-lived static cloud credentials.
- Run a daily
terraform plan -detailed-exitcodeand alert on exit code2to catch drift fast. - Add
prevent_destroyto stateful resources and back them up so rollbacks can never lose data. - Use a versioned, locking remote backend so state changes are recoverable and concurrent applies are serialized.
- Treat reverting a commit as the standard rollback path, and document which resources need out-of-band recovery.