Skip to content
Infrastructure as Code iac cicd 5 min read

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-approve against 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.

PlatformApproval mechanismWhere configured
GitHub ActionsEnvironment protection rules (required reviewers)Settings → Environments → production
GitLab CIProtected environments + manual jobs (when: manual)Settings → CI/CD → Protected environments
Terraform CloudRun tasks + manual apply confirmationWorkspace settings → Apply method
Atlantisapply_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_destroy and 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-exitcode and alert on exit code 2 to catch drift fast.
  • Add prevent_destroy to 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.
Last updated June 14, 2026
Was this helpful?