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 spwti-infra-platform 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)

├── spwti-bootstrap ← Day 0 only. Run once manually. No CI pipeline.
│ ├── versions.tf
│ ├── providers.tf
│ ├── variables.tf
│ ├── backend.tf ← S3 remote backend
│ ├── state_backend.tf ← S3 bucket + DynamoDB resources
│ ├── oidc.tf ← GitHub OIDC provider
│ ├── iam_roles.tf ← 3 GitHub Actions IAM roles
│ ├── iam_policies.tf ← 8 scoped IAM policies
│ └── data.tf

└── spwti-infra-platform ← All AWS infrastructure. CI/CD pipeline here.
├── modules/
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── iam/
├── environments/
│ ├── dev/
│ ├── staging/
│ └── prod/
└── .github/
└── workflows/
└── terraform.yml

Part 1 — spwti-bootstrap (Day 0, One-Time)

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

spwti-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
spwti-terraform IAM roleAssumed by spwti-infra-* pipelines on main — manages all AWS infra via 8 scoped policies
spwti-ecr-push IAM roleAssumed by ward-mitra pipeline (any branch) — pushes Docker images to ECR
spwti-s3-deploy IAM roleAssumed by ward-mitra pipeline (main only) — deploys React app to S3 + CloudFront
spwti-terraform-state S3 bucketRemote Terraform state — shared across all repos, per-repo state keys
spwti-terraform-state-lock DynamoDBState locking — shared, PAY_PER_REQUEST billing

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 = "spwti-platform"
}
}

File: iam_roles.tf

# Trust policy for spwti-terraform role
# Trusted by any spwti-infra-* repo on main branch (wildcard covers future infra repos)
data "aws_iam_policy_document" "github_terraform_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_org}/spwti-infra-*:ref:refs/heads/main"]
}
}
}

resource "aws_iam_role" "github_terraform" {
name = "spwti-terraform"
assume_role_policy = data.aws_iam_policy_document.github_terraform_assume_role.json
tags = { ManagedBy = "spwti-bootstrap", Purpose = "github-actions-terraform" }
}

# 8 scoped policies attached — see iam_policies.tf
# spwti-terraform-networking, spwti-terraform-eks, spwti-terraform-data,
# spwti-terraform-ecr, spwti-terraform-iam, spwti-terraform-security-config,
# spwti-terraform-dns-cdn, spwti-terraform-observability

# ECR push role — trusted by ward-mitra on any branch
resource "aws_iam_role" "github_ecr_push" {
name = "spwti-ecr-push"
# assume_role_policy trusts: repo:${var.github_org}/ward-mitra:*
}

# S3 deploy role — trusted by ward-mitra on main only
resource "aws_iam_role" "github_s3_deploy" {
name = "spwti-s3-deploy"
# assume_role_policy trusts: repo:${var.github_org}/ward-mitra:ref:refs/heads/main
}

File: variables.tf

variable "aws_profile" {
description = "AWS CLI profile to use for authentication"
type = string
default = "vendor-bootstrap"
}

variable "aws_region" {
description = "AWS region"
type = string
default = "ap-south-1"
}

variable "state_bucket_name" {
type = string
default = "spwti-terraform-state"
}

variable "lock_table_name" {
type = string
default = "spwti-terraform-state-lock"
}

variable "github_org" {
description = "GitHub organisation name (case-sensitive)"
type = string
default = "SPW-HEALTHCARE-INNOVATIONS-Pvt-Ltd"
}

variable "app_repo_names" {
description = "App repo names trusted to assume ECR push and S3 deploy roles"
type = list(string)
default = ["ward-mitra"]
}
note

Role ARNs can be retrieved after apply with terraform output or directly from the AWS console under IAM → Roles.

How to run (Day 0 only)

# Prerequisites: vendor-bootstrap AWS profile configured with credential_process
# (see Runbook: Bootstrap for full local machine setup)

cd spwti-bootstrap/

# Phase A — Create remote state
$env:TF_VAR_aws_profile = "vendor-bootstrap" # PowerShell
terraform init
terraform plan # review S3 + DynamoDB
terraform apply # creates spwti-terraform-state + spwti-terraform-state-lock

# Uncomment backend block in backend.tf, then:
terraform init -migrate-state # type "yes" — moves local state to S3

# Phase B — Create OIDC + IAM
terraform plan # review OIDC provider + IAM roles
terraform apply # creates OIDC provider + spwti-terraform/ecr-push/s3-deploy roles
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 — spwti-infra-platform 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 = "spwti-terraform-state"
key = "platform-infra/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "spwti-terraform-state-lock"
encrypt = true
}

required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}

required_version = ">= 1.5.0"
}

provider "aws" {
region = "ap-south-1"

default_tags {
tags = {
Project = "spwti-platform"
Environment = "prod"
ManagedBy = "terraform"
}
}
}
State file per repo

Each Terraform repo has its own state key in the shared spwti-terraform-state S3 bucket, with spwti-terraform-state-lock for DynamoDB locking:

  • bootstrap/terraform.tfstate — spwti-bootstrap
  • platform-infra/terraform.tfstate — spwti-infra-platform
  • {repo-name}/terraform.tfstate — pattern for future repos

GitHub Actions Variables Setup

After running spwti-bootstrap, add these as GitHub Actions Variables (not Secrets) in each repo:

spwti-infra-platform repo

VariableValueWhere to find
TERRAFORM_ROLE_ARNarn:aws:iam::170420138919:role/spwti-terraformspwti-bootstrap output

ward-mitra repo

VariableValueWhere to find
ECR_PUSH_ROLE_ARNarn:aws:iam::170420138919:role/spwti-ecr-pushspwti-bootstrap output
S3_DEPLOY_ROLE_ARNarn:aws:iam::170420138919:role/spwti-s3-deployspwti-bootstrap output
CF_DISTRIBUTION_IDCloudFront distribution IDspwti-infra-platform 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 → spwti-infra-platform 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://spwti-terraform-state/platform-infra/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/spwti-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 spwti-terraform-state-lock \
--key '{"LockID":{"S":"spwti-terraform-state/platform-infra/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.