Terraform CI/CD — OIDC & Pipeline Setup
This page covers two things:
- OIDC federation — how GitHub Actions authenticates to AWS with zero stored credentials
- Terraform pipeline — how
spwhi-infrarunsplanon PRs andapplyon merge tomain
For application pipelines (Ward Mitra API, UI, mobile) see CI/CD — Application Pipelines.
How OIDC Federation Works
GitHub Actions has a built-in OIDC identity provider. Every workflow run gets a short-lived signed JWT token. AWS IAM is configured to trust GitHub's OIDC provider — so a workflow can exchange its JWT for temporary AWS STS credentials without any stored secrets.
Nothing is stored in GitHub Secrets. The only GitHub-side requirement is permissions: id-token: write on the job.
Repository Structure
Two repositories are involved in Terraform infrastructure management:
SPW-HEALTHCARE-INNOVATIONS-Pvt-Ltd (GitHub Org)
│
├── spwhi-bootstrap ← Day 0 only. Run once manually. No CI pipeline.
│ ├── main.tf ← OIDC provider registration
│ ├── iam_roles.tf ← 3 GitHub Actions IAM roles
│ ├── variables.tf
│ ├── outputs.tf ← role ARNs for use in other repos
│ └── README.md
│
└── spwhi-infra ← All AWS infrastructure. CI/CD pipeline here.
├── bootstrap/ ← BreakGlass, IAM Identity Center, SCPs, CloudTrail
├── modules/
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── iam/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
└── .github/
└── workflows/
└── terraform.yml
Part 1 — spwhi-bootstrap (Day 0, One-Time)
spwhi-bootstrap is run once with local temporary admin credentials. Its entire purpose is to create the OIDC provider and the IAM roles that all other pipelines depend on. If it had a CI pipeline, it would need credentials to run — a circular dependency. It also means no automated process can modify its own trust policy (privilege escalation risk).
What it creates
| Resource | Purpose |
|---|---|
aws_iam_openid_connect_provider | Registers GitHub as a trusted OIDC identity provider in AWS |
github-actions-terraform IAM role | Assumed by spwhi-infra pipeline — manages all AWS infra |
github-actions-ecr-push IAM role | Assumed by wardmitra-api pipeline — pushes Docker images to ECR |
github-actions-s3-deploy IAM role | Assumed by wardmitra-ui pipeline — deploys React app to S3 + CloudFront |
File: main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
# Bootstrap runs locally — local state is fine for this one-time setup
# Do NOT add remote backend here
}
provider "aws" {
region = var.aws_region
}
# Register GitHub Actions as OIDC provider (one per AWS account)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
tags = {
ManagedBy = "terraform"
Purpose = "github-oidc"
Project = "spwhi-platform"
}
}
File: iam_roles.tf
locals {
oidc_arn = aws_iam_openid_connect_provider.github.arn
oidc_sub = "token.actions.githubusercontent.com:sub"
oidc_aud = "token.actions.githubusercontent.com:aud"
github_org = var.github_org # SPW-HEALTHCARE-INNOVATIONS-Pvt-Ltd
}
# ── Role 1: Terraform (infra repo, main branch only) ──────────────────────────
resource "aws_iam_role" "github_terraform" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = local.oidc_arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
(local.oidc_aud) = "sts.amazonaws.com"
(local.oidc_sub) = "repo:${local.github_org}/spwhi-infra:ref:refs/heads/main"
}
}
}]
})
tags = { ManagedBy = "terraform", Purpose = "ci-terraform" }
}
resource "aws_iam_role_policy" "github_terraform" {
name = "spwhi-infra-permissions"
role = aws_iam_role.github_terraform.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
# Terraform state access
Effect = "Allow"
Action = [
"s3:GetObject", "s3:PutObject", "s3:DeleteObject",
"s3:ListBucket", "s3:GetBucketVersioning"
]
Resource = [
"arn:aws:s3:::spwhi-platform-tfstate",
"arn:aws:s3:::spwhi-platform-tfstate/*"
]
},
{
# Terraform state locking
Effect = "Allow"
Action = [
"dynamodb:GetItem", "dynamodb:PutItem",
"dynamodb:DeleteItem", "dynamodb:DescribeTable"
]
Resource = "arn:aws:dynamodb:ap-south-1:*:table/spwhi-platform-tflock"
},
{
# Infrastructure management — scoped to spwhi-* resources
Effect = "Allow"
Action = [
"ec2:*", "eks:*", "rds:*", "iam:*",
"acm:*", "route53:*", "elasticloadbalancing:*",
"autoscaling:*", "cloudwatch:*", "logs:*",
"ssm:*", "secretsmanager:*", "ecr:*",
"s3:*", "cloudfront:*"
]
Resource = "*"
Condition = {
StringLike = {
"aws:ResourceTag/Project" = "spwhi-*"
}
}
}
]
})
}
# ── Role 2: ECR Push (app repos, any branch) ──────────────────────────────────
resource "aws_iam_role" "github_ecr_push" {
name = "github-actions-ecr-push"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = local.oidc_arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = { (local.oidc_aud) = "sts.amazonaws.com" }
StringLike = {
(local.oidc_sub) = "repo:${local.github_org}/wardmitra-api:*"
}
}
}]
})
tags = { ManagedBy = "terraform", Purpose = "ci-ecr-push" }
}
resource "aws_iam_role_policy" "github_ecr_push" {
name = "ecr-push-permissions"
role = aws_iam_role.github_ecr_push.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:BatchGetImage",
"ecr:DescribeRepositories"
]
Resource = "*"
}]
})
}
# ── Role 3: S3 Deploy (UI repo, main branch only) ─────────────────────────────
resource "aws_iam_role" "github_s3_deploy" {
name = "github-actions-s3-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = local.oidc_arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
(local.oidc_aud) = "sts.amazonaws.com"
(local.oidc_sub) = "repo:${local.github_org}/wardmitra-ui:ref:refs/heads/main"
}
}
}]
})
tags = { ManagedBy = "terraform", Purpose = "ci-s3-deploy" }
}
resource "aws_iam_role_policy" "github_s3_deploy" {
name = "s3-deploy-permissions"
role = aws_iam_role.github_s3_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:PutObject", "s3:DeleteObject",
"s3:ListBucket", "s3:GetBucketLocation"
]
Resource = [
"arn:aws:s3:::spwhi-ward-mitra-web-prod",
"arn:aws:s3:::spwhi-ward-mitra-web-prod/*"
]
},
{
Effect = "Allow"
Action = ["cloudfront:CreateInvalidation"]
Resource = "arn:aws:cloudfront::*:distribution/${var.cf_distribution_id}"
}
]
})
}
File: variables.tf
variable "aws_region" {
default = "ap-south-1"
}
variable "github_org" {
description = "GitHub organisation name (case-sensitive — must match exactly)"
default = "SPW-HEALTHCARE-INNOVATIONS-Pvt-Ltd"
}
variable "cf_distribution_id" {
description = "CloudFront distribution ID for Ward Mitra UI"
type = string
# Set this after CloudFront is created by spwhi-infra
}
File: outputs.tf
output "terraform_role_arn" {
value = aws_iam_role.github_terraform.arn
description = "Add as TERRAFORM_ROLE_ARN variable in spwhi-infra GitHub repo"
}
output "ecr_push_role_arn" {
value = aws_iam_role.github_ecr_push.arn
description = "Add as ECR_PUSH_ROLE_ARN variable in wardmitra-api GitHub repo"
}
output "s3_deploy_role_arn" {
value = aws_iam_role.github_s3_deploy.arn
description = "Add as S3_DEPLOY_ROLE_ARN variable in wardmitra-ui GitHub repo"
}
How to run (Day 0 only)
# Prerequisites: local AWS credentials with AdministratorAccess on spwhi-platform account
cd spwhi-bootstrap/
terraform init
# Review what will be created
terraform plan -var="cf_distribution_id=PLACEHOLDER"
# Apply (one time only)
terraform apply -var="cf_distribution_id=PLACEHOLDER"
# Note the 3 role ARNs from outputs — add them to GitHub Actions variables
terraform output
The OIDC provider is a singleton per AWS account. Running apply again when
a provider already exists will cause a conflict. If you need to change a role,
do it by editing iam_roles.tf and re-applying — not by destroying and recreating.
Part 2 — spwhi-infra Terraform Pipeline
How the pipeline works
File: .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
# Only one apply at a time — queue don't cancel
concurrency:
group: terraform-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write # Required for OIDC token minting
contents: read # Required for checkout
pull-requests: write # Required to post plan as PR comment
env:
TF_VERSION: "1.7.5"
AWS_REGION: "ap-south-1"
WORKING_DIR: "./environments/prod"
jobs:
terraform:
name: Terraform Plan / Apply
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TERRAFORM_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: terraform-${{ github.run_id }}
- name: Terraform Init
working-directory: ${{ env.WORKING_DIR }}
run: terraform init
- name: Terraform Format Check
working-directory: ${{ env.WORKING_DIR }}
run: terraform fmt -check -recursive
# Fail the pipeline if code is not formatted
# Fix locally with: terraform fmt -recursive
- name: Terraform Validate
working-directory: ${{ env.WORKING_DIR }}
run: terraform validate
- name: Terraform Plan
id: plan
working-directory: ${{ env.WORKING_DIR }}
run: |
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Post Plan as PR Comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
## Terraform Plan — `${{ env.WORKING_DIR }}`
<details><summary>Show Plan</summary>
~~~hcl
${{ steps.plan.outputs.stdout }}
~~~
</details>
**Plan exit code:** `${{ steps.plan.outputs.exitcode }}`
Triggered by: @${{ github.actor }} on `${{ github.head_ref }}`
- name: Fail if Plan errored
if: steps.plan.outputs.exitcode == '1'
run: exit 1
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
working-directory: ${{ env.WORKING_DIR }}
run: terraform apply -auto-approve tfplan
File: environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "spwhi-platform-tfstate"
key = "environments/prod/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "spwhi-platform-tflock"
encrypt = true
}
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
required_version = ">= 1.7.0"
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = {
Project = "spwhi-platform"
Environment = "prod"
ManagedBy = "terraform"
}
}
}
Each environment (dev, staging, prod) has its own backend.tf with a different key:
environments/dev/terraform.tfstateenvironments/staging/terraform.tfstateenvironments/prod/terraform.tfstate
All share the same S3 bucket and DynamoDB lock table.
GitHub Actions Variables Setup
After running spwhi-bootstrap, add these as GitHub Actions Variables (not Secrets) in each repo:
spwhi-infra repo
| Variable | Value | Where to find |
|---|---|---|
TERRAFORM_ROLE_ARN | arn:aws:iam::ACCOUNT_ID:role/github-actions-terraform | spwhi-bootstrap output |
wardmitra-api repo
| Variable | Value | Where to find |
|---|---|---|
ECR_PUSH_ROLE_ARN | arn:aws:iam::ACCOUNT_ID:role/github-actions-ecr-push | spwhi-bootstrap output |
wardmitra-ui repo
| Variable | Value | Where to find |
|---|---|---|
S3_DEPLOY_ROLE_ARN | arn:aws:iam::ACCOUNT_ID:role/github-actions-s3-deploy | spwhi-bootstrap output |
CF_DISTRIBUTION_ID | CloudFront distribution ID | spwhi-infra output after first apply |
These are stored as Variables (not Secrets) because they are not sensitive — they are role ARNs, which are not credentials. Secrets are reserved for values that must never appear in logs.
Branch Protection Rules
Configure these in GitHub → spwhi-infra repo → Settings → Branches:
main branch protection
| Rule | Setting |
|---|---|
| Require pull request before merging | ✅ enabled |
| Required approvals | 1 minimum |
| Require status checks to pass | ✅ Terraform Plan / Apply must pass |
| Require branches to be up to date | ✅ enabled |
| Allow force pushes | ✗ disabled |
| Allow deletions | ✗ disabled |
terraform apply only runs on push to main. Direct pushes bypass the plan review step — which means changes go to production without a PR comment showing what will change. Branch protection must enforce this.
Common Workflows
Making an infrastructure change
# 1. Create a feature branch
git checkout -b feat/add-rds-read-replica
# 2. Make your Terraform changes
# Edit modules/rds/main.tf
# 3. Format before committing (pipeline will fail if not formatted)
terraform fmt -recursive
# 4. Validate locally (optional but fast feedback)
cd environments/dev/
terraform init
terraform validate
# 5. Open a PR against main
git push origin feat/add-rds-read-replica
# GitHub Actions runs plan automatically
# Plan output appears as PR comment
# 6. Team reviews the plan comment — approve if correct
# 7. Merge to main → GitHub Actions runs apply automatically
Checking what Terraform last applied
# View current state (read-only — does not modify anything)
cd environments/prod/
terraform init
terraform show
# See recent state changes
aws s3 cp s3://spwhi-platform-tfstate/environments/prod/terraform.tfstate - \
| jq '.resources[].type' | sort | uniq -c | sort -rn
Checking for drift (manual resources someone created via console)
cd environments/prod/
terraform init
terraform plan
# If output shows changes you didn't make → someone used BreakGlass
# Follow the BreakGlass reconciliation procedure
# See: AWS Account Setup → BreakGlass post-use mandatory steps
Importing a manually created resource into Terraform state
# Example: import an RDS instance that was created via BreakGlass console
terraform import aws_db_instance.wardmitra_primary db-IDENTIFIER
# After import — verify plan shows no changes
terraform plan
# Expected: "No changes. Your infrastructure matches the configuration."
Troubleshooting
Error: No valid credential sources found
The OIDC role assumption failed. Check:
permissions: id-token: writeis set on the job (not just the workflow level)TERRAFORM_ROLE_ARNvariable is set correctly in the repo — no trailing spaces- The branch pushing is
main(apply) — or any branch (plan) - The role trust policy
subcondition matches exactly:repo:SPW-HEALTHCARE-INNOVATIONS-Pvt-Ltd/spwhi-infra:ref:refs/heads/main
Error: state lock acquired by another process
Another Terraform run is in progress (or a previous run crashed without releasing the lock).
# Check who holds the lock
aws dynamodb get-item \
--table-name spwhi-platform-tflock \
--key '{"LockID":{"S":"spwhi-platform-tfstate/environments/prod/terraform.tfstate"}}' \
--region ap-south-1
# Force-unlock only if you are certain no apply is running
terraform force-unlock LOCK_ID
terraform fmt -check failing in pipeline
Run locally before pushing:
terraform fmt -recursive
git add -A && git commit -m "fix: terraform fmt"
Plan shows unexpected changes after a BreakGlass session
Someone modified resources manually via the console. Follow the BreakGlass reconciliation procedure.
Security Notes
| Practice | Why |
|---|---|
| OIDC over IAM access keys | Keys can leak via logs, env dumps, or repo history. OIDC tokens are ephemeral and scoped to one job |
main-branch-only apply trust | Prevents feature branches from triggering apply — only reviewed, merged code goes to prod |
| Separate roles per concern | ecr-push cannot touch Terraform state. terraform cannot push to ECR. Breach of one pipeline doesn't compromise others |
No terraform apply -auto-approve on PR | Plan is posted for human review before any change is applied |
| Concurrency lock | Prevents two simultaneous applies from corrupting state |
| S3 versioning on tfstate bucket | Accidental state corruption can be rolled back to a previous version |
Related Documents
- AWS Account Setup & Access Strategy — OIDC role trust policy context, BreakGlass reconciliation
- CI/CD — Application Pipelines — Ward Mitra API (ECR push) and UI (S3 deploy) pipelines
- Kubernetes — EKS cluster and namespace structure that Terraform provisions
SparkOps Advisory Services · Sanket Pethkar · March 2026 · Confidential — SPW Healthcare Innovations Pvt. Ltd.