Automated Apply #

Automated apply is the end goal of a mature Terraform CI/CD setup — reviewed and merged configuration changes are automatically applied to the infrastructure without anyone needing to run a command manually. This removes the “wait for whoever has apply access” bottleneck, ensures consistency between code and infrastructure, and lets teams move faster. But “automatic” doesn’t mean “without control” — a good automated apply actually has more safeguards than a manual one.

flowchart TD
    A["Merge to main"] --> B["CI: Plan"]
    B --> C{"Auto checks"}
    C -->|"pass"| D["Apply"]
    C -->|"fail"| E["Block + Alert"]
    D --> F["Verify"]
    F -->|"drift"| G["Alert"]
    F -->|"ok"| H["Done"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#f97316,stroke:#ea580c,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#3b82f6,stroke:#1e40af,color:#fff
    style G fill:#f59e0b,stroke:#d97706,color:#fff
    style H fill:#10b981,stroke:#059669,color:#fff

Prerequisites for Safe Automated Apply #

An automated apply without the right prerequisites is more dangerous than a manual apply. Here’s what must be in place before enabling automated apply.

AUTOMATED APPLY PREREQUISITES:

REVIEW PROCESS:
  ✓ All changes go through a PR / merge request
  ✓ At least 1 reviewer approves before merging
  ✓ The plan output appears in the PR so reviewers see the IMPACT, not just the code

STATE MANAGEMENT:
  ✓ Remote state with locking (S3 + DynamoDB or Terraform Cloud)
  ✓ State can't be accessed or modified directly by anyone other than the pipeline

CREDENTIALS:
  ✓ CI/CD gets credentials via OIDC (not stored secrets)
  ✓ Different credentials for plan and apply (plan: read-only, apply: full)

MONITORING:
  ✓ Notifications when apply succeeds or fails
  ✓ Apply logs stored and auditable
  ✓ Alerts for unexpected changes (drift detection)

GitOps: The Branch as Source of Truth #

GitOps is an approach where the state of a specific Git branch always reflects the desired state of the infrastructure. Every merge to that branch automatically triggers an apply.

GITOPS BRANCHING STRATEGY:

  main branch      → production environment
  staging branch   → staging environment
  develop branch   → development environment

  Change flow:
  feature-branch → PR to develop → auto-apply dev
  develop → PR to staging        → auto-apply staging (or with approval)
  staging → PR to main           → apply production (always with approval)

  Each branch reflects its environment's state.
  Git history = infrastructure audit trail.
# GitHub Actions: GitOps-style automated apply
# .github/workflows/terraform-gitops.yml

name: Terraform GitOps

on:
  push:
    branches: [develop, staging, main]
    paths: ['infrastructure/**']

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Determine Environment
        id: env
        run: |
          case "${{ github.ref_name }}" in
            develop)  echo "environment=dev"        >> $GITHUB_OUTPUT
                      echo "auto_approve=true"       >> $GITHUB_OUTPUT ;;
            staging)  echo "environment=staging"    >> $GITHUB_OUTPUT
                      echo "auto_approve=true"       >> $GITHUB_OUTPUT ;;
            main)     echo "environment=production"  >> $GITHUB_OUTPUT
                      echo "auto_approve=false"      >> $GITHUB_OUTPUT ;;
          esac          

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: >
            arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/TerraformApplyRole-${{ steps.env.outputs.environment }}            
          aws-region: ap-southeast-1

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/environments/${{ steps.env.outputs.environment }}

      - name: Terraform Plan
        run: terraform plan -out=tfplan -no-color
        working-directory: infrastructure/environments/${{ steps.env.outputs.environment }}

      - name: Terraform Apply (Auto-approve for non-production)
        if: steps.env.outputs.auto_approve == 'true'
        run: terraform apply tfplan
        working-directory: infrastructure/environments/${{ steps.env.outputs.environment }}

      - name: Terraform Apply (Production — requires environment approval)
        if: steps.env.outputs.auto_approve == 'false'
        run: terraform apply tfplan
        working-directory: infrastructure/environments/${{ steps.env.outputs.environment }}
        environment: production  # Environment protection rules apply here

Safeguard: Detecting Dangerous Changes Before Applying #

A good pipeline has additional checks before the apply runs, especially to detect potentially destructive changes.

#!/bin/bash
# check-plan-safety.sh — run after terraform plan, before apply

PLAN_OUTPUT=$(terraform show -json tfplan)

# Count resources that will be destroyed
DESTROY_COUNT=$(echo $PLAN_OUTPUT | jq '[.resource_changes[] | select(.change.actions[] == "delete")] | length')

# Count resources that will be replaced (destroy + create)
REPLACE_COUNT=$(echo $PLAN_OUTPUT | jq '[.resource_changes[] | select(.change.actions | contains(["delete", "create"]))] | length')

echo "Resources to destroy: $DESTROY_COUNT"
echo "Resources to replace: $REPLACE_COUNT"

# Fail the pipeline if there are destroys in production
if [ "$ENVIRONMENT" = "production" ] && [ "$DESTROY_COUNT" -gt 0 ]; then
  echo "ERROR: Destroy operations detected in the production plan!"
  echo "Destroy operations require manual review and explicit override."
  exit 1
fi

# Warn if there are replaces in production
if [ "$ENVIRONMENT" = "production" ] && [ "$REPLACE_COUNT" -gt 0 ]; then
  echo "WARNING: Replace operations detected (resources will be destroyed and recreated)"
  echo "This may cause downtime. Ensure this is intentional."
  # No exit — just a warning, still needs human approval via environment protection
fi
# Integrate the check into the pipeline
- name: Check Plan Safety
  run: bash ./scripts/check-plan-safety.sh
  env:
    ENVIRONMENT: ${{ steps.env.outputs.environment }}
  working-directory: infrastructure/environments/${{ steps.env.outputs.environment }}

Rollback Strategies #

An automated apply failing midway can leave infrastructure in a partial state — some resources created, some not. Having a clear rollback strategy is very important.

TERRAFORM ROLLBACK STRATEGIES:

OPTION 1: Re-apply the previous version (safest)
  - Revert the commit in Git
  - The pipeline automatically triggers an apply of the old version
  - Terraform will restore the infrastructure to its previous state
  Note: This only works if the problematic resources can be changed back
           (not all resources can be safely rolled back)

OPTION 2: Terraform apply from a specific state point
  - terraform state pull > backup.tfstate  (before applying)
  - If apply fails: terraform apply -target=resource.that.needs.fixing
  Note: -target is only for emergency fixes, not routine workflows

OPTION 3: Restore from a state backup
  - If the state is corrupted or inconsistent
  - Restore the tfstate from a previous backup
  - Then run plan to see the differences

PREVENTION IS BETTER THAN ROLLBACK:
  ✓ Always back up state before a large apply
  ✓ Use create_before_destroy for critical resources
  ✓ Test changes in staging before production
  ✓ Save the plan output — it can be a reference when debugging

Notifications After Apply #

The team needs to know when infrastructure changes — whether it succeeds or fails.

# Notify Slack after apply
- name: Notify Slack on Success
  if: success()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "✅ Terraform apply succeeded",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*✅ Terraform Apply Succeeded*\nEnvironment: `${{ steps.env.outputs.environment }}`\nCommit: `${{ github.sha }}`\nPR: ${{ github.event.pull_request.html_url }}"
            }
          }
        ]
      }      
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_INFRA_WEBHOOK }}

- name: Notify Slack on Failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "🚨 Terraform apply FAILED",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*🚨 Terraform Apply FAILED*\nEnvironment: `${{ steps.env.outputs.environment }}`\nView the log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }
          }
        ]
      }      
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_INFRA_WEBHOOK }}

flowchart LR
    A["merge"] --> B["plan"]
    B --> C["checks"]
    C --> D["apply"]
    D --> E["notify"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff
    style C fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#f97316,stroke:#ea580c,color:#fff

Rollback Strategy #

# If an apply fails or causes problems:

# 1. Check the state to see what has changed
terraform state list
terraform state show aws_instance.web

# 2. Use a state backup to roll back
terraform state pull > current-state.tfstate
terraform state push backup-before-apply.tfstate

# 3. Or: revert the code and apply again
git revert HEAD
terraform plan
terraform apply

Apply Monitoring #

# Monitor a running apply
terraform apply -auto-approve 2>&1 | tee apply.log

# Parse the log for errors
if grep -q "Error:" apply.log; then
  echo "APPLY FAILED!"
  grep "Error:" apply.log
  # Send an alert
fi

# Verify after the apply
terraform output -json > post-apply-outputs.json
terraform state list | wc -l
# Make sure the resource count matches expectations

Apply Notifications #

# Send notifications after a successful/failed apply

# Notification script
terraform apply -auto-approve 2>&1 | tee apply.log

if [ $? -eq 0 ]; then
  # Apply succeeded
  curl -X POST $SLACK_WEBHOOK -d '{
    "text": "✅ Terraform apply succeeded",
    "attachments": [{"text": "'"$(tail -5 apply.log)"'"}]
  }'
else
  # Apply failed
  curl -X POST $SLACK_WEBHOOK -d '{
    "text": "❌ Terraform apply FAILED",
    "attachments": [{"text": "'"$(grep Error apply.log)"'"}]
  }'
fi

Summary #

  • Automated apply prerequisites: remote state with locking, a review process through PRs, credentials via OIDC, and monitoring notifications.
  • GitOps: a Git branch reflects the environment state — merging to develop = apply dev, merging to main = apply production.
  • Dev and staging can auto-apply after review; production always requires explicit approval through environment protection rules.
  • Destroy-detection safeguards — prevent automatic applies in production if there are unexpected delete operations in the plan.
  • The safest rollback is reverting the commit in Git and letting the pipeline apply the previous version — this is the advantage of GitOps.
  • Notifications are always present — the team must know every time infrastructure changes, whether it succeeds or fails, with a link to auditable logs.

← Previous: Plan Approval Strategy   Next: Policy as Code →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact