Skip to content

OpenTofu state and caching

tofu-plan and tofu-apply are the two components that actually touch AWS. Everything about how they authenticate, hand off a plan, and cache their way out of network flakiness follows from one starting constraint: no static credentials, anywhere.

Why OIDC, not access keys

GitLab CI mints a short-lived OIDC ID token via the id_tokens: keyword. The component writes it to a file and exports the three environment variables the AWS SDK's web-identity credential provider looks for (AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE, AWS_REGION) — no explicit aws sts call. OpenTofu's aws provider performs the AssumeRoleWithWebIdentity exchange itself, refreshes it, and the credentials are never written to disk beyond the token. This is the GitLab-side mirror of what GitHub Actions OIDC did for the project before its GitLab migration, and the reason the trust relationship exists on the phpboyscout-automation IAM role at all — see 2026-05-16-tofu-plan-apply-v0.2.md.

The one consumer-side requirement this imposes: the stack's aws provider must not hardcode a profile. A static profile short-circuits the credential chain before OIDC ever gets a chance — the provider looks for ~/.aws/credentials on a runner that has none, and fails. The safe pattern is a profile variable that defaults to null in CI:

variable "aws_profile" {
  type    = string
  default = null   # null in CI — the OIDC web-identity chain is used instead
}

provider "aws" {
  profile = var.aws_profile
}

Two components, not one mode-switched component

tofu-plan and tofu-apply are deliberately separate templates rather than one tofu component with a mode: plan|apply input. They have different rules: defaults (plan on branches/MRs; apply on the default branch, manual-gated); tofu-apply needs: the plan job's artifact, a dependency a single component can't cleanly express on itself; and a consumer's .gitlab-ci.yml reads more honestly as a plan stage and an apply stage than as one component wearing two hats.

The plan → apply handoff, two ways

tofu-plan writes tfplan.cache (the binary plan) and tfplan.json (a GitLab terraform report, so the MR widget shows an add/change/destroy summary) as artifacts. tofu-apply applies the exact plan that was reviewed — never a fresh re-plan — so tofu itself rejects the apply outright if state moved in between (fail-safe by design).

There are two ways to get that plan artifact to the apply job, selected by plan_source:

  • job (default) — same pipeline. tofu-apply declares needs: [{ job: tofu-plan, artifacts: true }]; plan and apply co-occur, normally on the default branch.
  • ref — cross pipeline. The plan was banked by an earlier, different pipeline (typically the main push that merged a Release MR); the apply — typically triggered by the release tag — downloads the latest artifact for that job on that ref via the GitLab jobs-artifacts API.

These aren't independent settings: the retrieval mode and the trigger are the same decision. needs: and rules: can't be conditionally interpolated inline, so each mode's wiring — where the plan comes from and when the apply fires — lives together in a hidden job that tofu-apply extends (.tofu-apply--plan--job / .tofu-apply--plan--ref). Mixing them up is a hard pipeline-creation error, not a silent bug: a job-mode apply on a tag pipeline would needs: a tofu-plan job that, per its own rule, never runs on a tag at all.

ref-mode apply is additionally restricted to tags matching tag_pattern (strict semver by default) — an unrestricted if: $CI_COMMIT_TAG would let a prerelease or a stray hand-pushed tag trigger a real tofu apply. See 2026-05-16-tofu-apply-plan-sources-v0.3.md.

A known, accepted edge case: the jobs-artifacts API returns the latest successful pipeline's artifact, not necessarily the latest pipeline's — if main's most recent run failed, ref mode happily applies an older green plan. The component trusts the consumer's release-MR merge gate to keep that window small; it doesn't query pipeline status itself.

Caching: keeping OpenTofu off the network

Two caches, for two different reasons:

  • Provider plugin cache (TF_PLUGIN_CACHE_DIR, on tofu-plan, tofu-apply, and tofu-validate) — providers are immutable once published, so a static cache key keeps every locked version warm indefinitely rather than evicting on each Cargo.lock- style state change.
  • Resolved-modules cache (.terraform/modules/, on tofu-validate) — tofu init downloads every module source a config references, even one gated behind count = 0, pulling from upstream registries like registry.opentofu.org. Caching the resolved modules means a warm run skips the network entirely — which incidentally also masks a transient upstream-registry outage that would otherwise fail validate for a reason that has nothing to do with the code under review.

See also