How to create PR based environments in GitHub
Brendan Thompson • 24 February 2022 • 6 min read
Introduction#
This post will talk through the process to create a temporary environment for each PR that developers raise against your repository using the power of GitHub Actions. These ephemeral environments are extremely useful when multiple teams consume different codebase versions.
We will be using a mix of Terraform and GitHub Actions to deliver these environments. Our Terraform state will be stored in Azure blob storage, the app itself will be deployed to our AKS cluster using Terraform. Our build process will be triggered via GitHub Actions.
- Azure Storage Account
- Azure Kubernetes Services
- Azure DNS Zone
- Azure Container Registry
- GitHub Repository
Creating the environment#
If you read my last post Using GitHub Actions to publish container images to Azure Container Registry then you will see a lot of similarities between the code we are going to be producing in this post. Similarly to the post mentioned above, I will break up the GitHub Workflow into its different components and put the whole file at the end.
This first section names our GitHub Actions workflow and sets up the triggers. As we want to trigger
the workflow when we have Pull Requests (PR), we are using the pull_request
trigger type.
name: Deploy PR Environment
on:
pull_request:
types: [opened, reopened, synchronize]
There are three events that we want this to trigger:
- opened — when the PR is opened
- reopened — when/if the PR is reopened
- synchronize — any time new commits are pushed to the branch that the PR is against
Now that we know when the workflow will trigger, we need to set up our environment variables to consume the jobs. As we are also going o be authenticating to Azure with Terraform, we will need to export our provider authentication details to connect to Azure.
env:
IMAGE_NAME: aura-storefront
PR_NUMBER: ${{ github.event.number }}
ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
All the items prefixed with ARM_
are used by the azurerm
Terraform provider. The PR_NUMBER
is
pulled from the github
context, and we will use that to name our deployed resources uniquely and
container images.
jobs:
key but in the final file it will only be shown once.Let's run through the steps in this job:
- Firstly, we checkout the code from our repository
- Authenticate to our Azure Container Registry with the credentials provided via GitHub Actions secrets
- Build our container image, with the PR number as our tag
jobs:
docker:
name: Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Registry Login
uses: docker/login-action@v1
with:
registry: ${{ secrets.ACR_ENDPOINT }}
username: ${{ secrets.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Build & Push
uses: docker/build-push-action@v2
with:
push: true
no-cache: true
tags: ${{ secrets.ACR_ENDPOINT }}/${{ env.IMAGE_NAME }}:${{ env.PR_NUMBER }}
Now that the container image has been built and pushed up to ACR, we need to use it. Let's run through each of the steps:
- Setup defaults for the job, such as which shell to use and the working directory. The latter is important as our Terraform code is in a subdirectory of the repository.
- Check out the repository.
- Execute a
terraform init
on the build agent; in this, we are passing in our backend configuration and ensuring that the state file name aligns with our PR number. - Format our Terraform code to make sure it's clean.
- Run a plan and export that plan to a file for use in the apply.InformationBy outputting the plan to a file, we only need to pass in the Terraform variables a single time, and it ensures that what was shown in the plan is exactly what we are applying.
- Apply the plan that we produced in the previous step!
jobs:
terraform:
name: Terraform
needs: docker
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: terraform
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Terraform
uses: hashicorp/setup-terraform@v1
- name: Terraform Init
run: |
terraform init \
-backend-config="resource_group_name=${{ secrets.TERRAFORM_BACKEND_RESOURCE_GROUP }}" \
-backend-config="storage_account_name=${{ secrets.TERRAFORM_BACKEND_STORAGE_ACCOUNT }}" \
-backend-config="key=${{ env.PR_NUMBER }}.tfstate"
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Plan
run: |
terraform plan \
-var "image_name=${{ secrets.ACR_ENDPOINT }}/${{ env.IMAGE_NAME }}:${{ env.PR_NUMBER }}" \
-var "suffix=${{ env.PR_NUMBER }}" \
-out ${{ env.IMAGE_NAME }}.tfplan
- name: Terraform Apply
run: terraform apply -auto-approve ./${{ env.IMAGE_NAME }}.tfplan
Destroying the environment#
At this point, we are closing our PR and would no longer want the ephemeral environment to exist, so our following GitHub Actions workflow to destroy the environment.
On the destroy, we will be triggering on the type on closed
.
name: Destroy PR Environment
on:
pull_request:
types: [closed]
The same as the creation workflow, we need to set up our environment variable for authentication and the container image details.
env:
IMAGE_NAME: aura-storefront
PR_NUMBER: ${{ github.event.number }}
ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
The destroy job is very similar to the create job; let's go through the steps:
- Setup the defaults for the job
- Checkout our code to the build agent
- Initialize our Terraform code and workspace
- Execute a Terraform plan with the destroy argument
- Apply the destroy plan that we produced, and use the destroy argument
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: terraform
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Terraform
uses: hashicorp/setup-terraform@v1
- name: Terraform Init
run: |
terraform init \
-backend-config="resource_group_name=${{ secrets.TERRAFORM_BACKEND_RESOURCE_GROUP }}" \
-backend-config="storage_account_name=${{ secrets.TERRAFORM_BACKEND_STORAGE_ACCOUNT }}" \
-backend-config="key=${{ env.PR_NUMBER }}.tfstate"
- name: Terraform Plan
run: |
terraform plan -destroy \
-var "image_name=${{ secrets.ACR_ENDPOINT }}/${{ env.IMAGE_NAME }}:${{ env.PR_NUMBER }}" \
-var "suffix=${{ env.PR_NUMBER }}" \
-out ${{ env.IMAGE_NAME }}.tfplan
- name: Terraform Destroy
run: |
terraform apply -destroy \
-auto-approve ./${{ env.IMAGE_NAME }}.tfplan
Now our ephemeral environment has all been cleaned up!
Closing Out#
Today we have gone through how to use GitHub Actions and Terraform to create ephemeral environments whose lifecycle is linked directly to a given Pull Request. We used a workflow for our creation phase, which builds and pushes our container image to our Azure Container Registry, then deploys that container image to an AKS cluster.
Using this method is a fantastic way to have short-lived environments that other teams can use to validate new features or bug fixes.