GitLab CI can run Terraform plan and apply safely by using a CI/CD variable to store Terraform state and leveraging GitLab’s built-in environment and deployment tracking.
Let’s see this in action. Imagine you have a .gitlab-ci.yml file like this:
stages:
- plan
- apply
variables:
TF_ROOT: ${CI_PROJECT_DIR}/terraform
TF_STATE_NAME: ${CI_ENVIRONMENT_SLUG}
plan:
stage: plan
image: hashicorp/terraform:1.5.7
script:
- cd ${TF_ROOT}
- terraform init -backend-config="bucket=my-terraform-bucket" -backend-config="key=${TF_STATE_NAME}/terraform.tfstate" -backend-config="region=us-east-1"
- terraform validate
- terraform plan -out=tfplan
artifacts:
paths:
- ${TF_ROOT}/tfplan
expire_in: 1 day
apply:
stage: apply
image: hashicorp/terraform:1.5.7
script:
- cd ${TF_ROOT}
- terraform init -backend-config="bucket=my-terraform-bucket" -backend-config="key=${TF_STATE_NAME}/terraform.tfstate" -backend-config="region=us-east-1"
- terraform apply -input=false tfplan
dependencies:
- plan
environment:
name: ${CI_COMMIT_REF_SLUG}
url: https://your-app-url.com # Replace with your actual URL
when: manual
This pipeline defines two stages: plan and apply. The plan stage initializes Terraform, validates the configuration, and generates an execution plan saved as tfplan. The apply stage, triggered manually, takes that tfplan and applies the changes.
The magic happens with how Terraform state is managed and how GitLab tracks deployments. Instead of storing state locally or in a simple Git repository, we’re configuring Terraform to use a remote backend, like an AWS S3 bucket. This is specified in the terraform init command using -backend-config arguments. The key for the state file is dynamically generated using ${TF_STATE_NAME}/terraform.tfstate, where ${TF_STATE_NAME} is derived from ${CI_ENVIRONMENT_SLUG}. ${CI_ENVIRONMENT_SLUG} is a GitLab predefined variable that creates a URL-friendly version of the branch or tag name (e.g., main becomes main, feature/new-thing becomes feature-new-thing). This ensures that each branch or environment gets its own isolated Terraform state file within the S3 bucket.
Crucially, the apply job is marked when: manual. This means a human must explicitly click a button in the GitLab UI to proceed from the plan to the apply stage. This is the primary safety mechanism. After the plan job runs, the generated tfplan artifact is available. The apply job downloads this artifact and uses it with terraform apply -input=false tfplan. The -input=false flag prevents Terraform from prompting for confirmation, as the plan has already been reviewed.
The environment block in the apply job is also key. It associates the deployment with a specific GitLab environment (named after the commit ref slug, e.g., production for the main branch). This allows GitLab to track deployments, show deployment history, and even provide roll-back capabilities if configured. The url field can point to the deployed application, providing a direct link from the deployment information.
For authentication to cloud providers (like AWS for the S3 backend or for the resources Terraform will manage), you’d use CI/CD variables. For AWS, you’d typically set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as masked and protected CI/CD variables in your GitLab project settings. The Terraform Docker image will automatically pick these up.
The most surprising thing is how little explicit state locking logic is needed when using a properly configured remote backend and GitLab’s manual apply. While Terraform itself supports state locking (e.g., with DynamoDB for S3), GitLab’s manual approval for the apply stage effectively acts as a human-initiated lock. It prevents concurrent, unintended applies by ensuring only one person can trigger the apply after a successful plan.
To enable the S3 backend, you’ll need to create an S3 bucket (e.g., my-terraform-bucket) in your AWS account and ensure your GitLab CI runner has the necessary IAM permissions to s3:GetObject, s3:PutObject, and s3:ListBucket on that bucket.
The next problem you’ll likely encounter is managing different environments (staging, production) and ensuring secrets like database passwords or API keys are handled securely, likely through GitLab’s secret management capabilities or dedicated secret management tools.