Skip to content
Back to skills

Set Up Terraform Pipeline

ASecurity

Use when a repo runs Terraform or OpenTofu on GitHub Actions — Azure or AWS via OIDC, develop/stage/main promotion with approval gates, saved plans applied after approval, and weekly drift detection.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 22, 2026
developmentrustbashexpressawsazureterraformgitapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 22, 2026

npx -y skills add VelimirMueller/claude-development-skills --skill set-up-terraform-pipeline --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Set Up Terraform Pipeline?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Set Up Terraform Pipeline
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/velimirmueller-set-up-terraform-pipeline/badge)](https://www.skillsdirectory.com/skills/velimirmueller-set-up-terraform-pipeline)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: set-up-terraform-pipeline
description: Use when a repo runs Terraform or OpenTofu on GitHub Actions — Azure or AWS via OIDC, develop/stage/main promotion with approval gates, saved plans applied after approval, and weekly drift detection.
---

# Set Up Terraform Pipeline

Infrastructure changes follow the same branch-promotion contract as application code: one plan per
environment, a human approves the plan, `apply` runs only the saved plan — never a re-plan. Contract:
[branch-promotion](../../core/_shared/branch-promotion.md). This skill generates the full pipeline; the
file-by-file content is [workflow-templates.md](./workflow-templates.md), the reasons are
[terraform-pipeline-patterns.md](./terraform-pipeline-patterns.md).

## 1. Audit current state (change nothing)

```bash
cat .claude/stack-profile.md 2>/dev/null          # iac, cloud, ci, runtime_manager
ls .github/workflows terraform/ infra/ 2>/dev/null
grep -rn "uses:" .github/workflows 2>/dev/null | grep -vE "@[0-9a-f]{40}" | grep -v "uses: \./"   # unpinned actions
gh api repos/{owner}/{repo}/environments --jq '.environments[].name' 2>/dev/null
gh api repos/{owner}/{repo}/rulesets --jq '.[].name' 2>/dev/null
gh api repos/{owner}/{repo}/actions/oidc/customization/sub 2>/dev/null    # OIDC subject template
git branch -r | grep -E 'origin/(develop|stage|main)$'                    # all three branches exist?
grep -rn -E 'data "(azurerm_key_vault_secret|aws_secretsmanager_secret_version)"' --include='*.tf' . 2>/dev/null
grep -rn -E 'plan -destroy|(terraform|tofu) destroy' .github scripts 2>/dev/null                    # destroy-as-rollback
```

From the profile: `iac` (terraform/opentofu/none), `cloud` (azure/aws), `ci` (must be github-actions). If there is no
profile, detect from the files above; suggest `set-up-stack-profile` when the answers change the output.

## 2. Decide what to do

- No workflows and no `terraform/`/`infra/` → full setup (steps 3 to 7).
- Workflows exist but break rules (floating tags, `-auto-approve` on a fresh plan, static cloud keys, no `permissions:{}`,
  a PR plan that takes the state lock) → apply only the delta; list each fix before editing.
- Everything already follows [terraform-pipeline-patterns.md](./terraform-pipeline-patterns.md) → exit "already in
  place", run step 7, stop.
- Tell the user in one line which of the three applies.

## 3. Detect the track

| Axis | Tracks |
|---|---|
| Tool | `terraform` (HashiCorp) / `tofu` (OpenTofu — also enable state+plan encryption, see [set-up-opentofu](../set-up-opentofu/SKILL.md)) |
| Cloud | `azure` (azurerm backend, `use_azuread_auth`) / `aws` (s3 backend, `use_lockfile`) |
| Layout | new (single `terraform/` root) / existing per-env dirs (`environments/<env>/<region>/`) — keep the layout, adapt only `init`/`-var-file` lines |

## 4. Prepare the repo once (needs admin)

`scripts/setup-github.sh` (in [workflow-templates.md](./workflow-templates.md)) creates, idempotently: the nine GitHub
environments (`dev`/`stg`/`prd`, `dev-plan`/`stg-plan`/`prd-plan`, `promote-stg`/`promote-prd`, `tf-apply-prd`), the
required reviewers on every gate environment, the deployment branch policies, the `stage`/`main` rulesets with the
promotion App in the bypass list, and the OIDC subject read-out. Create and install the promotion GitHub App
by hand first (Contents: write on this repo only); the script only adds it as the ruleset bypass actor. Run it, then create the cloud
identities it prints the subject for:

Azure — one app registration (or user-assigned managed identity) per environment, plan and apply:

```bash
gh api repos/<org>/<repo>/actions/oidc/customization/sub
# Newer repos present an id-embedded subject: repo:<org>@<org-id>/<repo>@<repo-id>:environment:<env>.
# Take the prefix exactly as the API (or the first AADSTS700213 error) shows it; never copy another repo's.
SUB_PREFIX="repo:<org>/<repo>"     # replace with the exact prefix
az ad app create --display-name "<app>-<env>-tf-plan" --sign-in-audience AzureMyOrg
az ad app create --display-name "<app>-<env>-tf-apply" --sign-in-audience AzureMyOrg
fic() { az ad app federated-credential create --id "$1" --parameters \
  "{\"name\":\"$2\",\"issuer\":\"https://token.actions.githubusercontent.com\",\"subject\":\"${SUB_PREFIX}:environment:$2\",\"audiences\":[\"api://AzureADTokenExchange\"]}"; }
fic <plan-app-object-id>  "<env>-plan"   # the plan identity is bound to the <env>-plan environment
fic <apply-app-object-id> "<env>"        # the apply identity is bound to the <env> environment
az role assignment create --assignee <plan-app-id> --role Reader --scope "/subscriptions/<subscription-id>"
az role assignment create --assignee <plan-app-id> --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/<subscription-id>/resourceGroups/<rg>/providers/Microsoft.Storage/storageAccounts/<sa>/blobServices/default/containers/tfstate"
az role assignment create --assignee <apply-app-id> --role Contributor --scope "/subscriptions/<subscription-id>/resourceGroups/<rg>"
```

AWS — one IAM role per environment, plan and apply, trusted on the GitHub OIDC provider:

```bash
aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com --thumbprint-list <thumbprint>
# trust.json: Principal.Federated = arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com,
#   Action = sts:AssumeRoleWithWebIdentity, Condition aud = sts.amazonaws.com,
#   sub = repo:<org>/<repo>:environment:<env>-plan  (trust-plan.json)
#   sub = repo:<org>/<repo>:environment:<env>       (trust-apply.json)
aws iam create-role --role-name "<app>-<env>-plan" --assume-role-policy-document file://trust-plan.json
aws iam create-role --role-name "<app>-<env>-apply" --assume-role-policy-document file://trust-apply.json
aws iam attach-role-policy --role-name "<app>-<env>-plan" --policy-arn <read-only-state-policy-arn>
```

Wait ~3 minutes after creating an Azure federated credential before the first run. There are no static cloud keys. These `az` and `aws` commands are *unverified* — they were not run against a live subscription or account; re-check the current CLI syntax and your exact scope before use.

## 5. Generate the workflows

Copy from [workflow-templates.md](./workflow-templates.md), then fill in the placeholders:

| File | Role |
|---|---|
| `.github/workflows/infra.yml` | Entry point. `setup` resolves env from ref, then `check`, `plan`, `gate` (prd only, only if changes), `apply`, `smoke`, `conclusion`. No logic beyond wiring. |
| `.github/workflows/_tf-check.yml` | `fmt -check -recursive`, `validate` every env with `init -backend=false`, tflint, trivy config, `terraform test`, version-pin guard. Credential-free. |
| `.github/workflows/_tf-plan.yml` | OIDC login, `init`, `plan -detailed-exitcode -out tfplan`, destroy/replace guard, summary, upload `tfplan-<env>-<sha>`, PR comment. |
| `.github/workflows/_tf-apply.yml` | Download `tfplan-<env>-<sha>`, verify sha, `apply tfplan`. Binds `environment: <env>`. |
| `.github/workflows/promote.yml` | `preflight` (ff check, `main` contained, previous env green for the SHA, nothing in flight, changelog) → `push` (waits for the `promote-<target>` approval, App token, fast-forward) → `verify`. Targets `stg`, `prd`, `stg-hotfix`. |
| `.github/workflows/branch-sync.yml` | Assert `main ⊆ stage ⊆ develop`; open the back-merge PR or the reconcile issue. |
| `.github/workflows/branch-policy.yml` | Required check: PR into `stage` fails; PR into `main` passes only `hotfix/*`. |
| `.github/workflows/tf-drift.yml` | Weekly read-only plan per env; exit 2 opens one issue per env. |
| `.github/workflows/workflow-lint.yml` | actionlint + zizmor, blocking. |
| `.github/actionlint.yaml`, `.tflint.hcl`, `.github/dependabot.yml` | config |
| `scripts/setup-github.sh` | the step-4 idempotent setup |

Keep the rules from the templates: pin every third-party `uses:` to a full SHA with the version in a trailing comment;
`permissions: {}` at workflow level; `persist-credentials: false` on every checkout; one exact tool version.

## 6. Wire

- **Environment variables, not secrets:** `ARM_USE_OIDC`, `ARM_CLIENT_ID`, `ARM_TENANT_ID`, `ARM_SUBSCRIPTION_ID` (or the
  AWS role ARN) live as **variables** on each environment — they are identifiers, not credentials. Scope `ARM_*` to the
  job that needs it, never the workflow.
- **Promotion App credentials:** `PROMOTE_APP_ID` (variable) and `PROMOTE_APP_PRIVATE_KEY` (secret) live on
  `promote-stg` and `promote-prd` only, so the
  `push` job can mint an installation token (a `GITHUB_TOKEN` push does not start the deploy pipeline).
- **Deploy identities in one closed table** (`.github/deploy-identities.yaml`): the plan job asserts its effective client
  id / role is the read-only identity, the apply job asserts it matches the committed writer for that environment.
- The promotion contract this enforces is [branch-promotion](../../core/_shared/branch-promotion.md).
- Alerts as code promote with the pipeline — SigNoz provider in the same root, one state per environment; post-apply
  read-back and ownership rules: [set-up-alerting](../../ops/set-up-alerting/SKILL.md).

## 7. Verify

```bash
actionlint                                                            # syntax and expressions
pipx run zizmor==1.30.1 .github/workflows                             # injection, permissions, unpinned refs
grep -rn "uses:" .github/workflows | grep -vE "@[0-9a-f]{40}|uses: \./"   # expect: no output
```

End to end, in this order:

1. Open a PR → `check` runs (credential-free), plus a dev plan; a sticky comment shows the dev plan.
2. Merge → dev plan + apply with no human gate.
3. Run `promote.yml` with `target: stg` → the `promote-stg` approval, then `stage` = the `develop` SHA, then stg apply.
4. Run `promote.yml` with `target: prd` → the `promote-prd` approval, a plan that has changes → the `tf-apply-prd` gate
   → apply → smoke test reports the deployed SHA.
5. `branch-sync.yml` is green; the weekly `tf-drift.yml` opens no issue (exit 0 or no "changes" issue per env).

## References
- [workflow-templates.md](./workflow-templates.md): the nine workflow files plus config and setup script, copy-ready.
- [terraform-pipeline-patterns.md](./terraform-pipeline-patterns.md): why each rule exists, the safety rules, when to deviate.
- [../../core/_shared/branch-promotion.md](../../core/_shared/branch-promotion.md): the promotion contract.
- [../set-up-opentofu/SKILL.md](../set-up-opentofu/SKILL.md): OpenTofu layout, encryption, backend.
- [../_shared/environments.md](../_shared/environments.md), [../_shared/stack-versions.md](../_shared/stack-versions.md).
- [../../ops/set-up-alerting/SKILL.md](../../ops/set-up-alerting/SKILL.md): alerts as code in the same pipeline.

Files in this skill

  • SKILL.md10.6 KB
  • terraform-pipeline-patterns.md16.2 KB
  • workflow-templates.md62.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.