DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Terraform CI/CD Pipeline

PreviousPrev
Next

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 plan reviewed 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:

  1. it prevents feature branches from applying infrastructure changes automatically
  2. 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:

  • address
  • lock_address
  • unlock_address
  • username
  • password
  • 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_ID
  • AWS_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:

  1. open merge request
  2. pipeline runs validate and plan
  3. review the code and plan output
  4. merge to main
  5. 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-dev
  • aws-project-staging
  • aws-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:

  • tflint for style and static checks
  • tfsec or checkov for 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:

  1. Always validate automatically.
  2. Always generate a plan before apply.
  3. Store the plan as an artifact.
  4. Restrict apply to the right branch and the right people.
  5. Keep state remote and shared.
  6. Store cloud credentials in CI variables, not code.
  7. Treat production differently from development.

That combination gives you consistency, auditability, and a dramatically lower chance of accidental infrastructure drift.

Practice Questions

Exercise

Question 1: Why Save tfplan?

Why does the pipeline use `terraform plan -out=tfplan` and then apply that saved plan later?

Exercise

Question 2: Feature Branch Behavior

What is the safest default behavior for Terraform pipelines on feature branches?

Exercise

Question 3: Remote State

Why is remote Terraform state important in a CI/CD pipeline?

PreviousPrev
Next

Continue Learning

Terraform AWS EC2 Web Server

Continue the AWS Terraform project by launching an EC2 web server with a security group, key pair, user data, and Elastic IP.

12 min read·Medium

Terraform AWS S3 Static Assets

Continue the AWS Terraform project by creating an S3 bucket for static assets with versioning, lifecycle rules, public-read policy, and object uploads.

10 min read·Medium

Terraform Provisioners

Learn what Terraform provisioners are, how local-exec, remote-exec, file, connection blocks, and null_resource work, and why provisioners should be a last resort.

10 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

Why Infrastructure as Code Belongs in CI/CD1. Consistency2. Audit trail3. No cowboy infrastructure4. Safer approvals5. Team scaleThe Standard Terraform Pipeline PatternThe Full GitLab CI/CD ExampleUnderstanding the Validate Stage`terraform fmt -check -recursive``terraform validate`Understanding the Plan StageWhy store the plan as an artifact?Manual Apply as a Safety GateWhy apply the saved plan file?Manual Destroy StageStoring State in GitLab-Managed Terraform StateMinimal Terraform backend blockWhy this pattern is usefulStoring AWS Credentials as GitLab CI/CD VariablesWhy masked and protected?Branch-Based WorkflowFeature branches: plan onlyDefault branch: allowed to applyEnvironment-Specific PipelinesWhy separate behavior by environment?Suggested Multi-Environment State NamingExample of a Safer Project LayoutWhy CI Pipelines Beat Manual `terraform apply`GitHub Actions AlternativeGitLab strengths in this patternGitHub Actions strengthsCommon Pipeline ImprovementsFinal RecommendationsPractice Questions