Skip to main content

Terraform CI/CD — OIDC & Pipeline Setup

Scope

This page covers two things:

  1. OIDC federation — how GitHub Actions authenticates to AWS with zero stored credentials
  2. Terraform pipeline — how spwhi-infra runs plan on PRs and apply on merge to main

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)

Run manually. Never add a CI pipeline to this repo.

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

ResourcePurpose
aws_iam_openid_connect_providerRegisters GitHub as a trusted OIDC identity provider in AWS
github-actions-terraform IAM roleAssumed by spwhi-infra pipeline — manages all AWS infra
github-actions-ecr-push IAM roleAssumed by wardmitra-api pipeline — pushes Docker images to ECR
github-actions-s3-deploy IAM roleAssumed 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
After this runs — do not run it again

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"
}
}
}
State file per environment

Each environment (dev, staging, prod) has its own backend.tf with a different key:

  • environments/dev/terraform.tfstate
  • environments/staging/terraform.tfstate
  • environments/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

VariableValueWhere to find
TERRAFORM_ROLE_ARNarn:aws:iam::ACCOUNT_ID:role/github-actions-terraformspwhi-bootstrap output

wardmitra-api repo

VariableValueWhere to find
ECR_PUSH_ROLE_ARNarn:aws:iam::ACCOUNT_ID:role/github-actions-ecr-pushspwhi-bootstrap output

wardmitra-ui repo

VariableValueWhere to find
S3_DEPLOY_ROLE_ARNarn:aws:iam::ACCOUNT_ID:role/github-actions-s3-deployspwhi-bootstrap output
CF_DISTRIBUTION_IDCloudFront distribution IDspwhi-infra output after first apply
Variables vs Secrets

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

RuleSetting
Require pull request before merging✅ enabled
Required approvals1 minimum
Require status checks to passTerraform Plan / Apply must pass
Require branches to be up to date✅ enabled
Allow force pushes✗ disabled
Allow deletions✗ disabled
No direct pushes to main

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:

  1. permissions: id-token: write is set on the job (not just the workflow level)
  2. TERRAFORM_ROLE_ARN variable is set correctly in the repo — no trailing spaces
  3. The branch pushing is main (apply) — or any branch (plan)
  4. The role trust policy sub condition 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

PracticeWhy
OIDC over IAM access keysKeys can leak via logs, env dumps, or repo history. OIDC tokens are ephemeral and scoped to one job
main-branch-only apply trustPrevents feature branches from triggering apply — only reviewed, merged code goes to prod
Separate roles per concernecr-push cannot touch Terraform state. terraform cannot push to ECR. Breach of one pipeline doesn't compromise others
No terraform apply -auto-approve on PRPlan is posted for human review before any change is applied
Concurrency lockPrevents two simultaneous applies from corrupting state
S3 versioning on tfstate bucketAccidental state corruption can be rolled back to a previous version


SparkOps Advisory Services · Sanket Pethkar · March 2026 · Confidential — SPW Healthcare Innovations Pvt. Ltd.