GitHub Workflows for Terraform
Stop pushing IaC from your local env, please, just stop.
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 push
ed 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.