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
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
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:*"
}
}
}
]
}
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
From my GitHub repository you can check the terraform files:
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
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)