
This guide explores almost all GitLab Terraform integration features, from pipelines to state management to private module registries and even managing GitLab itself using Terraform. We also cover best practices for maintainability and security.
What We'll Cover:
By the end of this post, you'll understand how to transform your infrastructure management into a streamlined, automated, and secure operation.
Want to learn more about Scalr pricing? Click here.
The absolute cornerstone of using GitLab with Terraform is version controlling your Terraform code. Storing your *.tf and *.tfvars files in a GitLab project provides numerous advantages:
A common way to structure your Terraform code within a GitLab project involves:
main.tf: Defines your primary set of Terraform resources.variables.tf: Declares input variables.outputs.tf: Defines outputs from your configuration.versions.tf: Specifies Terraform version and provider plugins versions.
providers.tf: Configures Terraform providers (e.g., AWS, Azure, GCP, GitLab).terraform.tfvars (often environment-specific, like dev.tfvars, prod.tfvars): Contains variable values. Ensure sensitive values are not committed here; use GitLab CI/CD variables instead..gitlab-ci.yml: Defines your GitLab CI/CD pipeline for Terraform automation.For multi-environment setups, you might use separate directories per environment or leverage Terraform workspaces (more on this in the CI/CD section).
More on structuring Terraform code here.
Terraform state files (terraform.tfstate) are critical. They store the mapping between your Terraform configuration and the real-world resources. Storing state locally on your local machine or in local storage is risky and unsuitable for team collaboration. This is where Terraform remote backends come into play, and GitLab offers a powerful, integrated solution: GitLab-Managed Terraform State.
GitLab can act as a remote backend for your Terraform state files using its built-in Terraform HTTP backend. This offers several advantages:
1. Terraform Backend Configuration:
In your Terraform project (e.g., in providers.tf or a dedicated backend.tf file), declare the HTTP backend:
terraform {
backend "http" {
// Configuration will be provided during init
}
}2. Initializing the Backend (Local Machine):
To initialize this backend from your local terminal for the first time or for local development against a GitLab-managed state:
# Ensure you have a GitLab Personal Access Token with 'api' scope
export GITLAB_ACCESS_TOKEN="your_personal_access_token"
export GITLAB_USERNAME="your_gitlab_username"
terraform init \
-backend-config="address=https://gitlab.com/api/v4/projects/<YOUR_PROJECT_ID>/terraform/state/<YOUR_STATE_NAME>" \
-backend-config="lock_address=https://gitlab.com/api/v4/projects/<YOUR_PROJECT_ID>/terraform/state/<YOUR_STATE_NAME>/lock" \
-backend-config="unlock_address=https://gitlab.com/api/v4/projects/<YOUR_PROJECT_ID>/terraform/state/<YOUR_STATE_NAME>/lock" \
-backend-config="username=${GITLAB_USERNAME}" \
-backend-config="password=${GITLAB_ACCESS_TOKEN}" \
-backend-config="lock_method=POST" \
-backend-config="unlock_method=DELETE" \
-backend-config="retry_wait_min=5"Replace <YOUR_PROJECT_ID> with your GitLab project's ID and <YOUR_STATE_NAME> with a descriptive name for your state (e.g., production, vpc). The address is the URL of the remote state backend.
3. Initializing the Backend (GitLab CI/CD):
Within a GitLab CI job, initialization is simpler as GitLab provides necessary environment variables. The gitlab-terraform helper scripts (though templates are being deprecated, the underlying mechanism is similar) or direct terraform init commands can leverage CI_JOB_TOKEN.
A common setup in .gitlab-ci.yml using direct Terraform commands:
variables:
TF_STATE_NAME: "default" # Or make this dynamic per environment
TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
TF_HTTP_ADDRESS: "${TF_ADDRESS}"
TF_HTTP_LOCK_ADDRESS: "${TF_ADDRESS}/lock"
TF_HTTP_UNLOCK_ADDRESS: "${TF_ADDRESS}/lock"
TF_HTTP_USERNAME: "gitlab-ci-token"
TF_HTTP_PASSWORD: "${CI_JOB_TOKEN}"
TF_HTTP_LOCK_METHOD: "POST"
TF_HTTP_UNLOCK_METHOD: "DELETE"
TF_HTTP_RETRY_WAIT_MIN: "5"
# ... inside a script section for init job ...
terraform initHere, CI_JOB_TOKEN is used for authentication, which is a secure, short-lived access token.
GitLab uses its standard project roles for controlling access to the Terraform state:
terraform plan -lock=false).terraform apply) and manage locks.For self-managed GitLab instances, GitLab administrators need to configure Terraform state storage. This can be a local filesystem path on the GitLab server or an object storage service like Amazon S3 or Google Cloud Storage. Using object storage is recommended for clustered GitLab deployments to avoid split-brain scenarios.
If you have an existing state file in a different state storage backend (e.g., local, S3 bucket) or want to change the state name or location, Terraform supports state migration.
backend "http" {} block if necessary, but primarily you'll provide the new configuration during the init command.terraform init -migrate-state. Terraform will prompt you for the configuration of the new backend (your GitLab HTTP backend details) and ask for confirmation to copy the old state to the new location. This is useful if you're starting with an empty state in GitLab but want to bring in an existing setup.# Example: Migrating from a local state to GitLab-managed state
# First, ensure your backend "http" {} block is in your .tf files.
# Then, run init with the new GitLab backend config and the -migrate-state flag:
terraform init -migrate-state \
-backend-config="address=https://gitlab.com/api/v4/projects/<PROJECT_ID>/terraform/state/<NEW_STATE_NAME>" \
# ... other backend-config parameters as shown before ...Terraform will detect the change and ask to copy the state.
Often, you need to share outputs from one Terraform configuration (e.g., network setup) with another (e.g., application deployment). The terraform_remote_state Terraform data source allows this.
# variables.tf
variable "network_state_address" {
description = "URL of the network Terraform state in GitLab"
type = string
}
variable "gitlab_api_user" {
description = "GitLab username for accessing remote state"
type = string
}
variable "gitlab_api_token" {
description = "GitLab token (PAT or CI_JOB_TOKEN) for accessing remote state"
type = string
sensitive = true
}
# data.tf
data "terraform_remote_state" "network" {
backend = "http"
config = {
address = var.network_state_address # e.g., "https://gitlab.com/api/v4/projects/YOUR_NET_PROJECT_ID/terraform/state/network"
username = var.gitlab_api_user # "gitlab-ci-token" if in CI, else your username
password = var.gitlab_api_token # ${CI_JOB_TOKEN} if in CI, else your PAT
lock_method = "POST"
unlock_method= "DELETE"
retry_wait_min = 5
}
}
# main.tf
resource "aws_instance" "app_server" {
# ... other configurations ...
subnet_id = data.terraform_remote_state.network.outputs.app_subnet_id
}You would pass the variable values via .tfvars files (kept out of VCS if containing PATs) or GitLab CI/CD variables.
GitLab CI/CD is where the real power of automating your Terraform operations (1) shines. A well-structured CD pipeline (1) can lint, validate, plan, and apply your infrastructure changes securely and reliably.
A typical GitLab pipeline (.gitlab-ci.yml) for Terraform includes several stages:
stages:
- validate
- build # Often called 'plan'
- deploy
- cleanup # Optional, for destroy operations
variables:
TF_ROOT: ${CI_PROJECT_DIR}/terraform # Directory where Terraform files are
TF_STATE_NAME: "default"
TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
# For OIDC (example for AWS)
# AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/GitLabCIRoleForTerraform"
# AWS_WEB_IDENTITY_TOKEN_FILE: "${CI_PROJECT_DIR}/web_identity_token" # For newer Terraform AWS provider versions
default:
image:
name: hashicorp/terraform:latest # Use a specific version in production
entrypoint: [""]
before_script:
- cd ${TF_ROOT}
# OIDC Token setup for AWS (example, adjust for your cloud)
# - echo "${CI_JOB_JWT_V2}" > ${AWS_WEB_IDENTITY_TOKEN_FILE}
# - export AWS_ROLE_SESSION_NAME="GitLabCI-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
# For GitLab HTTP Backend
- export GITLAB_TF_ADDRESS="-backend-config=address=${TF_ADDRESS}"
- export GITLAB_TF_LOCK_ADDRESS="-backend-config=lock_address=${TF_ADDRESS}/lock"
- export GITLAB_TF_UNLOCK_ADDRESS="-backend-config=unlock_address=${TF_ADDRESS}/lock"
- export GITLAB_TF_USERNAME="-backend-config=username=gitlab-ci-token"
- export GITLAB_TF_PASSWORD="-backend-config=password=${CI_JOB_TOKEN}"
- export GITLAB_TF_LOCK_METHOD="-backend-config=lock_method=POST"
- export GITLAB_TF_UNLOCK_METHOD="-backend-config=unlock_method=DELETE"
- export GITLAB_TF_RETRY="-backend-config=retry_wait_min=5"
- terraform --version
- terraform init -input=false ${GITLAB_TF_ADDRESS} ${GITLAB_TF_LOCK_ADDRESS} ${GITLAB_TF_UNLOCK_ADDRESS} ${GITLAB_TF_USERNAME} ${GITLAB_TF_PASSWORD} ${GITLAB_TF_LOCK_METHOD} ${GITLAB_TF_UNLOCK_METHOD} ${GITLAB_TF_RETRY}
validate:
stage: validate
script:
- terraform validate -json
plan:
stage: build # 'build' stage often used for plan generation
script:
- terraform plan -out=tfplan.cache -input=false
# Optionally, convert plan to JSON for further processing or MR comments
- terraform show -json tfplan.cache > tfplan.json
artifacts:
name: "plan_artifacts_${CI_COMMIT_REF_SLUG}"
paths:
- ${TF_ROOT}/tfplan.cache
- ${TF_ROOT}/tfplan.json
# By default, artifacts are public. Secure them!
public: false # Restricts access to project members (Reporter+)
# For more granular control (GitLab Premium+):
# access: developer # Or maintainer
expire_in: 1 week
apply:
stage: deploy
script:
- terraform apply -input=false tfplan.cache
dependencies:
- plan # Ensures 'plan' job artifacts are available
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Apply only on default branch
when: manual # Manual trigger for production changes is a good practice
- if: $CI_COMMIT_BRANCH =~ /^env\// # Example: auto-apply for 'env/*' branches
when: on_success
destroy: # Optional
stage: cleanup
script:
- terraform destroy -auto-approve
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DESTROY_INFRA == "true" # Require explicit variable
when: manualHardcoding static cloud provider access tokens or access keys in CI/CD variables is a security risk. OpenID Connect (OIDC) allows GitLab CI jobs to obtain short-lived, temporary credentials from cloud providers like AWS, GCP, and Azure, eliminating the need for long-lived sensitive data.
CI_JOB_JWT_V2 or other specific ID tokens like GITLAB_OIDC_TOKEN).project_A/branch_dev can only assume roles meant for that context.Example: OIDC for AWS in .gitlab-ci.yml and Terraform:
variables:
AWS_ROLE_ARN: "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourGitLabCIRole"
AWS_WEB_IDENTITY_TOKEN_FILE: "${CI_PROJECT_DIR}/aws_oidc_token.json" # Standard practice
AWS_ROLE_SESSION_NAME: "GitLabCI-${CI_JOB_ID}"
default:
id_tokens: # Request an OIDC token
GITLAB_OIDC_TOKEN:
aud: # Optional: specify audience if required by your IdP config in AWS
- sts.amazonaws.com
# - https://gitlab.com (if you configured GitLab instance as audience)
before_script:
- echo "${GITLAB_OIDC_TOKEN}" > ${AWS_WEB_IDENTITY_TOKEN_FILE}
# Terraform AWS provider will automatically pick up AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE.gitlab-ci.yml
No explicit provider configuration for credentials is needed if AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE are set as environment variables. The AWS provider SDK will handle the token exchange.
provider "aws" {
region = "us-east-1"
# No explicit access_key or secret_key needed!
}providers.tf
This significantly improves your security posture by avoiding static credentials for your AWS accounts or other cloud services.
The terraform plan -out=plan.cache command saves the execution plan. This plan file is a critical job artifact.
Security Risks: Plan files can contain sensitive data from your configuration or existing state. By default, in GitLab, anyone with Guest role access to a public/internal project can download artifacts.
Securing Plan Artifacts:
artifacts:public: false: In your .gitlab-ci.yml, set this on your plan artifact definition. This restricts download access to project members with at least Reporter access.artifacts:access: developer / maintainer: For GitLab Premium/Ultimate, you can use this for more granular control.gpg within your CI job before declaring it as an artifact, and decrypting it in the apply job. This adds complexity.Managing different environments is crucial. Two common approaches:
Terraform Workspaces:
terraform workspace select <name> before running plan or apply.variables:
TF_WORKSPACE: "${CI_COMMIT_REF_NAME}" # Use branch name as workspace
script:
- terraform workspace select ${TF_WORKSPACE} || terraform workspace new ${TF_WORKSPACE}
- terraform plan -out=tfplan.cacheDirectory-Based Structure:
environments/dev, environments/staging, environments/prod).main.tf, variables.tf, and backend configuration (though ideally, the backend config points to different state names/paths within the same GitLab HTTP backend).cd into the appropriate directory based on the branch or target environment.Caching: Terraform downloads provider plugins during terraform init. To speed up CI jobs:
TF_PLUGIN_CACHE_DIR environment variable in your .gitlab-ci.yml (e.g., TF_PLUGIN_CACHE_DIR: "${CI_PROJECT_DIR}/.terraform-plugin-cache")..terraform.lock.hcl file:cache:
key:
files:
- ${TF_ROOT}/.terraform.lock.hcl
paths:
- ${TF_ROOT}/.terraform-plugin-cache
policy: pull-push # Or pull for non-default branchesCustom Providers: For private or custom-built providers not in public registries, you can use Terraform's filesystem mirror capabilities by placing the provider binary in a known location (e.g., ~/.terraform.d/plugins or a project subdirectory) and configuring provider_installation in a CLI configuration file or using the TF_CLI_PLUGIN_PATH environment variable.
terraform plan automatically when a merge request is created or updated.gitlab-terraform helpers if still applicable, or custom scripts with jq and curl) to parse the JSON plan output and post a summary as a comment on the merge request. This allows reviewers to see the proposed changes directly in the MR.TF_ROOT, state locking conflicts (rare with GitLab backend if CI jobs are serialized correctly for applies), or syntax errors in Terraform code.Example: Provisioning a Virtual Machine with GitLab CI and OIDC (AWS EC2)
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "example_vm" {
ami = "ami-0c55b31ad2c675a0a" # Example: Amazon Linux 2 AMI (HVM)
instance_type = "t2.micro"
tags = {
Name = "GitLab-CI-VM-Example"
Environment = "Demo"
}
}
output "instance_id" {
value = aws_instance.example_vm.id
}
output "instance_public_ip" {
value = aws_instance.example_vm.public_ip
}terraform/main.tf
stages:
- validate
- plan
- deploy
variables:
TF_ROOT: ${CI_PROJECT_DIR}/terraform
TF_STATE_NAME: "vm-example"
TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"
AWS_ROLE_ARN: "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourGitLabCIRoleForVMs" # Set this in GitLab CI/CD variables
AWS_WEB_IDENTITY_TOKEN_FILE: "${CI_PROJECT_DIR}/aws_oidc_token.json"
AWS_DEFAULT_REGION: "us-east-1" # Or your desired region
AWS_ROLE_SESSION_NAME: "GitLabCI-VM-${CI_JOB_ID}"
default:
image: hashicorp/terraform:latest
entrypoint: [""]
before_script:
- cd ${TF_ROOT}
- echo "${CI_JOB_JWT_V2}" > ${AWS_WEB_IDENTITY_TOKEN_FILE} # Using CI_JOB_JWT_V2 for AWS
- export GITLAB_TF_ADDRESS="-backend-config=address=${TF_ADDRESS}"
- export GITLAB_TF_LOCK_ADDRESS="-backend-config=lock_address=${TF_ADDRESS}/lock"
# ... other GITLAB_TF_ backend configs as shown previously ...
- terraform init -input=false ${GITLAB_TF_ADDRESS} ${GITLAB_TF_LOCK_ADDRESS} # ... etc.
validate_vm:
stage: validate
script:
- terraform validate
plan_vm:
stage: plan
script:
- terraform plan -out=tfplan.cache
artifacts:
paths:
- ${TF_ROOT}/tfplan.cache
public: false
apply_vm:
stage: deploy
script:
- terraform apply -input=false tfplan.cache
dependencies:
- plan_vm
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual .gitlab-ci.yml
(Ensure your AWS IAM Role YourGitLabCIRoleForVMs has permissions to create EC2 instances and is configured to trust JWTs from your GitLab project/branch as per OIDC setup).
Terraform modules are essential for creating reusable, maintainable, and versioned components of your infrastructure. GitLab provides its own private Terraform Registry to host and share your custom modules within your organization.
Via GitLab CI/CD (Recommended):
Structure your module in a dedicated GitLab project.
Use a .gitlab-ci.yml file that triggers on Git tags (which should follow semantic versioning, e.g., v1.0.1).
The CI job will package your module (usually a .tgz archive) and use GitLab's API (with CI_JOB_TOKEN) to publish it to the registry.
GitLab provides CI/CD templates for this (e.g., Terraform-Module.gitlab-ci.yml), though with templates being deprecated, you might adapt this logic or use newer component-based approaches if available.
Via API Manually:
You can also use curl and the Packages API with a GitLab Personal Access Token, Project Access Token, or Deploy Token (with read_package_registry and write_package_registry scopes) to upload your packaged module.
Example: Referencing a Module from GitLab Registry in your Terraform code:
module "vpc" {
source = "gitlab.com/your-group/your-vpc-module/aws" # Or gitlab.your-instance.com
version = "1.0.1"
# Module inputs
vpc_cidr = "10.0.0.0/16"
# ...
}When terraform init runs, it needs to authenticate to your GitLab instance to download private modules.
Using ~/.terraformrc or terraform.rc: Create this file with credentials:
credentials "gitlab.com" { // Or your self-managed instance hostname
token = "<YOUR_GITLAB_PERSONAL_ACCESS_TOKEN_OR_DEPLOY_TOKEN>"
}Environment Variables (for CI/CD): Set TF_TOKEN_gitlab_com (replace gitlab_com with your instance, underscores for dots) with a suitable token. CI_JOB_TOKEN can often be used if the job has appropriate permissions.
Beyond managing external cloud infrastructure, you can use Terraform to manage GitLab itself using the official GitLab Terraform Provider (gitlabhq/gitlab). This is IaC for your DevSecOps platform!
provider "gitlab" {
# token = var.gitlab_admin_token # Can be set via env var GITLAB_TOKEN
}
resource "gitlab_project" "example_app" {
name = "My Example App"
description = "An example application project managed by Terraform."
visibility_level = "private"
default_branch = "main"
issues_enabled = true
merge_requests_enabled = true
wiki_enabled = false
snippets_enabled = false
container_registry_enabled = true # Requires GitLab 13.6+ for provider to set
}
resource "gitlab_project_variable" "app_env" {
project = gitlab_project.example_app.id
key = "APP_ENVIRONMENT"
value = "development"
protected = false
masked = false
environment_scope = "*" # All environments
}For an exhaustive list of resources and data sources, refer to the official GitLab Provider documentation on the Terraform Registry.
Security should be paramount throughout your IaC lifecycle.
db_key_base for self-managed instances.plan file) using artifacts:public: false or artifacts:access. Be mindful of project visibility settings and their impact on artifact and code visibility.tfsec, checkov, or GitLab's own SAST capabilities into your GitLab CI pipeline to catch misconfigurations and security vulnerabilities in your Terraform code before they reach production.Combining Terraform's powerful infrastructure provisioning capabilities with GitLab's comprehensive DevSecOps platform creates a robust, secure, and efficient workflow for managing the entire lifecycle of your infrastructure.
By leveraging GitLab for version control, adopting its secure HTTP backend for Terraform state files, building sophisticated GitLab CI/CD pipelines with OIDC, utilizing the private Terraform Module Registry, and even managing GitLab itself with the GitLab Terraform Provider, you can significantly enhance collaboration, automation, and security.
Embrace these best practices and GitLab Terraform integration features to ensure your team can confidently and rapidly evolve your cloud infrastructure, focusing on delivering value rather than wrestling with manual processes or security concerns.
