DEV Community

Cover image for Secure Terraform CI/CD on AWS with GitHub Actions (OIDC + Remote State)
Ismail G.
Ismail G.

Posted on

Secure Terraform CI/CD on AWS with GitHub Actions (OIDC + Remote State)

For CI/CD processes to work well, they need to be secure and repeatable. Without a strong authentication system and a consistent state management strategy, infrastructure automation quickly becomes vulnerable to security threats.
This blog post explains how to set up a remote Terraform backend with state locking using Amazon S3 and DynamoDB. We will also use OIDC to set up keyless authentication from GitHub Actions to Amazon Web Services (AWS).

  • Amazon S3 – remote state storage (versioned & encrypted)
  • DynamoDB – state locking
  • AWS KMS – encryption
  • GitHub Actions – CI/CD automation

Why This Setup Matters

AWS access keys are saved as secrets in traditional continuous integration pipelines. This plan is very risky because long-lived credentials could be stolen.

Key rotation is hard, but it's necessary. When CI is compromised, AWS is also compromised.

OpenID Connect (OIDC) solves this problem by letting GitHub Actions get an IAM role dynamically using short-lived credentials from AWS STS.

Terraform also needs to use a remote backend to:

  • Stop the state from getting corrupted at the same time.
  • Take care of values that are weak.
  • Make sure that people can work together.

This architecture solves both of these problems in a way that is easy to use and can grow with your needs.

High-Level Architecture:

  • GitHub Actions requests an OIDC identity token
  • AWS validates the token using IAM OIDC Provider
  • An IAM Role is assumed via sts:AssumeRoleWithWebIdentity
  • Terraform runs with temporary credentials
  • State is stored in encrypted S3, locked via DynamoDB

Step 1️: Create AWS OIDC Provider

To allow GitHub Actions to authenticate with AWS, an OIDC provider must be configured in AWS IAM. Before this, if you do not have AWS CLI configured in your local computer, you must setup it.

AWS CLI Setup (macOS)

# Homebrew
brew install awscli

aws --version

aws configure

write those when asked:
AWS Access Key ID [None]: <your-accesskey>
AWS Secret Access Key [None]: <your-secret-accesskey>
Default region name [None]: <region-name>
Default output format [None]: json

# Account test
aws sts get-caller-identity
Enter fullscreen mode Exit fullscreen mode

Create the OIDC Provider

Run the following command using AWS CLI or create the provider via the AWS Console.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Enter fullscreen mode Exit fullscreen mode

This enables AWS to validate GitHub-issued identity tokens.

Step 2️: Create IAM Role for GitHub Actions

Next, an IAM role must be created so that GitHub Actions workflows can assume it using sts:AssumeRoleWithWebIdentity.

This role explicitly defines:

  • Who can assume it (GitHub Actions)
  • From which repository it can be assumed

Create GitHubActionsRole with trust policy sts:AssumeRoleWithWebIdentity

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USERNAME/YOUR_REPO_NAME:*"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Attach IAM Policy

For bootstrap simplicity, we attach AdministratorAccess.
Important:
In real production environments, replace this with least-privilege policies.

Step 4️: Configure GitHub Repository Secret

GitHub Actions must now be informed which IAM role to assume.

In the GitHub repository: Settings → Secrets and variables → Actions → New repository secret

Create the following secret:

AWS_ROLE_ARN = arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsRole

Step 5️: Terraform Remote State

We use a one-time bootstrap workflow to provision:

  • S3 bucket (versioning + encryption)
  • DynamoDB table (state locking)
  • KMS key (state encryption)

Repository Structure

terraform-remote-state/
├── main.tf
├── providers.tf
├── variables.tf
├── terraform.tfvars
Enter fullscreen mode Exit fullscreen mode

From my GitHub repository you can check the terraform files:

GitHub Repo

Step 6️: Bootstrap GitHub Actions Workflow

Below is the final bootstrap workflow.

Uses OIDC for AWS auth

Accepts the S3 bucket name as an input

Pins Terraform version

Verifies AWS identity before provisioning

bootstrap.yml

# This workflow creates the foundational infrastructure for Terraform:
# - S3 bucket for state storage with encryption and versioning
# - DynamoDB table for state locking (prevents concurrent modifications)
# - KMS key for encrypting state files and secrets
#
# Run this ONCE before deploying main infrastructure

name: Bootstrap 

on:  
  workflow_dispatch:

permissions:
  contents: read
  id-token: write

env:
  AWS_REGION: us-east-1
  TF_VERSION: 1.5.0  

jobs: 
  bootstrap:  
    runs-on: ubuntu-latest 

    defaults:
      run:
        working-directory: terraform-remote-state

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

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
          role-session-name: GitHubActions-Bootstrap

      - name: Verify AWS identity
        run: |
          echo "Authenticated as:"
          aws sts get-caller-identity

          ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
          echo "AWS Account ID: $ACCOUNT_ID"
          echo "AWS Region: ${{ env.AWS_REGION }}"

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

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        run: |
          terraform plan -out=plan.tfplan

      - name: Terraform Apply
        run: terraform apply -auto-approve plan.tfplan

      - name: Terraform Output
        run: terraform output


Enter fullscreen mode Exit fullscreen mode

Step 7️: Run the Bootstrap Workflow

Go to GitHub Actions

Select Bootstrap

Click Run workflow

Step 8️: Store Terraform Outputs as GitHub Secrets

After completion, Terraform outputs values required by all future environments.
Store these as GitHub Secrets:
TF_STATE_BUCKET # S3 bucket name
TF_LOCK_TABLE # DynamoDB table name
KMS_KEY_ARN # KMS key ARN

Top comments (0)