Layer 5 - cicd: The Delivery Pipelines

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:

TEXT
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 tofu commands.
  • CodeStar Connection - the secure link to your GitHub repo. The first time you apply cicd, this connection is created in a PENDING state 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:

YAML
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 stage

cicd/buildspec-apply.yml then consumes that saved plan and applies exactly it:

YAML
  build:
    commands:
      - tofu -chdir="${ENV_DIR}" init -input=false
      - tofu -chdir="${ENV_DIR}" apply -input=false -lock-timeout=120s tfplan.binary

ENV_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 tofu and create infra), one for CodePipeline (allowed to drive CodeBuild and read the source). These show the standard assume-role pattern:

    HCL
    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 dynamic block - the cleanest example of a dynamic block in the repo:

    HCL
    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 dynamic block generates zero or more copies of a nested block based on a collection - here, it inserts the Approve stage only when require_manual_approval is true.

  • The build server's IAM role is intentionally broad (running tofu apply needs 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.

Adesh Tamrakar
SOFTWARE ENGINEER · VAULT

Notes, insights and random discoveries from a working engineer's vault - written for future me, published for you.