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.
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.
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.
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?
It records provider selections/checksums so plan and apply can initialize the same provider set rather than silently upgrading.
Why is plan.cache not placed in Git?
It is generated, potentially sensitive, tied to specific state/provider/source context and belongs in controlled short-lived evidence flow, not source history.
After manual file drift, which evidence proves the source did not change?
The recorded Git SHA and clean repository status. The drifted file is outside the managed source set and the new plan shows a provider/resource difference.
Why is resource_group not enough for a real
backend?
It serializes GitLab jobs, but the backend must still provide its own locking/integrity because state can be accessed outside that resource group or from other tools.
What is the safest response when the plan digest changed after review?
Stop and create a new review/evidence chain. Do not replace the reviewed file and continue.
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.
- Infrastructure as Code with OpenTofu and GitLab — official reference.
- GitLab-managed Terraform/OpenTofu state — official reference.
- OpenTofu integration in merge requests — official reference.
- CI/CD artifacts reports — terraform — official reference.
- CI/CD YAML syntax — resource_group and artifacts access — official reference.
- Resource groups — official reference.
- GitLab IaC troubleshooting — official reference.
- GitLab OpenTofu CI/CD component — official reference.
- GitLab deprecations and removals — official reference.
- glab opentofu state — official reference.
- OpenTofu 1.12 documentation — official reference.
- OpenTofu releases — official reference.
- HashiCorp local provider — official reference.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.