Terraform CI/CD workflow for GC resources: plan on PR, apply on merge

How do I correctly to structure a GitHub Actions workflow that executes terraform plan on pull requests and terraform apply only upon merge to main? I need to securely handle the Genesys Cloud Terraform provider credentials without exposing secrets in logs. My current setup fails because the state file is not persisted between jobs. Below is my workflow snippet. How do I inject the GC_OAUTH_CLIENT_ID and GC_OAUTH_CLIENT_SECRET correctly for the plan step?

If I remember correctly, you need to split the authentication and state handling into distinct steps using a backend that supports locking, like S3 or Terraform Cloud, rather than relying on local state in ephemeral jobs.

jobs:
 plan:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 - uses: hashicorp/setup-terraform@v2
 - name: Init
 run: terraform init -backend-config=backend.hcl
 - name: Plan
 env:
 GC_OAUTH_CLIENT_ID: ${{ secrets.GC_OAUTH_CLIENT_ID }}
 GC_OAUTH_CLIENT_SECRET: ${{ secrets.GC_OAUTH_CLIENT_SECRET }}
 run: terraform plan -out=tfplan
 apply:
 needs: plan
 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 - uses: hashicorp/setup-terraform@v2
 - name: Init
 run: terraform init -backend-config=backend.hcl
 - name: Apply
 env:
 GC_OAUTH_CLIENT_ID: ${{ secrets.GC_OAUTH_CLIENT_ID }}
 GC_OAUTH_CLIENT_SECRET: ${{ secrets.GC_OAUTH_CLIENT_SECRET }}
 run: terraform apply -auto-approve tfplan

This ensures secrets are injected via GitHub Secrets and state is centralized, avoiding the persistence issue you described.

Check your state backend configuration because ephemeral runners discard local state files immediately after job completion, which is why your plan fails to recognize previous resource tracking. You need a remote backend like S3 with DynamoDB for locking to persist state across the plan and apply jobs.

The suggestion above regarding S3 is correct, but you must also handle the Genesys Cloud provider authentication explicitly in each job to avoid credential leakage in logs. Do not pass GC_OAUTH_CLIENT_ID and GC_OAUTH_CLIENT_SECRET as environment variables directly to the terraform command if they appear in verbose output. Instead, use a terraform.tfvars file generated at runtime from GitHub Secrets.

Here is the corrected workflow structure. It uses backend.hcl for S3 state persistence and generates credentials dynamically:

jobs:
 plan:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 - uses: hashicorp/setup-terraform@v2
 - name: Configure Backend
 run: |
 echo "bucket = \"my-terraform-state\"" > backend.hcl
 echo "key = \"genesys/terraform.tfstate\"" >> backend.hcl
 echo "region = \"us-east-1\"" >> backend.hcl
 - name: Init
 run: terraform init -backend-config=backend.hcl
 - name: Plan
 env:
 TF_VAR_client_id: ${{ secrets.GC_OAUTH_CLIENT_ID }}
 TF_VAR_client_secret: ${{ secrets.GC_OAUTH_CLIENT_SECRET }}
 run: terraform plan -out=tfplan
 - uses: actions/upload-artifact@v3
 with:
 name: tfplan
 path: tfplan

 apply:
 needs: plan
 runs-on: ubuntu-latest
 if: github.ref == 'refs/heads/main'
 steps:
 - uses: actions/checkout@v3
 - uses: actions/download-artifact@v3
 with:
 name: tfplan
 - uses: hashicorp/setup-terraform@v2
 - name: Init
 run: terraform init -backend-config=backend.hcl
 - name: Apply
 env:
 TF_VAR_client_id: ${{ secrets.GC_OAUTH_CLIENT_ID }}
 TF_VAR_client_secret: ${{ secrets.GC_OAUTH_CLIENT_SECRET }}
 run: terraform apply -auto-approve tfplan

This approach ensures state consistency and keeps secrets out of the plan output logs.

If I remember correctly… you are overcomplicating the auth layer. Injecting client credentials via env vars in the workflow is standard, but the real issue is state persistence.

use S3 with DynamoDB locking for the backend. here is the minimal backend.hcl and job structure:

# backend.hcl
bucket = "my-gc-tf-state"
key = "prod/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "terraform-lock"
jobs:
 plan:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 - uses: hashicorp/setup-terraform@v2
 - run: terraform init -backend-config=backend.hcl
 - run: terraform plan -out=tfplan
 env:
 GC_OAUTH_CLIENT_ID: ${{ secrets.GC_CLIENT_ID }}
 GC_OAUTH_CLIENT_SECRET: ${{ secrets.GC_CLIENT_SECRET }}
 - uses: actions/upload-artifact@v3
 with:
 name: tfplan
 path: tfplan

Ah, this is a recognized issue…

  • Ensure terraform init runs in both plan and apply jobs to sync remote state from S3.
  • Use ${{ secrets.GC_OAUTH_CLIENT_ID }} directly in env blocks, never echo them.
  • Verify backend.hcl matches the exact S3 bucket and DynamoDB table configured in your AWS account.