Branch-based environments. GitHub Actions and Terraform how-to

In previous articles from this series I’ve written about tools to ensure seamless deployments and automations of your processes for your SaaS application, such as Terraform to manage your infrastructure, CI/CD pipelines, and CodeDeploy to deploy the code. I also elaborated on the concept of environments and why you’d want to implement those.

Now I have an opportunity to put those pieces together. I’ll show you how to get your hands dirty with something you can actually deploy. This blog post is meant to be a follow along tutorial explaining the basics of how you can implement branch-based environments, using Terraform and GitHub Actions (GHA). For a full code, please refer to this GitHub repository.

Photo of tree's branches.

Branch-based structure can help with organizing your environments. Photo by Mila Tovar

For this blog article, an environment will be based on a specific source code branch that contains proposed application changes, something that developers can work on and test out before merging into the main branch. I’ve defined it like this because both for the source code and from developers’ perspective, a branch is the basic unit of work. If you want to read more about this approach, feel free to refer to my previous blog post.

Prerequisites

To follow this article like a tutorial, you will need an AWS account and a GitHub repository. I’m using OIDC to allow GitHub to call AWS APIs; this is much less of a risk than storing a privileged access key into GitHub. We like this approach a lot and also use it in our consulting packages, such as the container platform. I recommend setting up the OIDC integration as in this article. However, if you set it up another way and it works for you, that’s fine too - this is a tutorial not production code.

Overview

First, we need to define a piece of infrastructure. In my case, it will be a simple static website hosted on AWS S3, but in your real-world application it will probably be something closer to AWS ECS with CodeDeploy. Then, automations will be created via GitHub Actions.

I’ll store GitHub Actions in one directory (.github/workflows), whereas my workload infrastructure is defined in the s3_bucket directory. For real infrastructure, you might want to add a third directory with an infrastructure shared among all the environments, e.g., AWS VPC, subnets, Internet Gateway, NAT Gateway. For the purposes of this blog article I won’t need any shared infrastructure because S3-hosted websites have a very simple architecture; if you’re following along at home I strongly encourage you to experiment here.

Terraform code

Terraform is a simple and easy to use infrastructure-as-code (IaC) tool to define cloud infrastructure, keep track of your infrastructure state and manage changes to it in one place. I’m already assuming you’re familiar with the basics of it, so here I’ll present only the chunks of it. For the full code, please refer to the GitHub repository.

There are two important considerations when building a workload Terraform skeleton to account for scalability across branch-based environments. Both of these are aimed at ensuring not to overwrite the same cloud resources when spawning a different environment.

  1. Name of resources. Parameterise the name of your environment as a variable (it will be a branch name, and later on I’ll explain how the GitHub Action can pass this into your infrastructure code). Use that variable whenever a resource has a name argument reference. For example, given a following definition of a variable:
variable "environment" {
  description = "Environment to deploy workload"
  type        = string
  default     = "dev"
  nullable    = false
}

I recommend adding description, type and a default value as good practice. Next, define resource names in the s3_bucket directory using ${var.environment}, as follows:

resource "aws_s3_bucket" "website" {
  bucket = "website-${var.environment}-${data.aws_caller_identity.current.account_id}"

  # Must force destroy since buckets will have objects
  force_destroy = true
}
  1. Terraform state needs to be stored separately for different environments. For easy environment management in Terraform, I could use terragrunt or Terraform workspaces, which would help me with state separation. But I want to keep it simple and not to use more dependencies than necessary, so I could also set Terraform backend configuration dynamically to account for different state file names. In my case, they will be keys (i.e., URL paths) used in the S3 backend configuration. The dynamic backend configuration for the unique key will be described in the next section, as it needs to be done via GHA.

The important thing for now is to understand the concept and note that my main.tf would omit specifying a key and look like:

terraform {

  backend "s3" {
   # don't set "key" in this file; it gets set somewhere else
    bucket = "demo-terraform-state-${data.aws_caller_identity.current}" 
                                    # pick a bucket name that won't collide
                                    # (bucket names in S3 are global)
    region = "eu-west-1"
  }
}

provider "aws" {
  region = "eu-west-1"
}

Deployment

To spawn my infrastructure I’ve created .github/workflows/build_workload_infra.yaml. Firstly, I’ll give the name to the workflow, and define it to run on workflow dispatch (GitHub jargon for manual triggering). You trigger the workflow pushing the button in the Actions part of the GitHub web UI, with the option to select which branch the action definition should come from.

name: 'Deploy workload infrastructure'

on:
  workflow_dispatch:

If you prefer you can consider automatic triggers, such as push or pull_request with the possibility of specifying a branch (e.g. only on pull request to main branch). Then, I extract the branch name and make it available as a GHA environment variable: this is part of how the infrastructure code knows what to deploy. I’m doing that using an env block.

env:
 BRANCH_NAME: ${{ github.head_ref || github.ref_name }} 

In the next part, I configure permissions for the GHA authentication mechanism to AWS and define my terraform_workload job, which consists of several steps.

permissions:
  id-token: write
  contents: read

jobs:
  terraform_workload:
    name: 'Deploy workload infrastructure using Terraform'
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
        working-directory: ‘s3_bucket’

Now I can list steps which I want to execute in my terraform_workload job. First, I configure AWS credentials using aws-actions/configure-aws-credentials@v2 action, indicating a role to assume for OIDC integration (see this article). Next, I checkout my code and set up the Terraform with the desired version. Note that I’ve parametrized my AWS account ID in this step, by creating a AWS_ACCOUNT_DEMO variable in my repository settings beforehand (Settings → Secrets and values → actions). Now I can refer to its value using ${{vars.AWS_ACCOUNT_DEMO}} from Actions defined by me.

    steps:
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v2
      with:
        role-to-assume: arn:aws:iam::${{vars.AWS_ACCOUNT_DEMO}}:role/github_action_role
        aws-region: eu-west-1

    - name: Checkout
      uses: actions/checkout@v3

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

As you may remember from the previous terraform section, there are a few gotchas around creating multiple environments: ensuring separation for Terraform state, and unique naming of Terraform resources. In the env block, I’ve extracted the branch name as a GHA environment variable, and I can now dynamically configure the backend using it as a key.

    - name: Terraform init
      run: terraform init -backend-config="key=${{ env.BRANCH_NAME }}"

Next, I format the Terraform code, as well as plan changes and apply them. I do that using terraform plan with -out terraform-plan option. This allows me to save it and reuse it when applying. -input=false flag prevents Terraform from prompting for input, as it is executed automatically.

Normally using CI/CD I would need --auto-approve option, for the Terraform not to prompt to approve the changes. This is not required when we are using the plan before.

Please note that I’m using the -var 'environment=${{ env.BRANCH_NAME }}' option for the terraform plan. This accounts for unique naming of Terraform resources for separate environments, giving them suffixes of a branch name. This happens by plugging my GHA environment variable from env block to the Terraform code as its own variable (defined under the name environment, as in the previous section). For example, deploying from the test branch, the name of the bucket (website-${var.environment}-${data.aws_caller_identity.current.account_id}) will translate to website-test-123456789012.

Finally, I apply the changes by executing terraform apply and specifying the saved plan name - in my case, terraform-plan.

    - name: Terraform format
      run: terraform fmt -check

    - name: Terraform plan & apply
      run: |
       terraform plan --var 'environment=${{ env.BRANCH_NAME }}' --input=false --out terraform-plan
        terraform apply terraform-plan       

Now you could view your webpage by going to http://<bucket name>.s3-website.<aws region>.amazonaws.com/ - for example, deploying from the test branch in eu-west-1 would result in: website-test-123456789012.s3-website.eu-west-1.amazonaws.com URL. Remember that S3 doesn’t support HTTPS for website endpoints.

If you deployed from two different branches, you would see two different “Hello world” messages in each bucket.

Real world scenario

If you want to avoid people deploying from specific branches, e.g. from the live branch, have a look at Using conditions to control job execution.

In a real world scenario you may want to use this approach in a more complex application architecture. For example: AWS ECS with CodeDeploy. In this case, you can store your app code in a separate directory (or even repository), and define the deployment workflow as I explained in Using CodeDeploy with Terraform and GitHub Actions.

Maybe you have different kinds of code in the same repository. When you update component A, you’ve no need to push a new image for component B. Another thing you may want to tune is to trigger this workflow automatically (on: push or on: pull_request), but only when application files in a particular directory have changed (using paths).

When defining your deployment workflow for container images, you can extract the Git commit SHA to the GHA environment variable using GITHUB_SHA: ${{ github.sha }} definition in an env block (similar to the variable containing the branch name) to use it as an unique container image tag.

Last, but not least, don’t forget to include a workflow to destroy the Terraform infrastructure on the desired environment. It’s worth having an automatic trigger next to the manual one and destroying infrastructure on the branch delete. You don’t want that to happen to your live environment, so take extra care to make sure you’re protecting that one.

on:
  workflow_dispatch:
  delete:
    branches:

When writing a destroy workflow, just remember to account for a state file only for a specific environment, as well as the name of the directory where the workflow executes. You don’t want to wipe either other environments, or any of the shared infrastructure. You’ll find an example chunk of code below.

   - name: Terraform init
      run: terraform init -backend-config="key=${{ env.BRANCH_NAME }}"

    - name: Terraform plan destroy
      run: |
         terraform plan -destroy --var=environment=${{ env.BRANCH_NAME }} --input=false --out=destroy-plan
          terraform apply destroy-plan         

Summary

At this point, thanks to connecting components from CI/CD and IaC toolbox, I have defined a convenient automation to provision infrastructure and deploy applications in a fast and easy-to-rollback way. You can do the same for your infrastructure. This process is easily scalable: you can repeat it for multiple environments independently and effortlessly - as all is needed is just triggering the workflow (or just push or pull request, if you decide to do so). Separate branch-based environments allow testing work before pushing to the main branch, and that testing can happen independently from other work in progress. The approach I’ve outlined can positively contribute to team productivity and help you deliver faster, as well as more robust, releases.


We offer hands-on AWS training as part of our SaaS Growth subscription, to help your team make the best use of the AWS cloud. Book a free chat to find out more.

For some topics, you can also get the same training just on the topic you need - see our Terraform training and Kubernetes training pages.


This blog is written exclusively by The Scale Factory team. We do not accept external contributions.

Free Healthcheck

Get an expert review of your AWS platform, focused on your business priorities.

Book Now

Discover how we can help you.


Consulting packages

Advice, engineering, and training, solving common SaaS problems at a fixed price.

Learn more >

Growth solutions

Complete AWS solutions, tailored to the unique needs of your SaaS business.

Learn more >

Support services

An ongoing relationship, providing access to our AWS expertise at any time.

Learn more >