Julio Jimenez
Essential DevOps

Essential DevOps

GitHub Workflows for Terraform

GitHub Workflows for Terraform

Stop pushing IaC from your local env, please, just stop.

Julio Jimenez's photo
Julio Jimenez
·Nov 5, 2021·

5 min read

Subscribe to my newsletter and never miss my upcoming articles

In this article we will be building two GitHub Workflows. One will create a plan of your Terraform configuration on each push to a pull request, the other will plan and apply the configuration when the pull request is merged to the default branch.

For the majority of the article, everything will be almost the same for both workflow files. The apply step will be done exclusively in its corresponding workflow.

An excellent resource to have handy is Workflow Syntax for Github Actions. It has everything you need to get super fancy with this stuff.

Create Workflow Files

First thing's first, otherwise, it wouldn't be. From the root of your repository, create your files in the following paths:

mkdir -p .github/workflows
touch .github/workflows/plan.yml
touch .github/workflows/apply.yml

Now open both of these files in your favorite code editor.

"The Top Part"

In other words anything above jobs: which we will see below.

plan.yml

name: Terraform Plan
on:
  pull_request:
    branches:
      - main

env:
  TF_VERSION: 1.0.8
  TF_VAR_terraform-state-bucket: 'my-tfstate-bucket'
  TF_VAR_terraform-state-bucket-key: 'terraform.tfstate'
  TF_VAR_terraform-state-bucket-aws-region: 'us-east-1'
  TF_VAR_terraform-dynamodb-state-locking-table-name: 'my-tfstate-lock'
  TF_VAR_aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
  TF_VAR_aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

apply.yml

name: Terraform Apply
on:
  push:
    branches:
      - main

env:
  TF_VERSION: 1.0.8
  TF_VAR_terraform-state-bucket: 'my-tfstate-bucket'
  TF_VAR_terraform-state-bucket-key: 'terraform.tfstate'
  TF_VAR_terraform-state-bucket-aws-region: 'us-east-1'
  TF_VAR_terraform-dynamodb-state-locking-table-name: 'my-tfstate-lock'
  TF_VAR_aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
  TF_VAR_aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Not much is different between the plan and apply workflows, except for one key detail, the triggers.

In plan.yml we are triggering the work flow on pull_requests to branches main. In English, whenever we push a commit to a pull request that is pointed to the main branch, this workflow will be executed.

In the case of apply.yml, the workflow is executed whenever a commit is pushed to the main branch. Couldn't figured out how to get branches in that sentence, but you get the idea. Note: Avoid randos from pushing to your main (or default) branch by configuring proper branch protections.

The environment variables assume we are configuring against Terraform 1.0.8, have an Amazon S3 bucket named my-tfstate-bucket, will be saving our state to a file (they're called objects in S3) named terraform.tfstate, in region us-east-1.

We will also be locking our state file while planning and applying configuration, to avoid simultaneous changes to the state by multiple users, a DynamoDB table named my-tfstate-lock will be used for this purpose.

AWS credentials have been saved as repository (or organization, for GitHub Teams and Enterprise users) secrets and accessed in the workflow using the ${{ secrets.AWS_ACCESS_KEY_ID }} and ${{ secrets.AWS_SECRET_ACCESS_KEY }} stubs.

Job and Steps

plan.yml

jobs:
  terraform:
    name: Plan
    runs-on: ubuntu-latest

apply.yml

jobs:
  terraform:
    name: Apply
    runs-on: ubuntu-latest

The only difference above is the name of the job that will be printed on screen while in the Checks tab of a pull request, or the Actions tab of the repository.

The internal names of the jobs is terraform, this is an arbitrary YAML compliant key.

Finally, the jobs will be running the latest supported Ubuntu GitHub Hosted Runner.

Here are the steps, which are the same for both workflows.

steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1.2.1
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: |
          terraform init \
          -backend-config="dynamodb_table=${{ env.TF_VAR_terraform-dynamodb-state-locking-table-name }}" \
          -backend-config="access_key=${{ env.TF_VAR_aws-access-key }}" \
          -backend-config="secret_key=${{ env.TF_VAR_aws-secret-key }}" \
          -backend-config="bucket=${{ env.TF_VAR_terraform-state-bucket }}" \
          -backend-config="key=${{ env.TF_VAR_terraform-state-bucket-key }}" \
          -backend-config="region=${{ env.TF_VAR_terraform-state-bucket-aws-region }}"          

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan

Pretty straightforward stuff, eh? First we checkout our repository using the actions/checkout@v2, then we use another action, hashicorp/setup-terraform@v1.2.1, to setup Terraform with the version defined in the TF_VERSION environment variable.

We then run terraform init which sets up the backend using the values from the rest of our environment variables, terraform validate to lint our configuration, and terraform plan to do just that!

This completes plan.yml, but there is one more step we must add to apply.yml to call this one done.

- name: Terraform Apply
        run: terraform apply -auto-approve

Whoop there it is! Now we are applying the configuration as soon as the pull request is merged. Adding -auto-approve skips interactive approval of plan before applying.

The whole #!

Below are both complete workflows just in case you missed something.

.github/workflows/plan.yml

name: Terraform Plan
on:
  pull_request:
    branches:
      - main

env:
  TF_VERSION: 1.0.8
  TF_VAR_terraform-state-bucket: 'my-tfstate-bucket'
  TF_VAR_terraform-state-bucket-key: 'terraform.tfstate'
  TF_VAR_terraform-state-bucket-aws-region: 'us-east-1'
  TF_VAR_terraform-dynamodb-state-locking-table-name: 'my-tfstate-lock'
  TF_VAR_aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
  TF_VAR_aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: Plan
    runs-on: ubuntu-latest

steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1.2.1
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: |
          terraform init \
          -backend-config="dynamodb_table=${{ env.TF_VAR_terraform-dynamodb-state-locking-table-name }}" \
          -backend-config="access_key=${{ env.TF_VAR_aws-access-key }}" \
          -backend-config="secret_key=${{ env.TF_VAR_aws-secret-key }}" \
          -backend-config="bucket=${{ env.TF_VAR_terraform-state-bucket }}" \
          -backend-config="key=${{ env.TF_VAR_terraform-state-bucket-key }}" \
          -backend-config="region=${{ env.TF_VAR_terraform-state-bucket-aws-region }}"          

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan

.github/workflows/apply.yml

name: Terraform Apply
on:
  push:
    branches:
      - main

env:
  TF_VERSION: 1.0.8
  TF_VAR_terraform-state-bucket: 'my-tfstate-bucket'
  TF_VAR_terraform-state-bucket-key: 'terraform.tfstate'
  TF_VAR_terraform-state-bucket-aws-region: 'us-east-1'
  TF_VAR_terraform-dynamodb-state-locking-table-name: 'my-tfstate-lock'
  TF_VAR_aws-access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}
  TF_VAR_aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: Apply
    runs-on: ubuntu-latest

steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1.2.1
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: |
          terraform init \
          -backend-config="dynamodb_table=${{ env.TF_VAR_terraform-dynamodb-state-locking-table-name }}" \
          -backend-config="access_key=${{ env.TF_VAR_aws-access-key }}" \
          -backend-config="secret_key=${{ env.TF_VAR_aws-secret-key }}" \
          -backend-config="bucket=${{ env.TF_VAR_terraform-state-bucket }}" \
          -backend-config="key=${{ env.TF_VAR_terraform-state-bucket-key }}" \
          -backend-config="region=${{ env.TF_VAR_terraform-state-bucket-aws-region }}"          

      - name: Terraform Validate
        run: terraform validate

      - name: Terraform Plan
        run: terraform plan

      - name: Terraform Apply
        run: terraform apply -auto-approve

That's it. Go be essential.

 
Share this