Set up a Terraform Pipeline with GitHub Actions and GitHub OIDC for AWS

Jon Bass
April 14, 2022

At Sym we often work with customers that run Terraform pipelines but don't use Terraform Cloud for their backend state storage. To help these teams out, we've developed some patterns to quickly bootstrap an S3-backed Terraform pipeline with a few GitHub Actions. We also take advantage of GitHub's OIDC support for AWS to make this setup as frictionless as possible.

Example walkthrough

We're going to walk through our terraform-github-actions-oidc example repo that sets up AWS infrastructure using our GitHub Actions approach. It provisions an EC2 instance that you can SSH into with AWS Session Manager, along with bootstrapping the Terraform state for this resource.

Building on the community

Our example builds on many enabling technologies and resources from the community… I'm going to highlight a few of them here.

GitHub OIDC for AWS

We're going to get this whole setup configured without ever putting an AWS Access Key and Secret Key ID in GitHub! We can do this thanks to GitHub Actions' support for OpenID Connect (OIDC). With OIDC, we can provision an AWS IAM Role that trusts our GitHub org and repo. We then use the configure-aws-credentials Action to create role-based temporary access keys that Terraform can use to do the things.

GitHub Actions Permissions

Using GitHub OIDC means you'll have to do some permissions configuration in your GitHub Action configs. If your organization is set up to allow permissive default access, then you may not have encountered the GitHub permissions configuration requirements before. GitHub OIDC requires write for the id-token scope, which is not in the default access scopes. The tricky thing is that once you configure any permissions, all the ones you don't specify are set to no access. So you have to configure all the ones you need. Since our workflow configs are going to create and comment on Pull Requests, we've added the contents, issues, and pull-requests scopes in addition to the id-token scope.

terraform-aws-oidc-github module

Getting your OIDC stuff set up is pretty simple, but rather than starting from scratch there's a nice starter module by @unfunco that we're going to use. The module lets you configure the name of the GitHub org and the list of GitHub repositories that your AWS Role should trust.

Note that you can actually lock down the trust relationship to more fine grained conditions than the terraform-aws-oidc-github module currently exposes. You can filter for specific environments, for Pull Requests only, for specific branches, or specific tags (full details here). We're going to contribute an example with all the knobs and levers soon!

tfstate-backend module

We're not just going to avoid setting AWS Access Keys for our pipeline, we're also going to bootstrap our own Terraform state backend right from within GitHub Actions! The tfstate-backend module by CloudPosse sets up an S3 bucket and a DynamoDB table to manage concurrent state locks. Critically for our setup with GitHub Actions, the module generates a config file that we can use to automatically bootstrap state management from a GitHub Action.

setup-terraform Action

We use HashiCorp's setup-terraform module in our workflows to actually do the provisioning. The README provides a nice example of how to comment on your Pull Requests with a well formatted Terraform plan.

Step 1: Provision the IAM Roles that our workflows will use

Setting up GitHub Actions so that we can use IAM Roles instead of access keys is awesome! But now we have to actually give the roles some permissions so that they can actually manage our infrastructure. We've created a bootstrap configuration in our example repo that sets up an IAM that can bootstrap state and provision our EC2 instance.

This is the only step we'll have to do from outside of GitHub Actions, since we need these roles to exist in order for your workflows to run. We'll take the role outputs from our bootstrap configuration and use those to configure our workflows in the next step. Run a terraform apply from the environments/bootstrap directory to create the role:

Bootstrap Role

We split the IAM Permissions into two policies - a bootstrap policy that you only need during state management bootstrap and a main policy that you need for ongoing iteration on your infrastructure. In further iterations of this setup, your org could maintain a general purpose bootstrap role to let teams create state management resources.

Step 2: Bootstrap Terraform State

Now that we've got a bootstrap role provisioned, we can set the AWS_ROLE_ARN in the terraform-bootstrap workflow. terraform-bootstrap provisions the tfstate-backend module where we'll store our Terraform state, and creates a Pull Request that adds backend configuration to our repo. terraform-bootsrap is set up with the workflow_dispatch trigger - this means we can manually trigger the workflow from the UI rather than it being triggered by Git activity.

Manually running the workflow will provision your state and create a Pull Request like this one:

Bootstrap Pull Request

Step 3: Provision Our Infrastructure

Once we've got state management configured, we configure the AWS_ROLE_ARN in the main workflow. The main workflow uses one of the recommendations from the setup-terraform action to add comments to Pull Requests. Now whenever we create a Pull Request, our main workflow will generate a plan and create a comment in our repo with the results. When we push to main, our action will apply the changes to our infrastructure.

Testing it out

Update your terraform.tfvars to set bastion_enabled to true, and to configure the VPC and Private Subnet IDs to use for your instance.

Now push these changes to a branch called enable-bastion. This will create a Pull Request like this one with the plan. Then merge the PR and your bastion will get provisioned!

Bastion Pull Request

Review the main workflow output to find the ID of the instance, and then run aws ssm start-session --target <instance-id> to connect.

SSM Session

Port forwarding

If you configure the bastion_tunnel_ports variable, you can use the script to port forward resources to localhost.

Cleaning up the repo

If you want to clean up this repo when you're done, you have to do a little Terraform dance which we have NOT set up as a GitHub Action. Basically you first have to unconfigure the remote backend state, and THEN do a Terraform destroy. Details in the tfstate-backend README

Next steps

One of the challenges with this workflow is defining what permissions your Main role needs in order to provision your infrastructure. At Sym we're working on ways to make this easier to manage!

We'd also like to explore further constraints on the bootstrap IAM permissions, in particular session tagging to limit when the Create* actions can run.

More about Sym

Sym helps developers solve painful access management problems with standard infrastructure tools.

Check out Sym's GitHub Actions Quickstart for an example of how to set up a temporary access flow for Okta using GitHub Actions.

Recommended Posts