Skip to content

Terraform CI/CD

GitHub Actions runs Terraform for all three infrastructure stacks. Operators no longer terraform apply from a laptop — change lands via PR, plan is reviewed on the PR, merge applies.

This pipeline covers infrastructure only (infrastructure/gcp, infrastructure/aws, infrastructure/cloudflare). Kubernetes manifests are delivered separately by Argo CD — see Argo CD. The K8s workflow here only validates manifests (kustomize build + kubeconform); it never applies them.

Workflows

Workflow File Trigger Does
Terraform Plan .github/workflows/terraform-plan.yml PR touching infrastructure/** fmt -check + plan each stack, posts the plan as a PR comment
Terraform Apply .github/workflows/terraform-apply.yml merge to main, or manual dispatch applies — see the two paths below
K8s Validate .github/workflows/k8s-validate.yml PR touching k8s/** kustomize build + kubeconform, no apply
Secret Scan .github/workflows/secret-scan.yml PR or push to main Runs Gitleaks to detect committed secrets/keys/credentials
Infra Lint & Security .github/workflows/infra-lint.yml PR or push to main Runs TFLint, Trivy IaC scanner, yamllint, and ShellCheck

All run on GitHub-hosted runners — deliberately not the self-hosted ARC pool, which runs on the cluster we cap at 2 nodes. CI must not consume that capacity.

Auth — no long-lived keys

Neither cloud stores a static credential in GitHub.

  • GCPWorkload Identity Federation. GitHub's OIDC token is exchanged for short-lived credentials impersonating terraform-ci@development-485000.iam.gserviceaccount.com. The trust is scoped to assertion.repository == tomoda-labs/devops — no other repo or fork can assume it. Defined in infrastructure/gcp/cicd_wif.tf.
  • AWS — an OIDC-assumable IAM role, arn:aws:iam::373544523193:role/terraform-ci, trust scoped to repo:tomoda-labs/devops:*. Defined in infrastructure/aws/cicd_oidc.tf.

Every stack authenticates to GCP even when it targets AWS/Cloudflare, because all three keep Terraform state in the same GCS bucket (gs://development-485000-tfstate) and the AWS/Cloudflare provider secrets are pulled from GCP Secret Manager at run time (never stored in GitHub). The wiring values live in repo variables GCP_WIF_PROVIDER, GCP_TERRAFORM_CI_SA, AWS_TERRAFORM_CI_ROLE.

See IAM → terraform-ci for the SA's roles.

Which infrastructure splits dev/prod

This is the crux of the pipeline's blast radius. Only AWS has a real dev/prod split.

Stack Splits dev/prod? Mechanism What actually differs
AWS Yes Terraform workspaces (default = dev, prod) S3 assets bucket, CloudFront distribution, ACM cert, IAM users (uploader + eso-reader), Secrets Manager creds, CloudFront signing key — each named …-${var.environment}, a separate copy per workspace
GCP No one project, one cluster dev/prod split lives in K8s namespaces, not Terraform. var.environment exists but no resource consumes it. The one nuance: Artifact Registry has separate tomoda-dev-repo / tomoda-prod-repo repos, and prod-only capacity (enable_stateful_pool, node ceilings) is a variable toggle inside the same state — not a separate environment
Cloudflare No single copy Access, tunnel, worker — one of each

Consequence: "releasing prod" only ever means moving the AWS prod workspace. There is no separate GCP prod environment to deploy.

AWS prod workspace needs TF_VAR_environment=prod

var.environment defaults to dev and nothing maps the workspace name to it. Selecting the prod workspace without setting TF_VAR_environment=prod makes Terraform want to rename every prod resource to its dev name — destroying the prod bucket, IAM users, and secrets. The apply workflow's prod path sets this explicitly. Never run a prod apply without it.

The two apply paths

Dev — automatic on merge

Merging a PR to main that touched infrastructure/** runs the apply-dev job: it applies all three stacks — GCP (the single project), AWS default (dev) workspace, and Cloudflare.

The gate is PR review. Nothing reaches main without a reviewed, merged PR, and the Plan workflow has already posted the real diff on that PR. Review the posted plan before approving.

Prod — manual, AWS-only

Prod is a manual workflow_dispatch on the Apply workflow (env = prod). It runs the apply-prod job, which applies only the AWS prod workspace — the assets bucket + CloudFront, the sole resources with a dev/prod split. GCP and Cloudflare are not touched on this path; their prod-flavoured changes ride the normal merge-to-main dev apply.

Actions → Terraform Apply → Run workflow → env: prod → Run

Gate limitations on the free plan

Two GitHub features this pipeline would otherwise use require a paid plan and are unavailable:

  • Branch protection / required status checks — can't force the Plan job to pass before merge (403 "Upgrade to GitHub Pro or make this repository public"). Mitigation: PR review is the enforced gate; the reviewer must eyeball the posted plan.
  • Environment approval gates — can't insert a human-approval pause before apply. Mitigation: prod is strictly manual dispatch; dev's gate is PR review.

If the repo upgrades to Pro, add branch protection requiring plan (gcp) / plan (aws) / plan (cloudflare) and wrap apply in an Environment with required reviewers.

Serialization

Applies share one GCS state lock per stack. The Apply workflow uses a tf-apply concurrency group and max-parallel: 1 on the stack matrix so runs queue rather than collide on the lock. cancel-in-progress: false — a half-finished apply is never cancelled.

Image vulnerability scanning

Not part of this Terraform pipeline — it belongs in the app-side Cloud Build (tomoda monorepo). AR's built-in scan is off by cost decision; a trivy build step is the plan. See Cloud Build → Vulnerability scanning.

Adding infra that needs a new secret or variable

  • Non-secret var (like project_id) — set it inline in the workflow's stack-vars step.
  • Secret (provider token, password) — put it in GCP Secret Manager and add a gcloud secrets versions access line to the "Load provider secrets" step. Never add it as a GitHub secret. See Secrets Management.