Terraform CI/CD Pipeline
Build a GitLab CI/CD pipeline for Terraform with validate, plan, apply, and destroy stages, environment controls, remote state, and secure credentials handling.
Why Infrastructure as Code Belongs in CI/CD
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.
1. Consistency
The same validation and deployment steps run every time. You do not rely on one engineer remembering the right command sequence.
2. Audit trail
Pipelines attach infrastructure actions to commits, branches, merge requests, and job logs. That makes it far easier to answer questions like:
- Who changed production networking?
- Which commit introduced the IAM policy update?
- Was a
terraform planreviewed before apply?
3. No cowboy infrastructure
“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.
4. Safer approvals
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.
5. Team scale
Once more than one person manages infrastructure, a pipeline becomes the easiest way to standardize execution.
The Standard Terraform Pipeline Pattern
A practical GitLab Terraform pipeline often follows this flow:
validate -> plan -> apply (manual) -> destroy (manual)
Each phase has a specific purpose:
- validate checks formatting and syntax
- plan calculates the proposed change set and stores it as an artifact
- apply applies an approved plan
- destroy is present for controlled teardown, usually only in non-production environments or as a protected manual job
This separation matters. You do not want “validate and deploy immediately” as your only option.
The Full GitLab CI/CD Example
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.
Understanding the Validate Stage
The validate stage should be cheap, fast, and mandatory.
terraform fmt -check -recursive
This checks whether your Terraform files are formatted according to Terraform's standard style.
terraform fmt -check -recursive
Why care?
- consistent formatting makes reviews easier
- it removes pointless whitespace discussions in pull requests
- it catches files that were edited but not formatted
-check means “verify formatting, do not rewrite files.” That is exactly what you want in CI.
terraform validate
This validates the configuration's syntax and internal consistency.
terraform validate
It checks things like:
- invalid resource arguments
- malformed expressions
- missing required attributes
- inconsistent references inside the loaded configuration
It does not create infrastructure. Think of it as structural validation.
Understanding the Plan Stage
The plan stage is where the pipeline becomes genuinely useful.
terraform plan -out=tfplan
The -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.
Why store the plan as an artifact?
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.txt
That converts the binary plan into readable text and saves it as an artifact too. Reviewers can inspect plan.txt without re-running Terraform locally.
Manual Apply as a Safety Gate
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:
- it prevents feature branches from applying infrastructure changes automatically
- it forces a human approval step for the branch that matters most
Many teams consider this the minimum acceptable production safety pattern.
Why apply the saved plan file?
terraform apply -auto-approve tfplan
Applying the saved plan ensures the actual deployment matches the reviewed plan artifact, instead of recomputing a fresh plan at apply time.
Manual Destroy Stage
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.
Storing State in GitLab-Managed Terraform State
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.
Minimal Terraform backend block
Inside your Terraform code, add:
terraform {
backend "http" {}
}
The pipeline then supplies the backend details at runtime:
addresslock_addressunlock_addressusernamepassword- lock/unlock HTTP methods
Why this pattern is useful
- state is centralized
- locking is handled by GitLab
- CI jobs can access the same state consistently
- you do not hardcode backend secrets into the repo
It is especially convenient for GitLab-centric teams because the state backend and the pipeline platform live together.
Storing AWS Credentials as GitLab CI/CD Variables
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_KEY- optionally
AWS_SESSION_TOKEN - optionally
AWS_DEFAULT_REGION
Mark sensitive values as:
- masked
- protected (for production credentials)
Why masked and protected?
- masked prevents accidental log exposure
- protected restricts use to protected branches or tags
If you can use short-lived credentials via OIDC or another federated method, that is even better than long-lived access keys.
Branch-Based Workflow
A clean Terraform workflow usually distinguishes between branch types.
Feature branches: plan only
On feature branches and merge requests, you want:
- formatting checks
- validation
- a readable plan artifact
- no automatic infrastructure changes
That gives reviewers visibility without risk.
Default branch: allowed to apply
On main or your default branch, you can allow a manual apply job after review.
That creates a workflow like this:
- open merge request
- pipeline runs validate and plan
- review the code and plan output
- merge to main
- trigger manual apply on main
This is much safer than letting every developer run production apply from a laptop.
Environment-Specific Pipelines
Many teams want different behavior in different environments.
A common example:
- dev auto-applies after a successful plan
- prod requires manual approval
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'
Why separate behavior by environment?
Because speed and risk tolerance are different.
- Development environments benefit from fast feedback.
- Production environments benefit from explicit control and approvals.
You can take this further with separate state names, separate AWS accounts, separate runners, and separate protected variables.
Suggested Multi-Environment State Naming
When using GitLab-managed state, pick a state name per environment, for example:
aws-project-devaws-project-stagingaws-project-prod
Then 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.
Example of a Safer Project Layout
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.
Why CI Pipelines Beat Manual terraform apply
Manual local applies have several downsides:
- they depend on local AWS credentials
- they may use local state accidentally
- they are harder to audit
- they are easier to run against the wrong workspace or directory
- they create “works on my laptop” process drift
A pipeline is not magical, but it makes the safe path easier to follow consistently.
GitHub Actions Alternative
GitHub Actions can implement the same Terraform lifecycle:
- checkout code
- install Terraform
- run
fmt -check - run
validate - generate a plan
- store the plan artifact
- require manual approval before apply using environments or protected workflows
The main difference is platform integration.
GitLab strengths in this pattern
- built-in GitLab-managed Terraform state
- tight MR + pipeline + approval integration
- simple artifact and variable management
GitHub Actions strengths
- strong ecosystem and marketplace actions
- easy integration with GitHub environments and branch protections
- good fit if the repo already lives entirely in GitHub
The Terraform concepts stay the same. Only the CI syntax changes.
Common Pipeline Improvements
Once the basic pipeline works, teams often add:
tflintfor style and static checkstfsecorcheckovfor security scanning- merge request comments containing plan summaries
- separate runners per environment
- policy checks before apply
- cost estimation tools
Those additions are valuable, but the foundation should still be the same four-step flow: validate, plan, approved apply, controlled destroy.
Final Recommendations
If you are building your first Terraform pipeline, keep these rules in mind:
- Always validate automatically.
- Always generate a plan before apply.
- Store the plan as an artifact.
- Restrict apply to the right branch and the right people.
- Keep state remote and shared.
- Store cloud credentials in CI variables, not code.
- Treat production differently from development.
That combination gives you consistency, auditability, and a dramatically lower chance of accidental infrastructure drift.
Practice Questions
Question 1: Why Save tfplan?
Why does the pipeline use `terraform plan -out=tfplan` and then apply that saved plan later?
Question 2: Feature Branch Behavior
What is the safest default behavior for Terraform pipelines on feature branches?
Question 3: Remote State
Why is remote Terraform state important in a CI/CD pipeline?