Automate Genesys Cloud Infrastructure with Terraform in a GitHub Actions CI/CD Pipeline

Automate Genesys Cloud Infrastructure with Terraform in a GitHub Actions CI/CD Pipeline

What You Will Build

  • This tutorial builds a GitHub Actions workflow that executes terraform plan on every pull request to validate infrastructure changes and executes terraform apply automatically when the pull request is merged into the main branch.
  • This uses the Genesys Cloud Terraform Provider (mypurecloud/genesyscloud) and GitHub Actions native runners.
  • The programming language covered is HCL for Terraform configuration and YAML for the GitHub Actions workflow definition, with shell scripts for execution logic.

Prerequisites

  • OAuth Client Type: Machine-to-Machine (M2M) OAuth Client. You must create a client in the Genesys Cloud Admin Console with Client Credentials grant type.
  • Required Scopes: The client needs scopes matching the resources you manage. For a typical setup, include admin, user:read, routing:queue:write, routing:skill:write, and organization:read.
  • Terraform Version: 1.5.0 or later.
  • Genesys Cloud Provider Version: 1.100.0 or later.
  • GitHub Repository: A repository with a main branch protection rule that requires status checks to pass before merging.
  • GitHub Secrets: You must store the Genesys Cloud OAuth credentials and region in GitHub Secrets:
    • GENESYS_CLIENT_ID
    • GENESYS_CLIENT_SECRET
    • GENESYS_REGION (e.g., us-east-1)

Authentication Setup

The Genesys Cloud Terraform provider does not use static API keys. It uses OAuth 2.0 Client Credentials flow. The provider handles the token exchange internally, but you must provide the client ID and secret securely.

In your GitHub repository, navigate to Settings > Secrets and variables > Actions. Add the following repository secrets:

  1. GENESYS_CLIENT_ID: The client ID from your Genesys Cloud OAuth client.
  2. GENESYS_CLIENT_SECRET: The client secret from your Genesys Cloud OAuth client.
  3. GENESYS_REGION: Your Genesys Cloud region endpoint (e.g., us-east-1, eu-west-1).

The Terraform provider configuration (provider.tf) must reference these environment variables. This ensures credentials are never hardcoded in the repository.

# provider.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    genesyscloud = {
      source  = "mypurecloud/genesyscloud"
      version = "~> 1.100.0"
    }
  }

  # Use a local backend for simplicity in this tutorial.
  # In production, use S3, GCS, or Azure Blob Storage for remote state locking.
  backend "local" {
    path = "terraform.tfstate"
  }
}

provider "genesyscloud" {
  # The provider reads these from environment variables set in the GitHub Action
  client_id     = var.genesys_client_id
  client_secret = var.genesys_client_secret
  region        = var.genesys_region
}

Implementation

Step 1: Define Terraform Variables and State

You must define inputs for the sensitive credentials so they can be injected from GitHub Secrets. Create a variables.tf file.

# variables.tf

variable "genesys_client_id" {
  description = "The OAuth Client ID for Genesys Cloud"
  type        = string
  sensitive   = true
}

variable "genesys_client_secret" {
  description = "The OAuth Client Secret for Genesys Cloud"
  type        = string
  sensitive   = true
}

variable "genesys_region" {
  description = "The Genesys Cloud region (e.g., us-east-1)"
  type        = string
  default     = "us-east-1"
}

# Example resource variable
variable "queue_name" {
  description = "Name of the support queue to create"
  type        = string
  default     = "CI-Test-Support-Queue"
}

Create a simple resource to test the pipeline. This example creates a Routing Queue.

# main.tf

resource "genesyscloud_routing_queue" "support_queue" {
  name        = var.queue_name
  description = "Queue managed by Terraform CI/CD pipeline"
  
  # Basic settings to ensure the resource is valid
  enable_apd = false
  wrap_up_policy = "optional"
  
  # Note: In a real scenario, you would define members, skills, and other complex attributes
}

Step 2: Configure GitHub Actions Workflow

Create the file .github/workflows/terraform-cicd.yml. This workflow defines two jobs: plan and apply.

The plan job runs on every pull request. It initializes Terraform, plans the changes, and posts the plan output as a comment on the PR. It also sets a check status to block merging if the plan fails.

The apply job runs only when code is pushed to the main branch (i.e., after a merge). It applies the changes to the Genesys Cloud environment.

name: Genesys Cloud Terraform CI/CD

on:
  pull_request:
    branches:
      - main
    paths:
      - '**.tf'
      - '.github/workflows/terraform-cicd.yml'
  push:
    branches:
      - main
    paths:
      - '**.tf'
      - '.github/workflows/terraform-cicd.yml'

# Environment variables for the entire workflow
env:
  TF_IN_AUTOMATION: 1
  TF_CLI_ARGS_init: "-backend-config=backend.hcl" # Optional if using remote backend
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    outputs:
      plan_success: ${{ steps.plan-outcome.outcome == 'success' }}
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

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

      - name: Terraform Init
        run: terraform init
        env:
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        id: plan-step
        run: |
          terraform plan -no-color -out=tfplan || true
          # Capture the plan output
          terraform show -no-color tfplan > plan_output.txt
        env:
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Post Plan Comment
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            let planOutput = fs.readFileSync('plan_output.txt', 'utf8');
            
            // Truncate long outputs to avoid GitHub comment limits
            if (planOutput.length > 60000) {
              planOutput = planOutput.substring(0, 60000) + '\n\n... (output truncated)';
            }
            
            const header = `### Terraform Plan Output\n\`\`\`terraform\n${planOutput}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: header
            });

      - name: Check Plan Outcome
        id: plan-outcome
        run: |
          if [ -f tfplan ]; then
            # Check if there are changes
            if terraform show -json tfplan | jq -e '.resource_changes | length > 0' > /dev/null; then
              echo "Changes detected."
            else
              echo "No changes detected."
            fi
            exit 0
          else
            echo "Plan failed or did not produce output."
            exit 1
          fi

  apply:
    name: Terraform Apply
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

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

      - name: Terraform Init
        run: terraform init
        env:
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Terraform Apply
        run: terraform apply -auto-approve -input=false
        env:
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

Step 3: Handle State Locking and Remote Backend

Using a local backend (terraform.tfstate file) works for single-developer testing but fails in CI/CD because the state file is not shared between the plan and apply jobs, nor across different PRs. You must use a remote backend with state locking.

For Genesys Cloud integrations, AWS S3 is the standard choice. Create an S3 bucket and a DynamoDB table for locking.

Update backend.hcl (or inline in provider.tf):

# backend.hcl
bucket  = "my-genesys-terraform-state"
key     = "genesys/infrastructure/terraform.tfstate"
region  = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt  = true

Update the variables.tf to include backend configuration if you want to parameterize it, or keep it static in the backend config file. In the GitHub Action, you must pass the backend config during terraform init.

Modify the Terraform Init step in the workflow:

      - name: Terraform Init
        run: terraform init -backend-config=backend.hcl
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

You must also add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to your GitHub Secrets. These keys require permissions to read/write the S3 bucket and update the DynamoDB table.

Complete Working Example

Here is the full directory structure and file contents for a minimal, working repository.

Directory Structure:

.
├── .github/
│   └── workflows/
│       └── terraform-cicd.yml
├── backend.hcl
├── main.tf
├── provider.tf
└── variables.tf

.github/workflows/terraform-cicd.yml

name: Genesys Cloud Terraform CI/CD

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

env:
  TF_IN_AUTOMATION: 1
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

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

      - name: Terraform Init
        run: terraform init -backend-config=backend.hcl
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: |
          terraform plan -no-color -out=tfplan
          terraform show -no-color tfplan > plan_output.txt
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Post Plan Comment
        uses: actions/github-script@v7
        if: always()
        with:
          script: |
            const fs = require('fs');
            let planOutput = fs.readFileSync('plan_output.txt', 'utf8');
            if (planOutput.length > 60000) {
              planOutput = planOutput.substring(0, 60000) + '\n\n... (output truncated)';
            }
            const header = `### Terraform Plan Output\n\`\`\`terraform\n${planOutput}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: header
            });

  apply:
    name: Terraform Apply
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

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

      - name: Terraform Init
        run: terraform init -backend-config=backend.hcl
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

      - name: Terraform Apply
        run: terraform apply -auto-approve -input=false
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          GENESYS_CLIENT_ID: ${{ secrets.GENESYS_CLIENT_ID }}
          GENESYS_CLIENT_SECRET: ${{ secrets.GENESYS_CLIENT_SECRET }}
          GENESYS_REGION: ${{ secrets.GENESYS_REGION }}

backend.hcl

bucket  = "my-genesys-terraform-state"
key     = "genesys/infrastructure/terraform.tfstate"
region  = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt  = true

provider.tf

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    genesyscloud = {
      source  = "mypurecloud/genesyscloud"
      version = "~> 1.100.0"
    }
  }
}

provider "genesyscloud" {
  client_id     = var.genesys_client_id
  client_secret = var.genesys_client_secret
  region        = var.genesys_region
}

variables.tf

variable "genesys_client_id" {
  description = "The OAuth Client ID for Genesys Cloud"
  type        = string
  sensitive   = true
}

variable "genesys_client_secret" {
  description = "The OAuth Client Secret for Genesys Cloud"
  type        = string
  sensitive   = true
}

variable "genesys_region" {
  description = "The Genesys Cloud region"
  type        = string
  default     = "us-east-1"
}

variable "queue_name" {
  description = "Name of the support queue"
  type        = string
  default     = "CI-Test-Queue"
}

main.tf

resource "genesyscloud_routing_queue" "support_queue" {
  name        = var.queue_name
  description = "Queue managed by Terraform CI/CD"
  enable_apd  = false
  wrap_up_policy = "optional"
}

Common Errors & Debugging

Error: 401 Unauthorized or Invalid credentials

  • Cause: The GENESYS_CLIENT_ID or GENESYS_CLIENT_SECRET secrets in GitHub are incorrect, or the OAuth client in Genesys Cloud is disabled.
  • Fix: Verify the secrets in GitHub Settings. Ensure the OAuth client in Genesys Cloud Admin Console is active and has the Client Credentials grant type enabled. Check that the client has the necessary scopes (e.g., admin).

Error: 403 Forbidden or Access Denied

  • Cause: The OAuth client lacks the required scopes to perform the action (e.g., creating a queue requires routing:queue:write).
  • Fix: In the Genesys Cloud Admin Console, go to Platform > OAuth Clients, edit your client, and add the missing scopes. Note that scope changes may take up to 15 minutes to propagate.

Error: State Lock Timeout

  • Cause: Another Terraform operation is running and holding the lock in DynamoDB, or a previous run failed and did not release the lock.
  • Fix: Check the DynamoDB table terraform-locks. If you find a stale lock entry, delete it manually. In GitHub Actions, ensure the apply job is not running concurrently with another apply job on the same branch.

Error: Plan Failed with Exit Code 1

  • Cause: Syntax error in HCL or invalid configuration for a Genesys Cloud resource.
  • Fix: Review the logs from the Terraform Validate and Terraform Plan steps in GitHub Actions. The plan output posted to the PR comment will often show the specific resource causing the issue. Use terraform validate locally to catch syntax errors before pushing.

Error: Resource Not Found on Apply

  • Cause: The Genesys Cloud provider is trying to update or delete a resource that does not exist in the state but is referenced in the code, or the API returned a 404 for a dependent resource.
  • Fix: Ensure that any dependencies (e.g., users, skills, skill groups) are created before the resource that depends on them. Use depends_on in Terraform if implicit dependencies are not detected.

Official References