Chapter 29Lesson 02~265 minutes

Infrastructure-as-Code Pipelines, Terraform/OpenTofu Workflows, Plan Reviews, State Safety, and Drift Controls: Guided Hands-On Workflow and Core Operations

Create a disposable OpenTofu workflow against a local-file resource: format, validate, generate and hash a saved plan, simulate review, apply exactly that plan, inject controlled drift, diagnose it, and clean up with a reviewed destroy plan.

Hands-onOpenTofu 1.12.6local provider 2.9.0Plan digestExact apply

Learning objectives

  • Create a reproducible disposable OpenTofu project using OpenTofu 1.12.6 and hashicorp/local 2.9.0.
  • Run formatting, initialization, validation and planning before any mutation.
  • Hash and review a saved plan, then apply exactly that plan.
  • Capture source/provider/plan/state/external evidence without publishing secrets.
  • Inject controlled drift, interpret detailed exit codes, and clean up through a reviewed destroy plan.

1. Lab scenario and trust boundary

Create a disposable repository named glci-ch29-lab. The “infrastructure” is only a file under sandbox/, managed by the official local provider. This makes plan/apply/drift mechanics real without a cloud account. The provider must be downloaded from its registry, but no cloud credential or production target is used.

Side-effect boundary: every mutation stays under the lab repository. Do not substitute a real cloud backend, production state, production credentials or shared infrastructure while learning this workflow.

2. Reproducibility assumptions

Item Pinned/recorded value Reason
OpenTofu CLI 1.12.6 Current stable release on 2026-09-12; includes current security fixes.
Provider hashicorp/local 2.9.0 Small, official provider for disposable local resources.
Backend Local backend for mandatory local workflow No remote credential; state stays in the disposable lab directory.
Source Exact Git SHA plus dirty-tree check Prevents reviewing one configuration and applying another.
Plan plan.cache plus SHA-256 digest Concrete review/apply identity.
Evidence evidence/ text/hashes only Keeps the raw state and provider cache out of the evidence packet.

3. Preflight: prove the workspace before creating anything

mkdir -p glci-ch29-lab && cd glci-ch29-lab
git init
mkdir -p evidence sandbox
printf '.terraform/\n*.tfstate\n*.tfstate.*\nplan.cache\ndestroy.cache\nsandbox/managed.txt\n' > .gitignore
tofu version
command -v sha256sum
command -v jq || true
git status --short

If your installed OpenTofu differs, either install 1.12.6 for this lab or record the exact version and treat output differences as a compatibility deviation. Never silently claim the pinned version if another binary ran.

4. Create the smallest useful IaC configuration

terraform {
  required_version = "= 1.12.6"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "= 2.9.0"
    }
  }
}

variable "message" {
  type        = string
  description = "Synthetic content written only inside the disposable lab"
}

resource "local_file" "managed" {
  filename        = "${path.module}/sandbox/managed.txt"
  content         = var.message
  file_permission = "0644"
}

output "managed_path" {
  value = local_file.managed.filename
}

Save this as main.tf. The provider is pinned exactly so the first initialization creates a lock file whose selection can be carried into plan/apply evidence.

5. Inspect before mutation: format, initialize, validate

tofu fmt -check -diff
tofu init -input=false
tofu validate
tofu providers
sha256sum .terraform.lock.hcl | tee evidence/provider-lock.sha256
git status --short

init downloads the provider and creates .terraform.lock.hcl; it does not create sandbox/managed.txt. Commit the configuration and lock file, not the provider cache or local state.

git add main.tf .terraform.lock.hcl .gitignore
git commit -m "ch29: add disposable IaC lab"
git rev-parse HEAD | tee evidence/source-sha.txt
test -z "$(git status --porcelain)"

6. Generate a saved plan and preserve its identity

tofu plan -input=false   -var='message=reviewed-v1'   -out=plan.cache   -no-color | tee evidence/plan-human.txt
sha256sum plan.cache | tee evidence/plan.sha256
sha256sum .terraform.lock.hcl | tee evidence/provider-lock-after-plan.sha256
tofu version -json > evidence/tofu-version.json
printf 'source_sha=%s\n' "$(git rev-parse HEAD)" > evidence/plan-metadata.txt
printf 'plan_file=plan.cache\n' >> evidence/plan-metadata.txt
printf 'plan_job=local-plan\n' >> evidence/plan-metadata.txt

The raw binary plan is kept locally for apply. The evidence packet contains its digest and human-readable synthetic plan. In a real project, assume plan content is sensitive and restrict any uploaded copy.

7. Simulate an approval that names the exact plan

Do not simulate review with “looks good.” Record what was approved:

PLAN_DIGEST=$(cut -d' ' -f1 evidence/plan.sha256)
SOURCE_SHA=$(cat evidence/source-sha.txt)
LOCK_DIGEST=$(cut -d' ' -f1 evidence/provider-lock.sha256)
cat > evidence/review.txt <<EOF
review_status=approved-for-lab
source_sha=$SOURCE_SHA
plan_sha256=$PLAN_DIGEST
provider_lock_sha256=$LOCK_DIGEST
target=local:sandbox/managed.txt
reviewer=fake-reviewer@example.test
EOF
cat evidence/review.txt

The fake reviewer is synthetic course metadata. A real GitLab approval/manual job should preserve the actual actor in GitLab audit/pipeline records rather than trusting a user-authored text file.

8. Guard apply against source or plan substitution

test -z "$(git status --porcelain)"
sha256sum -c evidence/plan.sha256
grep -Fx "source_sha=$(git rev-parse HEAD)" evidence/review.txt
grep -Fx "plan_sha256=$(cut -d' ' -f1 evidence/plan.sha256)" evidence/review.txt
sha256sum .terraform.lock.hcl | tee evidence/provider-lock-before-apply.sha256
cmp evidence/provider-lock.sha256 evidence/provider-lock-before-apply.sha256
tofu init -input=false -lockfile=readonly

If any guard fails, preserve the evidence and create a new plan/review. Do not overwrite the old digest or silently regenerate plan.cache.

9. Apply exactly the reviewed plan

tofu apply -input=false -auto-approve plan.cache | tee evidence/apply.txt
tofu state list | tee evidence/state-list.txt
sha256sum sandbox/managed.txt | tee evidence/managed-file.sha256
cat sandbox/managed.txt | tee evidence/managed-file.txt

Expected external state is a single file whose content is reviewed-v1. The state list should contain local_file.managed. The plan digest and source SHA remain the evidence linking review to mutation.

10. Read-only verification after apply

test "$(cat sandbox/managed.txt)" = 'reviewed-v1'
tofu plan -input=false -detailed-exitcode   -var='message=reviewed-v1'   -no-color > evidence/post-apply-plan.txt
rc=$?
test "$rc" -eq 0
printf 'post_apply_drift=none\n' | tee evidence/post-apply-result.txt

A zero exit code now proves that, at that observation time, the refreshed local resource matches the configuration for reviewed-v1. It still does not prove future absence of drift.

11. Inject controlled drift without touching configuration or state

printf 'out-of-band-drift\n' > sandbox/managed.txt
sha256sum sandbox/managed.txt | tee evidence/drifted-file.sha256
set +e
tofu plan -input=false -detailed-exitcode   -var='message=reviewed-v1'   -no-color > evidence/drift-plan.txt
rc=$?
set -e
printf 'drift_exit_code=%s\n' "$rc" | tee evidence/drift-result.txt
test "$rc" -eq 2

Exit code 2 is expected: the plan succeeded and found a change. Preserve drift-plan.txt. Do not immediately apply it. The exercise is to prove that drift becomes a reviewable observation.

12. Diagnose ownership before reconciling

The source SHA and state have not changed; only the external file changed. That local observation points to external drift rather than a configuration edit. In a cloud environment you would still need audit logs, actor identity and provider-specific evidence before deciding whether to reconcile.

Wrong response: “drift detected, therefore run apply automatically.” The correct next state is “drift evidence captured; owner/cause and reconciliation intent require review.”

13. Map the same workflow to GitLab jobs

This compact example shows the control-plane mapping. Record CI_PIPELINE_SOURCE, CI_COMMIT_SHA, pipeline ID and job ID before the IaC commands so the plan can be tied back to the exact GitLab event. It is intentionally not a complete remote-state production pipeline:

stages: [validate, plan, apply, verify]

variables:
  TF_IN_AUTOMATION: "true"

validate:
  stage: validate
  script:
    - tofu fmt -check -diff
    - tofu init -input=false -lockfile=readonly
    - tofu validate

plan:
  stage: plan
  script:
    - tofu init -input=false -lockfile=readonly
    - tofu plan -input=false -out=plan.cache -var='message=reviewed-v1'
    - sha256sum plan.cache > plan.sha256
    - printf 'source_sha=%s\n' "$CI_COMMIT_SHA" > plan-metadata.txt
  artifacts:
    access: developer
    expire_in: 1 day
    paths: [plan.cache, plan.sha256, plan-metadata.txt, .terraform.lock.hcl]

apply_lab:
  stage: apply
  needs: [plan]
  resource_group: tofu-lab-state
  when: manual
  allow_failure: false
  rules:
    - if: '$CI_COMMIT_BRANCH == "glci/ch29-lab"'
  script:
    - sha256sum -c plan.sha256
    - grep -Fx "source_sha=$CI_COMMIT_SHA" plan-metadata.txt
    - tofu init -input=false -lockfile=readonly
    - tofu apply -input=false -auto-approve plan.cache

verify:
  stage: verify
  needs: [apply_lab]
  rules:
    - if: '$CI_COMMIT_BRANCH == "glci/ch29-lab"'
  script:
    - tofu plan -input=false -detailed-exitcode -var='message=reviewed-v1' || test "$?" -eq 2

A real multi-job GitLab run needs a backend whose state survives ephemeral job workspaces. Use GitLab-managed state or another reviewed remote backend; do not pass state as a casual artifact.

14. Optional GitLab-managed state path

GitLab-managed state is Free-tier compatible. Current GitLab documentation recommends the versioned OpenTofu component. For example, pin the component and an OpenTofu version actually published by that component release:

include:
  - component: gitlab.com/components/opentofu/validate-plan-apply@4.8.1
    inputs:
      version: 4.8.1
      opentofu_version: 1.12.5
      root_dir: .
      state_name: ch29-lab

stages: [validate, build, deploy]

Verify the component’s current input names in its release documentation before adopting it, because components evolve. The important architecture is: released component tag + supported OpenTofu image + remote state identity + reviewed plan + constrained deploy job.

15. Challenge: which layer needs correction?

A teammate proposes: “upload terraform.tfstate from the plan job, download it in apply, then let apply run in parallel because the artifact makes state deterministic.” Identify at least four wrong layers.

Reveal a strong answer

The state backend layer is wrong because state should be coordinated by a backend, not generic artifacts. The security layer is wrong because state can contain secrets. The concurrency layer is wrong because parallel applies still race on external/provider state. The evidence layer is wrong because copying a state snapshot does not bind the apply to the reviewed plan. Use a protected backend/lock, saved plan digest, provider/source identity and serialized apply instead.

16. Cleanup with a reviewed destroy plan

First restore the managed file using the previously reviewed apply only if that reconciliation is explicitly accepted for the disposable lab. Then generate and apply a separate destroy plan:

# Disposable lab only: reconcile the known drift under explicit lab intent.
tofu plan -input=false -out=reconcile.cache -var='message=reviewed-v1'
sha256sum reconcile.cache > evidence/reconcile.sha256
sha256sum -c evidence/reconcile.sha256
tofu apply -input=false -auto-approve reconcile.cache

# Review and execute cleanup as its own mutation.
tofu plan -destroy -input=false -out=destroy.cache -var='message=reviewed-v1'   -no-color | tee evidence/destroy-plan.txt
sha256sum destroy.cache | tee evidence/destroy.sha256
sha256sum -c evidence/destroy.sha256
tofu apply -input=false -auto-approve destroy.cache | tee evidence/destroy-apply.txt
test ! -e sandbox/managed.txt
printf 'cleanup=verified\n' | tee evidence/cleanup.txt

Knowledge check

Why does the lab commit .terraform.lock.hcl?

Why is plan.cache not placed in Git?

After manual file drift, which evidence proves the source did not change?

Why is resource_group not enough for a real backend?

What is the safest response when the plan digest changed after review?

Next lesson

Next lesson

Lesson 3 turns these mechanics into design decisions for MR planning, saved plans, apply gates, remote state and team-scale autonomy.

Version and compatibility note

GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.

Official references and version notes

Documentation verification date: 2026-09-12. GitLab-managed OpenTofu/Terraform state and merge-request plan integration are available on Free, Premium, and Ultimate. GitLab’s legacy Terraform CI/CD templates were removed in GitLab 18.0; current guidance is the versioned OpenTofu CI/CD component. GitLab-managed OpenTofu/Terraform state support in the current CLI flow was introduced in GitLab 18.3 and requires glab 1.66+. Plan files are not encrypted by GitLab or OpenTofu and can contain credentials or sensitive values; restrict artifact access and never treat plan output as safe by default. The current released OpenTofu component used for optional examples is 4.8.1; that release supports OpenTofu 1.12.5 and publishes signed rootless images. The standalone local lab pins OpenTofu 1.12.6 (latest stable on the verification date) and hashicorp/local 2.9.0. The optional component example uses release 4.8.1 with OpenTofu 1.12.5 because that exact component release published 1.12.5 images. The standalone lab can use OpenTofu 1.12.6 independently.

Current assumptions used in this chapter: Version-sensitive YAML, Runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations must be verified against the exact GitLab, GitLab Runner, tool, and external-system versions used in production. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for reproducible work.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.