Genesys Cloud Terraform CI/CD: plan on PR, apply on merge?

Hey folks,

Trying to wire up a proper CI/CD pipeline for our Genesys Cloud infrastructure using the Terraform provider. The goal is to run terraform plan automatically on every pull request to catch drift before we merge, then run terraform apply only when the PR gets merged into main. I’ve got a GitHub Actions workflow set up, but I’m stuck on handling the state locking and environment variables securely. When the plan runs on the PR branch, it seems to be picking up the state from the main branch, which is fine, but I want to make sure we aren’t accidentally locking the state for too long if someone abandons a PR. Here’s the snippet I’m working with for the plan step:

- name: Terraform Plan
 run: |
 terraform init
 terraform plan -out=tfplan
 env:
 GENESYS_CLOUD_REGION: ${{ secrets.GENESYS_REGION }}
 TF_VAR_CLIENT_ID: ${{ secrets.TF_CLIENT_ID }}

Is there a standard pattern for managing the state file location per branch vs main, or should I just be careful with the workspace naming? The apply step is straightforward, but I’m worried about race conditions if two PRs merge at the same time. Also, how are you guys handling the OAuth token refresh in the apply step since the token might expire between the plan on PR and the apply on merge if there’s a delay?

You’re right to worry about state locking. If your plan runs on a PR branch but doesn’t have exclusive access to the state file, you’ll get conflicts. The trick isn’t just about the workflow steps, it’s about how you scope the state.

For Genesys Cloud, I’d strongly recommend splitting your state files by deployment unit or even by resource type if they’re loosely coupled. But assuming you want a single state file for now, you need to ensure the PR run is read-only regarding state locks where possible, or that the lock is released immediately.

Here is a pattern that works well with GitHub Actions and the Genesys Cloud provider. The key is setting GENESYS_CLOUD_OAUTH_CLIENT_ID and GENESYS_CLOUD_OAUTH_CLIENT_SECRET as repository secrets, not environment variables in the workflow file itself.

name: Genesys Cloud Terraform CI/CD

on:
 pull_request:
 branches: [ main ]
 paths:
 - '**/*.tf'
 push:
 branches: [ main ]
 paths:
 - '**/*.tf'

jobs:
 terraform-plan:
 if: github.event_name == 'pull_request'
 runs-on: ubuntu-latest
 steps:
 - name: Checkout code
 uses: actions/checkout@v3

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v2
 with:
 terraform_version: 1.5.0

 - name: Init
 run: terraform init -backend-config="access_key=${{ secrets.AWS_ACCESS_KEY_ID }}" -backend-config="secret_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}"

 - name: Plan
 id: plan
 run: terraform plan -no-color -var="oauth_client_id=${{ secrets.GENESYS_CLOUD_OAUTH_CLIENT_ID }}" -var="oauth_client_secret=${{ secrets.GENESYS_CLOUD_OAUTH_CLIENT_SECRET }}" -out=tfplan
 env:
 TF_IN_AUTOMATION: true
 TF_INPUT: false

 - name: Upload Plan
 if: always()
 uses: actions/upload-artifact@v3
 with:
 name: tfplan
 path: tfplan

 - name: Comment Plan on PR
 if: always()
 uses: actions/github-script@v6
 with:
 script: |
 const fs = require('fs');
 const plan = fs.readFileSync('tfplan', 'utf8');
 github.rest.issues.createComment({
 issue_number: context.issue.number,
 owner: context.repo.owner,
 repo: context.repo.repo,
 body: '## Terraform Plan Output\n```' + plan + '```'
 })

 terraform-apply:
 if: github.event_name == 'push' && github.ref == 'refs/heads/main'
 needs: terraform-plan
 runs-on: ubuntu-latest
 steps:
 - name: Checkout code
 uses: actions/checkout@v3

 - name: Setup Terraform
 uses: hashicorp/setup-terraform@v2
 with:
 terraform_version: 1.5.0

 - name: Init
 run: terraform init -backend-config="access_key=${{ secrets.AWS_ACCESS_KEY_ID }}" -backend-config="secret_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}"

 - name: Apply
 run: terraform apply -auto-approve -var="oauth_client_id=${{ secrets.GENESYS_CLOUD_OAUTH_CLIENT_ID }}" -var="oauth_client_secret=${{ secrets.GENESYS_CLOUD_OAUTH_CLIENT_SECRET }}"
 env:
 TF_IN_AUTOMATION: true
 TF_INPUT: false

One thing to watch out for is the terraform init step. If you’re using a remote backend like S3 or Terraform Cloud, make sure the credentials for the backend are separate from the Genesys Cloud API credentials. Mixing them up causes confusing errors.

Also, Genesys Cloud APIs can be rate-limited. If your plan is huge, you might hit limits. You can add a retry logic in the apply step if needed, but usually, the provider handles basic retries. Just make sure your OAuth client has the right scopes. admin:all is easiest for testing, but for production, you should narrow it down to routing:all, organization:all, etc.

State locking is handled by the backend. If you’re using S3 with DynamoDB, the lock is automatic. If you’re using local state, you’re going to have a bad time with CI/CD. Don’t use local state for shared projects.

The comment on PR step is really useful for reviewers. They can see exactly what will change without running terraform locally. It helps catch accidental deletions of queues or users.

One more thing. If you have multiple environments (dev, staging, prod), you’ll want to separate the state files completely. Maybe use dev.tfstate and prod.tfstate. You can do this with different backend configs or different directories. Mixing environments in one state file is a recipe for disaster.

I’ve seen teams try to use variables to switch environments within one state file. It works until it doesn’t. When you delete a resource in dev, it might try to delete it in prod if the IDs match up weirdly. Just keep them separate.

The workflow above is a good starting point. You can tweak the conditions based on your branch protection rules. Make sure you require the plan job to pass before merging. That way, you never apply broken infrastructure.

Also, check your Terraform provider version. Genesys Cloud updates their APIs frequently. An old provider might miss new features or break on API changes. Pin your provider version in versions.tf so you know exactly what you’re running.

terraform {
 required_providers {
 genesyscloud = {
 source = "mygenesys/genesyscloud"
 version = "~> 1.0"
 }
 }
}

This keeps things stable. You can update the version when you’re ready to test new features.

If you run into issues with the plan output being too long for the comment, you can truncate it or upload it as an artifact and link to it. GitHub comments have a character limit.

Hope this helps you get your pipeline running. It’s a bit of setup, but it pays off when you can push changes without worrying about manual errors.