Terraform gives you reproducible infrastructure, but if engineers still apply changes manually from their laptops, you only solved half the problem.
A CI/CD pipeline turns Terraform from âcode that could be run consistentlyâ into âcode that is run consistently.â That matters for several reasons.
The same validation and deployment steps run every time. You do not rely on one engineer remembering the right command sequence.
Pipelines attach infrastructure actions to commits, branches, merge requests, and job logs. That makes it far easier to answer questions like:
terraform plan reviewed before apply?âCowboy infrastructureâ means someone changes production directly from a laptop, outside a reviewable pipeline, often with local credentials and local state. CI/CD reduces that risk by making the pipeline the normal path.
A good pipeline can validate automatically, publish a plan artifact, and require a manual approval before apply. That creates a safer change-management flow without slowing teams down too much.
Once more than one person manages infrastructure, a pipeline becomes the easiest way to standardize execution.
A practical GitLab Terraform pipeline often follows this flow:
validate -> plan -> apply (manual) -> destroy (manual)Each phase has a specific purpose:
This separation matters. You do not want âvalidate and deploy immediatelyâ as your only option.
Below is a complete .gitlab-ci.yml example for a Terraform project that uses GitLab-managed Terraform state and AWS credentials supplied through CI/CD variables.
image: hashicorp/terraform:1.9.8
stages:
- validate
- plan
- apply
- destroy
variables:
TF_ROOT: .
TF_IN_AUTOMATION: "true"
TF_INPUT: "false"
TF_STATE_NAME: "aws-project"
TF_PLAN_FILE: "tfplan"
TF_CLI_ARGS_init: "-no-color"
TF_CLI_ARGS_plan: "-no-color"
TF_CLI_ARGS_apply: "-no-color"
TF_CLI_ARGS_destroy: "-no-color"
before_script:
- cd "$TF_ROOT"
- terraform version
- terraform init -backend-config="address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}" -backend-config="lock_address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}/lock" -backend-config="unlock_address=${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}/lock" -backend-config="lock_method=POST" -backend-config="unlock_method=DELETE" -backend-config="retry_wait_min=5" -backend-config="username=gitlab-ci-token" -backend-config="password=${CI_JOB_TOKEN}"
validate:
stage: validate
script:
- terraform fmt -check -recursive
- terraform validate
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
plan:
stage: plan
script:
- terraform plan -out=${TF_PLAN_FILE}
- terraform show -no-color ${TF_PLAN_FILE} > plan.txt
artifacts:
name: "terraform-plan-${CI_COMMIT_REF_SLUG}"
paths:
- ${TF_ROOT}/${TF_PLAN_FILE}
- ${TF_ROOT}/plan.txt
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
apply:
stage: apply
script:
- terraform apply -auto-approve ${TF_PLAN_FILE}
dependencies:
- plan
when: manual
allow_failure: false
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
destroy:
stage: destroy
script:
- terraform destroy -auto-approve
when: manual
allow_failure: false
environment:
name: production
action: stop
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'This pipeline is intentionally simple and production-minded. It validates on branches and merge requests, always produces a plan, and only allows apply or destroy manually on the default branch.
The validate stage should be cheap, fast, and mandatory.
terraform fmt -check -recursiveThis checks whether your Terraform files are formatted according to Terraform's standard style.
terraform fmt -check -recursiveWhy care?
-check means âverify formatting, do not rewrite files.â That is exactly what you want in CI.
terraform validateThis validates the configuration's syntax and internal consistency.
terraform validateIt checks things like:
It does not create infrastructure. Think of it as structural validation.
The plan stage is where the pipeline becomes genuinely useful.
terraform plan -out=tfplanThe -out=tfplan flag saves the generated execution plan to a binary plan file. That matters because it creates a fixed artifact that can later be applied.
Because the plan is the reviewable proposed change set.
If the apply stage uses the exact plan produced earlier, you reduce the chance that conditions drift between âwhat we reviewedâ and âwhat got applied.â
The example pipeline also runs:
terraform show -no-color tfplan > plan.txtThat converts the binary plan into readable text and saves it as an artifact too. Reviewers can inspect plan.txt without re-running Terraform locally.
The apply stage is manual on the default branch.
apply:
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'That does two good things:
Many teams consider this the minimum acceptable production safety pattern.
terraform apply -auto-approve tfplanApplying the saved plan ensures the actual deployment matches the reviewed plan artifact, instead of recomputing a fresh plan at apply time.
A destroy stage is powerful and dangerous. Including it as a manual job is often useful for ephemeral environments, test projects, labs, or sandbox accounts.
For production, destroy permissions should usually be heavily restricted or omitted entirely.
Still, it is educational to include the stage because teams often need a controlled teardown path.
Terraform state must live somewhere shared and durable if CI/CD is going to manage infrastructure reliably.
GitLab offers managed Terraform state via an HTTP backend. That is why the pipeline's terraform init command passes backend settings dynamically.
Inside your Terraform code, add:
terraform {
backend "http" {}
}The pipeline then supplies the backend details at runtime:
addresslock_addressunlock_addressusernamepasswordIt is especially convenient for GitLab-centric teams because the state backend and the pipeline platform live together.
Never hardcode AWS keys in Terraform files or .gitlab-ci.yml.
Instead, store them as GitLab CI/CD variables:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_SESSION_TOKENAWS_DEFAULT_REGIONMark sensitive values as:
If you can use short-lived credentials via OIDC or another federated method, that is even better than long-lived access keys.
A clean Terraform workflow usually distinguishes between branch types.
On feature branches and merge requests, you want:
That gives reviewers visibility without risk.
On main or your default branch, you can allow a manual apply job after review.
That creates a workflow like this:
This is much safer than letting every developer run production apply from a laptop.
Many teams want different behavior in different environments.
A common example:
Here is a simplified GitLab pattern:
auto-apply-dev:
stage: apply
script:
- terraform apply -auto-approve ${TF_PLAN_FILE}
dependencies:
- plan
environment:
name: dev
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
manual-apply-prod:
stage: apply
script:
- terraform apply -auto-approve ${TF_PLAN_FILE}
dependencies:
- plan
when: manual
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'Because speed and risk tolerance are different.
You can take this further with separate state names, separate AWS accounts, separate runners, and separate protected variables.
When using GitLab-managed state, pick a state name per environment, for example:
aws-project-devaws-project-stagingaws-project-prodThen set TF_STATE_NAME through variables or rules so each environment writes to isolated state.
That prevents a dev pipeline from accidentally applying against production state.
A common repository structure looks like this:
terraform/
âââ modules/
â âââ vpc/
â âââ ec2/
â âââ s3/
âââ env/
âââ dev/
âââ staging/
âââ prod/Your pipeline can then run in a specific environment directory by setting TF_ROOT.
That keeps environment-specific variables, state names, and backends isolated while still sharing reusable modules.
terraform applyManual local applies have several downsides:
A pipeline is not magical, but it makes the safe path easier to follow consistently.
GitHub Actions can implement the same Terraform lifecycle:
fmt -checkvalidateThe main difference is platform integration.
The Terraform concepts stay the same. Only the CI syntax changes.
Once the basic pipeline works, teams often add:
tflint for style and static checkstfsec or checkov for security scanningThose additions are valuable, but the foundation should still be the same four-step flow: validate, plan, approved apply, controlled destroy.
If you are building your first Terraform pipeline, keep these rules in mind:
That combination gives you consistency, auditability, and a dramatically lower chance of accidental infrastructure drift.
<MCQExercise
exercise={{
id: 'terraform-cicd-pipeline-q1',
title: 'Question 1: Why Save tfplan?',
description: 'Why does the pipeline use terraform plan -out=tfplan and then apply that saved plan later?',
options: [
{ id: 'a', text: 'Because Terraform can only run in CI if every plan is stored as a binary file' },
{ id: 'b', text: 'Because saving the plan helps ensure the reviewed change set is the same one that gets applied' },
{ id: 'c', text: 'Because GitLab cannot display text plans as artifacts' },
{ id: 'd', text: 'Because Terraform validate requires a plan file before it can run' },
],
correctId: 'b',
explanation: 'Saving the plan as tfplan lets the apply stage use the exact reviewed execution plan instead of recalculating one later. That improves consistency and change control.',
wrongExplanations: {
a: 'Terraform can run without saving a binary plan, but saving it is a best-practice for controlled pipelines.',
c: 'GitLab can store readable text artifacts too. The binary plan is about consistency, not display limitations.',
d: 'terraform validate does not require a plan file.',
},
}}
/>
<MCQExercise exercise={{ id: 'terraform-cicd-pipeline-q2', title: 'Question 2: Feature Branch Behavior', description: 'What is the safest default behavior for Terraform pipelines on feature branches?', options: [ { id: 'a', text: 'Auto-apply every branch to production so feedback is immediate' }, { id: 'b', text: 'Run validate and plan, but do not apply infrastructure changes automatically' }, { id: 'c', text: 'Skip validation and only run apply after merge request approval' }, { id: 'd', text: 'Destroy the environment after every feature branch commit automatically' }, ], correctId: 'b', explanation: 'Feature branches are usually the right place for validation and plan generation only. That gives reviewers visibility into the proposed changes without risking unintended infrastructure updates.', wrongExplanations: { a: 'Auto-applying feature branches to production is extremely risky.', c: 'Validation should not be skipped. It is one of the cheapest and most useful CI checks.', d: 'Automatic destroy behavior on every branch commit is not a safe or typical default.', }, }} />
<MCQExercise exercise={{ id: 'terraform-cicd-pipeline-q3', title: 'Question 3: Remote State', description: 'Why is remote Terraform state important in a CI/CD pipeline?', options: [ { id: 'a', text: 'Because Terraform can only validate configurations when state is stored remotely' }, { id: 'b', text: 'Because remote state provides a shared source of truth and lets CI jobs coordinate safely' }, { id: 'c', text: 'Because remote state automatically fixes all drift without running plan' }, { id: 'd', text: 'Because GitLab refuses to run Terraform jobs with local state files present in the repository' }, ], correctId: 'b', explanation: 'A pipeline needs shared, durable, centrally managed state. Remote state lets different CI jobs and team members operate against the same source of truth instead of depending on a local state file on one machine.', wrongExplanations: { a: 'Validation does not require remote state. Remote state matters for shared operations and applies.', c: 'Remote state does not remove the need to run plan or review drift.', d: 'GitLab does not enforce that rule; it is an operational best practice, not a platform prohibition.', }, }} />
Because without it, the apply recalculates from whatever state and real infrastructure exist at that moment, which may differ from what the reviewer saw. terraform plan -out=tfplan followed by terraform apply tfplan guarantees the approved change is the applied change. The caveat is that a plan file contains sensitive values in plain text, so treat the artifact as a secret and expire it quickly.
Use OIDC. Register your CI provider as an OIDC identity provider in AWS, create a role whose trust policy accepts its tokens with conditions restricting the specific repository and branch, and have the pipeline exchange a short-lived token for temporary credentials. Nothing long-lived is stored, there is nothing to rotate, and a leaked log contains no reusable secret. The equivalent mechanism exists for Azure and GCP.
State locking, which a proper remote backend provides. S3 with native locking or a DynamoDB table, or Terraform Cloud. The second run then waits or fails rather than corrupting state. Add pipeline-level concurrency control as well, since queueing at the CI layer produces a clearer outcome than a lock timeout. If a run is killed mid-apply the lock can stick, and terraform force-unlock clears it once you have confirmed nothing is actually running.
For non-production, yes. Fast feedback is worth more than the risk. For production, a manual approval gate is the norm, because a plan can contain a resource replacement that destroys data and no amount of automated checking reliably catches intent. A reasonable middle ground is automatic apply when the plan contains only additions and in-place updates, with approval required whenever it contains a destroy or a replacement.
Keep a separate state file per environment and run the same pipeline with different backend configuration and credentials, supplied via -backend-config and -var-file. Promote by applying identical code to each environment in turn, dev, then staging, then production, rather than maintaining divergent branches. Scope each environment's credentials to that environment alone, so a pipeline misconfiguration cannot reach production from a staging job.
Secure Terraform code by scanning with tfsec and Checkov, protecting secrets in state, fixing common misconfigurations and adding checks to CI/CD.
Learn what Terraform is and how Infrastructure as Code works, why it beats clicking through consoles, and how it compares to Ansible and CloudFormation.
Install Terraform on macOS, Linux and Windows with the official package managers, verify the version, switch versions with tfenv and run your first project.