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.

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:

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.

Let's run through the steps in this job:

  1. Firstly, we checkout the code from our repository
  2. Authenticate to our Azure Container Registry with the credentials provided via GitHub Actions secrets
  3. 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:

  1. 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.
  2. Check out the repository.
  3. 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.
  4. Format our Terraform code to make sure it's clean.
  5. Run a plan and export that plan to a file for use in the apply.
  6. 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:

  1. Setup the defaults for the job
  2. Checkout our code to the build agent
  3. Initialize our Terraform code and workspace
  4. Execute a Terraform plan with the destroy argument
  5. 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.


Brendan Thompson

Principal Cloud Engineer

Discuss on Twitter