
We’ve written in a previous blog post how Terraform helps us manage a lot of infrastructure for several platforms in a consistent manner. Recently we’ve been able to develop an automated workflow for actually applying our Terraform configuration to environments with full review and approval baked in. How? Github actions.
Github actions has been generally available since November 2019 and we had already jumped on board for a number of key tasks:
Towards the end of 2019, I became familar with the standardized Github actions published by HashiCorp for Terraform. These looked like something we could model our workflow on at Rewind. As we developed our workflow, there were a few bumps along the way that I’ll try and highlight in this post.
A small example repository to accompany this post is at rewindio/terraform-rewindio-example
At Rewind, we have several terraform repositories for different pieces of infrastructure. I’ve covered some of the layout in detail in this past post but in general, all of our repositories follow a similar layout that looks something like this

There are separate AWS accounts for staging and production (a fairly common setup). Each account and region within that account requires it’s own .tfvars file containing the account-region specific configuration.
Further, each .tfvars file is tied to it’s own Terraform workspace which is named using the same convention as the .tfvars file. Simply, we use
_
so st-test-results-bucket_us-east-1 is in the staging account, probably has something to do with test results and it’s in the us-east-1 region. All of our terraform templates parse the workspace name and pull out the region (one less thing to configure).
This standardized naming convention will be important when we show how the Github actions work below.

Github secrets are managed on a per-repo basis so if you have a few repos, it can become a challenge to manage these. We created the Github Secrets Manager tool to make this easier across repos. Since this article was originally published, secrets can now also be managed at the organization level.

In both the plan and apply workflows we will outline below, we use the matrix strategy for jobs which allows the workflow to dynamically generate jobs and run them in parallel.
Let’s walk through the details of the plan and apply workflows.

The plan workflow is stored under .github/workflows/tf-plan.yaml and invoked whenever a new pull request is created.
Breaking down the jobs section with examples where warranted. Refer to the example repo in Guthub for the full workflow:
${{ matrix.workspace }}terraform-plan:
strategy:
matrix:
workspace: [st-test-results-bucket_us-east-1,
pd-test-results-bucket_us-east-1]
${{ steps.tfvars.outputs.tfvars_file }}- name: Generate tfvars Path
id: tfvars
run: |
if [[ "${WORKSPACE}" == "st"* ]]; then
echo "::set-output name=tfvars_file::tfvars/staging/${WORKSPACE}.tfvars"
elif [[ "${WORKSPACE}" == "pd"* ]]; then
echo "::set-output name=tfvars_file::tfvars/production/${WORKSPACE}.tfvars"
else
echo "::set-output name=tfvars_file::UNKNOWN"
fi
${{ secrets.GITHUB_TOKEN }}. This is because the standard GITHUB_TOKEN secret only has access to this repo. If you’re using private Terraform modules in other repos, you will not be able to pull them during the init step. You will also need to use a token if you are using git submodules (which only v1.0.0 of the checkout action supports — submodule support has been incredibly removed from v2.0.0)- name: Checkout
uses: actions/[email protected]
with:
submodules: 'true'
token: ${{ secrets.deploy_user_PAT }}
- name: Terraform Plan
id: terraform-plan
uses: rewindio/terraform-github-actions@master
with:
tf_actions_version: ${{ env.TF_VERSION }}
tf_actions_subcommand: plan
args: -var-file backend/backend.tfvars -var-file ${{ steps.tfvars.outputs.tfvars_file }}
env:
TF_WORKSPACE: ${{ env.WORKSPACE }}
AWS_SHARED_CREDENTIALS_FILE: .aws/credentials
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Plan Artifact
uses: actions/upload-artifact@v1
with:
name: terraform-plan-${{ env.WORKSPACE }}
path: ${{ steps.terraform-plan.outputs.tf_actions_plan_output_file }}
That’s the plan workflow. If all works well, you will end up with a comment to the pull request that looks like this:


The second point answered a long standing question I had when using Github actions as to why my workflow sometimes used the yaml file in the master branch rather than the one I was changing!
In looking into all of these, I found this open pull request from Alex Jurkiewicz which essentially solved all of this. After forking the official repo and merging Alex’s great changes, here’s the main pieces of our apply workflow (again, see the example repo for the full workflow)
on: issue_comment: types: [created]
jobs:
terraform-apply:
# Only run for comments starting with "terraform " in a pull request.
if: >
startsWith(github.event.comment.body, 'terraform apply') &&
startsWith(github.event.issue.pull_request.url, 'https://')
strategy: matrix: workspace: [st-test-results-bucket_us-east-1, pd-test-results-bucket_us-east-1]
env_name indicating staging or production. Later we can use this for applying to all workspaces or only staging or only production.- name: 'Load PR Details'
id: load-pr
run: |
set -eu
resp=$(curl -sSf
--url ${{ github.event.issue.pull_request.url }}
--header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}'
--header 'content-type: application/json')
sha=$(jq -r '.head.sha'
( do_apply ) which we can check in subsequent workflow steps.- name: Determine Command
id: determine-command
uses: actions/[email protected]
env:
ENV_NAME: ${{ steps.tfvars.outputs.env_name }}
with:
github-token: ${{github.token}}
script: |
// console.log(context)
const body = context.payload.comment.body.toLowerCase().trim()
console.log("Detected PR comment: " + body)
console.log("This job is for workspace " + process.env.WORKSPACE)
commandArray = body.split(/s+/)
if (commandArray[0] == "terraform") {
action = commandArray[1]
switch(action) {
case "apply":
if(typeof commandArray[2] === 'undefined') {
console.log("::set-output name=do_apply::true")
} else if (commandArray[2] == process.env.WORKSPACE) {
console.log("::set-output name=do_apply::true")
} else if (commandArray[2] == process.env.ENV_NAME) {
console.log("::set-output name=do_apply::true")
} else {
console.log("apply command is not for this job")
}
break
}
}
if condition to only run this step if the previous step determined we are doing an apply for this workspace. This is also present in all subsequent steps.ref option- name: Checkout
if: steps.determine-command.outputs.do_apply == 'true'
uses: actions/[email protected]
with:
ref: ${{ steps.load-pr.outputs.head_sha }}
submodules: 'true'
token: ${{ secrets.deploy_user_PAT }}
if condition as with the checkout to only initialize when needed. We can use the regular GITHUB token here as the action only uses it to comment back to thispull request- name: Terraform Init
uses: rewindio/terraform-github-actions@master
if: steps.determine-command.outputs.do_apply == 'true'
with:
tf_actions_version: ${{ env.TF_VERSION }}
tf_actions_subcommand: init
args: -backend-config backend/backend.tfvars
env:
TF_WORKSPACE: ${{ env.WORKSPACE }}
AWS_SHARED_CREDENTIALS_FILE: .aws/credentials
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tf_actions_comment_url — this is the new parameter added to allow the apply command output to be commented back to the pull request. The URL itself comes from the load-pr stepargs — the .tfvars file is determined in the tfvars step and used here in the apply. Remember there are multiple jobs running concurrently in the matrix strategyAWS_SHARED_CREDENTIALS_FILE — this is needed because the usual path and home variables that allow AWS SDKs to load credentials are not automatically set in Github actions.- name: Terraform Apply
if: steps.determine-command.outputs.do_apply == 'true'
uses: rewindio/terraform-github-actions@master
with:
tf_actions_version: ${{ env.TF_VERSION }}
tf_actions_subcommand: apply
tf_actions_comment: true
tf_actions_comment_url: ${{ steps.load-pr.outputs.comments_url }}
args: -var-file backend/backend.tfvars -var-file ${{ steps.tfvars.outputs.tfvars_file }}
env:
TF_WORKSPACE: ${{ env.WORKSPACE }}
AWS_SHARED_CREDENTIALS_FILE: .aws/credentials
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Here’s what the output looks like back to the pull request:


terraform help. The steps to get the pull request details and checkout the code have been covered but here’s the step to output the help:
- name: Show Help
env:
COMMENTS_URL: ${{ steps.load-pr.outputs.comments_url }}
run: |
set -eu
echo "Sending help text to: $COMMENTS_URL"
helpPayload=$(cat .github/workflows/tf-help.md | jq -R --slurp '{body: .}')
resp=$(echo $helpPayload | curl -sSf
--url $COMMENTS_URL
--data @-
--header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}'
--header 'content-type: application/json')
echo "Adding comment returned: $resp"

This article originally appeared in the Rewind.io blog and has been published here with permission.