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 planon every pull request to validate infrastructure changes and executesterraform applyautomatically 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 Credentialsgrant 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, andorganization:read. - Terraform Version: 1.5.0 or later.
- Genesys Cloud Provider Version: 1.100.0 or later.
- GitHub Repository: A repository with a
mainbranch 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_IDGENESYS_CLIENT_SECRETGENESYS_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:
GENESYS_CLIENT_ID: The client ID from your Genesys Cloud OAuth client.GENESYS_CLIENT_SECRET: The client secret from your Genesys Cloud OAuth client.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_IDorGENESYS_CLIENT_SECRETsecrets 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 Credentialsgrant 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 theapplyjob is not running concurrently with anotherapplyjob 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 ValidateandTerraform Plansteps in GitHub Actions. The plan output posted to the PR comment will often show the specific resource causing the issue. Useterraform validatelocally 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_onin Terraform if implicit dependencies are not detected.