Terraform apply from GitHub Actions is a powerful way to automate your infrastructure deployments, but it’s easy to get wrong and end up with unintended changes or security vulnerabilities.

Let’s look at a typical workflow:

name: Terraform Apply

on:
  push:
    branches:
      - main

jobs:
  terraform_apply:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.5.7

      - name: Terraform Init
        run: terraform init
        env:

          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}


          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}


      - name: Terraform Plan
        run: terraform plan -out=tfplan
        env:

          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}


          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}


      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan
        env:

          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}


          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

This looks straightforward, but the terraform apply -auto-approve is a huge red flag. It bypasses the critical human review step, which is where most safety nets lie. The real power comes from understanding how to integrate Terraform’s planning and review process within GitHub Actions, not just blindly executing apply.

The core idea is to decouple the plan from the apply. You want to generate a detailed plan, have someone approve it (or a system confirm it), and then apply that specific, pre-approved plan. This prevents drift and ensures that only what was intended gets deployed.

Here’s how you can structure this for safety:

  1. Generate the Plan and Store it as an Artifact:

    • The terraform plan command creates a plan file (.tfplan). This file is a binary representation of exactly what Terraform will do.
    • You can upload this .tfplan file as a GitHub Actions artifact. This makes it available for download and inspection later.
    # ... (checkout, setup_terraform, terraform_init steps) ...
    
    - name: Terraform Plan
      id: plan
      run: terraform plan -out=tfplan -input=false
      env:
    
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    
    
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
    
    - name: Upload plan artifact
      uses: actions/upload-artifact@v3
      with:
        name: terraform-plan
        path: tfplan
    
    • id: plan gives this step a reference.
    • -input=false is crucial in CI/CD to prevent Terraform from prompting for input.
  2. Create a Manual Approval Step:

    • GitHub Actions has a built-in feature for environments and manual approvals. You can configure an environment in your repository settings (e.g., "Production") and require approvers.
    • This means the workflow will pause at a specific step until one or more designated users approve it.
    # ... (previous steps) ...
    
    - name: Deploy to Production
      if: github.ref == 'refs/heads/main' # Only deploy from main branch
      uses: "marvinpinto/action-automatic-releases@latest" # Placeholder, you'll use a custom deploy job
      with:
    
        repo_token: "${{ secrets.GITHUB_TOKEN }}"
    
        automatic_release_tag: "latest"
        prerelease: true
        title: "Automated Release"
        files: "/path/to/your/release/files" # This is where you'd download the plan
    
    • Correction: The marvinpinto/action-automatic-releases is not the right tool here. You need a job that waits for approval and then applies. The correct way is to use a separate job with an environment block.

    Corrected Approach for Manual Approval:

    # ... (previous steps for plan generation) ...
    
    jobs:
      terraform_apply:
        runs-on: ubuntu-latest
        # ... (checkout, setup_terraform, terraform_init, terraform_plan steps as above) ...
    
        # This job will run after the plan is generated and uploaded as an artifact.
        # It requires manual approval.
        deploy_production:
          needs: terraform_apply # Ensure plan job completes first
          runs-on: ubuntu-latest
          environment: Production # Configure "Production" environment in GitHub Repo Settings -> Environments
          steps:
            - name: Download plan artifact
              uses: actions/download-artifact@v3
              with:
                name: terraform-plan
                path: ./artifacts
    
            - name: Setup Terraform
              uses: hashicorp/setup-terraform@v3
              with:
                terraform_version: 1.5.7
    
            - name: Terraform Init
              run: terraform init
              env:
    
                AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    
    
                AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
    
            - name: Terraform Apply
              run: terraform apply -input=false -auto-approve ./artifacts/tfplan
              env:
    
                AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    
    
                AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    
    
    • Key Changes:
      • A new job deploy_production is introduced.
      • environment: Production is specified. Go to your GitHub repository’s Settings -> Environments. Create an environment named "Production". Under "Environment protection rules", check "Required reviewers" and add the users or teams who must approve deployments.
      • needs: terraform_apply ensures this job only runs after the terraform_apply job (which generates the plan) has finished.
      • actions/download-artifact@v3 is used to retrieve the tfplan file generated in the previous job.
      • The terraform apply command now uses the downloaded tfplan file. -auto-approve is still present here because the manual approval step itself is the gatekeeper. The approval means you are explicitly agreeing to apply this specific plan.
  3. Secure Your Credentials:

    • Never hardcode credentials. Use GitHub Secrets.
    • For cloud providers like AWS, consider using IAM roles for GitHub Actions if your CI/CD runner can assume them, which is more secure than long-lived access keys.
    # In your GitHub Repository Settings -> Secrets and variables -> Actions
    # Add:
    # AWS_ACCESS_KEY_ID
    # AWS_SECRET_ACCESS_KEY
    

    If using IAM roles (preferred for AWS):

    • You don’t need to pass AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as environment variables.
    • Your Terraform configuration would typically be set up to use the instance profile credentials.
    • The GitHub Actions runner needs to be configured to assume an IAM role. This often involves an extra step in your workflow or runner setup.

The most surprising true thing about automating Terraform apply is that the real safety comes not from the automation itself, but from integrating human review and explicit approval into the automated pipeline. Blind automation is dangerous; a guided, approved automation is powerful.

By generating a plan artifact and requiring manual approval through GitHub Environments, you create a robust process:

  1. Code Change: A developer pushes Terraform code to your repository.
  2. Plan Generation: A GitHub Action runs terraform plan, creates a .tfplan file, and uploads it as an artifact.
  3. Pull Request Review: The code change is reviewed via a Pull Request.
  4. Manual Approval: Once merged to main (or your deployment branch), a new workflow triggers. This workflow pauses at the deploy_production job, waiting for designated reviewers to approve the deployment.
  5. Apply: Upon approval, the workflow downloads the previously generated .tfplan artifact and executes terraform apply -auto-approve using that specific plan.

This ensures that what gets deployed has been explicitly reviewed at the code level (via PR) and explicitly approved at the execution level (via environment approval), using a plan generated from the exact code that was merged.

The next thing you’ll likely encounter is managing drift between your Terraform state and the actual infrastructure, especially if manual changes are made outside of Terraform.

Want structured learning?

Take the full Github-actions course →