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
spwti-infra-platformrunsplanon 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)
│
├── 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)
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
| Resource | Purpose |
|---|---|
aws_iam_openid_connect_provider | Registers GitHub as a trusted OIDC identity provider in AWS |
spwti-terraform IAM role | Assumed by spwti-infra-* pipelines on main — manages all AWS infra via 8 scoped policies |
spwti-ecr-push IAM role | Assumed by ward-mitra pipeline (any branch) — pushes Docker images to ECR |
spwti-s3-deploy IAM role | Assumed by ward-mitra pipeline (main only) — deploys React app to S3 + CloudFront |
spwti-terraform-state S3 bucket | Remote Terraform state — shared across all repos, per-repo state keys |
spwti-terraform-state-lock DynamoDB | State 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"]
}
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
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"
}
}
}
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-bootstrapplatform-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
| Variable | Value | Where to find |
|---|---|---|
TERRAFORM_ROLE_ARN | arn:aws:iam::170420138919:role/spwti-terraform | spwti-bootstrap output |
ward-mitra repo
| Variable | Value | Where to find |
|---|---|---|
ECR_PUSH_ROLE_ARN | arn:aws:iam::170420138919:role/spwti-ecr-push | spwti-bootstrap output |
S3_DEPLOY_ROLE_ARN | arn:aws:iam::170420138919:role/spwti-s3-deploy | spwti-bootstrap output |
CF_DISTRIBUTION_ID | CloudFront distribution ID | spwti-infra-platform 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 → spwti-infra-platform 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://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:
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/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
| 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.