iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔐

Secure AWS Deployment with Terraform and GitHub OIDC

に公開

Introduction

When deploying to AWS from GitHub Actions, the first challenge you face is "how to handle authentication."

While putting IAM user access keys into GitHub Secrets is simple, it carries the risk of storing long-term credentials in a repository. Managing key rotation is also a bit of a hassle.

By using GitHub OIDC (OpenID Connect), you can access AWS without long-term credentials. Furthermore, managing that configuration with Terraform ensures reproducibility and visibility of your settings.

In this article, I will introduce the GitHub OIDC × Terraform design that I actually built for my personal development project, the diary app Storyie.

Why OIDC?

Let's clarify the differences between the traditional method and OIDC.

IAM User + Access Key Method

  • Register AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets
  • If the keys are leaked, permanent access is possible
  • Periodic rotation is required (but often forgotten)

GitHub OIDC Method

  • Authenticate to AWS using short-lived tokens issued by GitHub
  • No long-term credentials exist
  • Access can be restricted per branch or repository
  • Validate "which request comes from which branch of which repository" using the trust policy on the AWS side

Even in personal development, I don't want to cut corners on security. With OIDC, the configuration cost isn't that high, and once it's built, you're freed from the burden of rotation management.

Overall Architecture

The structure is simple.

terraform/github-oidc/
├── main.tf                    # OIDC Provider + IAM Role + Policy
├── variables.tf               # Variable definitions
├── outputs.tf                 # Outputs (Role ARN, etc.)
└── terraform.tfvars.example   # Configuration example

There are three resources managed by Terraform:

  1. OIDC Provider — Register GitHub Actions as a trusted identity provider in AWS
  2. IAM Role — The target that GitHub Actions assumes. Restricted by repository and branch in the trust policy
  3. IAM Policy — A collection of AWS permissions required for SST deployment

Terraform Design

OIDC Provider

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1",
    "1c58a3a8518e8759bf075b76b750d4f2df264fcd"
  ]
}

Specify sts.amazonaws.com in client_id_list. This is the audience used when GitHub Actions performs the token exchange with AWS STS.

The thumbprint_list is the fingerprint of the TLS certificate for GitHub's OIDC endpoint. AWS uses it to verify the token from GitHub.

Trust Policy — Which Repositories and Branches to Allow

data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    actions = ["sts:AssumeRoleWithWebIdentity"]

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        for branch in var.github_branches :
        "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${branch}"
      ]
    }
  }
}

This is the core of security. By using the conditions on the sub claim, only requests from specific branches of specific repositories are permitted.

In Storyie, only the main and develop branches are allowed. Since direct deployments from feature branches are rejected by the trust policy, you can enforce the deployment flow.

Because it is managed with variables, adding or changing branches is as simple as editing terraform.tfvars and running apply.

variable "github_branches" {
  description = "List of GitHub branches allowed to assume the role"
  type        = list(string)
  default     = ["develop", "main"]
}

IAM Policy for SST Deployment

SST (Serverless Stack) operates many AWS services internally, such as CloudFormation, S3, Lambda, and CloudFront. The necessary permissions are consolidated into a policy document.

data "aws_iam_policy_document" "sst_deploy" {
  # CloudFormation — SST stack management
  statement {
    sid     = "CloudFormation"
    effect  = "Allow"
    actions = ["cloudformation:*"]
    resources = ["*"]
  }

  # S3 — Asset delivery and SST state management
  statement {
    sid     = "S3"
    effect  = "Allow"
    actions = ["s3:*"]
    resources = ["*"]
  }

  # Lambda — Next.js Server Functions
  statement {
    sid     = "Lambda"
    effect  = "Allow"
    actions = ["lambda:*"]
    resources = ["*"]
  }

  # CloudFront — CDN delivery
  # Route53 — DNS management
  # ACM — SSL certificates
  # SQS + DynamoDB — Next.js ISR Revalidation
  # ... same for other services
}

To be honest, frequent use of * is a tradeoff for personal development. In a corporate project, you should be more strict by narrowing down the resource ARNs. However, in personal development, SST may create new resources with each version update, so I balance this with the stress of deployment failures caused by insufficient permissions.

GitHub Actions Configuration

Next, use the role created with Terraform in GitHub Actions.

jobs:
  deploy:
    runs-on: self-hosted
    permissions:
      id-token: write   # Required to obtain the OIDC token
      contents: read

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Deploy to AWS with SST
        run: pnpm sst:deploy:prod

There are two key points.

permissions.id-token: write: Without this, GitHub will not issue an OIDC token. Since id-token permissions are not granted by default, you must specify this explicitly.

aws-actions/configure-aws-credentials@v4: By simply passing the Role ARN to role-to-assume, it internally handles everything from obtaining the OIDC token to executing AssumeRoleWithWebIdentity for STS and setting up temporary credentials.

Since AWS_ROLE_ARN is shown in the Terraform output, register it in GitHub Secrets.

Setup Procedure

1. Run Terraform

cd terraform/github-oidc
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars (set github_org, github_repo)
terraform init
terraform plan
terraform apply

2. Configure GitHub Secrets

Register the Role ARN displayed in the output of terraform apply as AWS_ROLE_ARN in the GitHub repository's Secrets.

The Terraform outputs also include the setup instructions.

output "github_secret_instructions" {
  value = <<-EOT
    1. Copy the role ARN above
    2. Go to Settings → Secrets and variables → Actions
    3. Name: AWS_ROLE_ARN
    4. Value: (Role ARN)
  EOT
}

It is quite convenient to embed the steps in the output so that team members or your future self don't get lost.

3. Run the Workflow

Now, simply push to main or develop, and the automatic deployment will run via OIDC authentication.

State Management

In this setup, the Terraform state is saved locally.

backend "local" {
  path = "terraform.tfstate"
}

Since OIDC settings are rarely changed once created, I decided that building a remote backend with S3 + DynamoDB was not worth the effort. Since I am the only one working on this personal development project, state conflicts do not occur.

However, don't forget to add terraform.tfstate to your .gitignore. The state file contains information like AWS Account IDs and Role ARNs.

Insights from Actual Operation

What Went Well

  • Zero Rotation Management: Once built, you can completely forget about it. This is the biggest advantage.
  • Branch Restrictions as Natural Guardrails: I no longer accidentally deploy from feature branches.
  • Visibility via Terraform: Even six months later, I can understand the role's permissions just by looking at the .tf files.
  • Setup Procedures in Outputs: I don't get lost when creating the same configuration for a new repository.

Points to Note

  • Forgetting id-token: write: This was the first point I got stuck on. Without it, the OIDC token cannot be retrieved, resulting in AccessDenied.
  • Thumbprint Updates: If GitHub updates its certificate, the thumbprint may change. Check this if you encounter errors.
  • Combination with Self-hosted Runners: Storyie uses self-hosted runners, but OIDC works the same way whether you use GitHub-hosted or self-hosted runners.

Conclusion

The combination of GitHub OIDC + Terraform is an investment that pays off even for personal development.

  • Free yourself from managing long-term credentials.
  • Control deployment permissions at the branch level.
  • Make configurations reproducible with Terraform.

Because personal development is where security tends to be "postponed due to hassle," solving it with a system at the beginning allows you to focus on your code with peace of mind.


The configuration introduced in this article is the actual deployment infrastructure for the diary app Storyie.

📝 Web: https://storyie.com
🍎 iOS: App Store
🤖 Android Beta: https://storyie.com/android-beta

Discussion