Terraform and OpenTofu with GitHub Actions and AWS OIDC (No Access Keys)
A CI/CD pipeline for infrastructure without stored AWS keys #
The Terraform CI/CD tutorial explains the concepts. This guide shows a concrete pipeline for GitHub Actions. The key point is authentication: instead of saving an AWS access key and secret in GitHub, the workflow proves its identity to AWS with an OpenID Connect (OIDC) token and receives temporary credentials.
1. Create the OIDC provider and the role in AWS #
Create these resources once, with Terraform, in the AWS account where the pipeline will deploy:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:my-org/my-infra:*"]
}
}
}
resource "aws_iam_role" "terraform" {
name = "github-terraform"
assume_role_policy = data.aws_iam_policy_document.assume.json
}Attach to the role the IAM permissions that your code needs. Use two roles: a read-only role for plan (used in pull requests) and a write role for apply (only on main). See IAM roles and policies.
2. The workflow #
name: infra
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
id-token: write # needed to request the OIDC token
contents: read
pull-requests: write
env:
AWS_REGION: eu-west-1
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.10.0
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/github-terraform-plan
aws-region: ${{ env.AWS_REGION }}
- run: tofu fmt -check -recursive
- run: tofu init -input=false
- run: tofu validate
- run: tofu plan -input=false -out=tfplan
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: infra/tfplan
apply:
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # add required reviewers in GitHub
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.10.0
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/github-terraform-apply
aws-region: ${{ env.AWS_REGION }}
- uses: actions/download-artifact@v4
with:
name: tfplan
path: infra
- run: tofu init -input=false
- run: tofu apply -input=false tfplanReplace the account ID, role names, region and the versions of the tools with yours. To use Terraform, replace the setup action with hashicorp/setup-terraform and the tofu commands with terraform.
How it works #
permissions: id-token: writelets the job request the OIDC token from GitHub.configure-aws-credentialsexchanges the token for temporary credentials of the role. They expire in about an hour and are never stored.- On pull requests only the
planjob runs, with the read-only role. - On merge to
main,applyruns the saved plan (tfplan), so what is applied is exactly what was reviewed. Theenvironmentsetting lets you require a manual approval.
Good practices #
- Use a remote backend with locking, since two runs must never apply at the same time. Add a
concurrencygroup to the workflow too. - Do not print secrets: see secrets management.
- Add security scanning and tests before the plan.
- Pin third-party actions to a full commit SHA in sensitive repositories.
- Show the plan in the pull request comments with a tool such as Atlantis, Spacelift or a plan-comment action, so reviewers see the changes.