So far you'd run tofu apply from your laptop. This project deliberately does
not allow that for normal changes. Instead, every environment is applied by
an automated pipeline, triggered by a git push. The README is blunt about it:
"Every environment - Dev included - is applied from CodePipeline, never from a
laptop."
Why? Three reasons that matter even to a solo developer:
- Auditability - every change is tied to a git commit and a pipeline run.
- Consistency - the pipeline always runs the exact same steps, with the exact pinned tool version, in a clean environment.
- Safety - a human reviews the plan and clicks Approve before anything is applied. The apply uses the saved plan, so what's applied is exactly what was reviewed.
The pipeline shape
cicd/main.tf creates one pipeline per environment by calling the
iac-pipeline module three times (dev/staging/prod), all sharing a single GitHub
connection. Each pipeline has four stages:
Source (pull code from GitHub via a CodeStar Connection)
→ Plan (CodeBuild runs: tofu init + validate + plan, saves the plan)
→ Approve (a human clicks "Approve" - present on every env)
→ Apply (CodeBuild runs: tofu apply of the *saved* plan)AWS services involved:
- CodePipeline - the orchestrator that moves through the stages.
- CodeBuild - a managed build server that actually runs the
tofucommands. - CodeStar Connection - the secure link to your GitHub repo. The first time
you apply
cicd, this connection is created in aPENDINGstate and you authorize it once by hand in the AWS console (a one-time GitHub handshake).
How the build steps are defined: buildspecs
A buildspec is a YAML file telling CodeBuild what commands to run. This repo
has two, and they show you the real tofu commands the automation runs.
cicd/buildspec-plan.yml:
phases:
install: # install the pinned OpenTofu version
commands:
- /tmp/install-opentofu.sh --install-method standalone --opentofu-version "${TOFU_VERSION}"
pre_build: # quality gates
commands:
- tofu -chdir="${ENV_DIR}" fmt -check -recursive
- tofu -chdir="${ENV_DIR}" init -input=false
- tofu -chdir="${ENV_DIR}" validate
build: # produce a saved binary plan + a human-readable text version
commands:
- tofu -chdir="${ENV_DIR}" plan -input=false -lock-timeout=120s -out=tfplan.binary
- tofu -chdir="${ENV_DIR}" show -no-color tfplan.binary > plan.txt
artifacts:
files:
- "**/*" # carry the whole tree (incl. the saved plan) to the apply stagecicd/buildspec-apply.yml then consumes that saved plan
and applies exactly it:
build:
commands:
- tofu -chdir="${ENV_DIR}" init -input=false
- tofu -chdir="${ENV_DIR}" apply -input=false -lock-timeout=120s tfplan.binaryENV_DIR and TOFU_VERSION are environment variables the module sets per
pipeline, so the same buildspecs serve dev, staging, and prod.
Inside the iac-pipeline module
modules/iac-pipeline/main.tf builds everything a
pipeline needs. Highlights for a beginner:
-
An artifact bucket (S3) where the pipeline passes files between stages - hardened the same way as the state bucket (versioned, encrypted, public access blocked).
-
Two IAM roles: one for CodeBuild (allowed to run
tofuand create infra), one for CodePipeline (allowed to drive CodeBuild and read the source). These show the standard assume-role pattern:data "aws_iam_policy_document" "codebuild_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["codebuild.amazonaws.com"] # the CodeBuild service may assume this role } } } -
The manual approval stage is added conditionally with a
dynamicblock - the cleanest example of a dynamic block in the repo:dynamic "stage" { for_each = var.require_manual_approval ? [1] : [] # one stage if true, none if false content { name = "Approve" action { name = "ManualApproval" category = "Approval" owner = "AWS" provider = "Manual" version = "1" } } }A
dynamicblock generates zero or more copies of a nested block based on a collection - here, it inserts theApprovestage only whenrequire_manual_approvalis true. -
The build server's IAM role is intentionally broad (running
tofu applyneeds wide permissions) but bounded by the account it runs in plus a named allow-list of services. The code comments flag a TODO to tighten it further with a permission boundary before Prod - a good example of honest, evolving infra.