GitLab CI
For teams that run Terraform from GitLab CI. When you finish, every job that runs terraform init downloads compliance.tf modules with a masked CI/CD variable, and the rest of the pipeline runs as before. Authentication concepts shared by every platform are on CI/CD integration.
Before you start
- An organization-owned registry token for this pipeline, created on the organization's tokens screen: see Create a token. CI/CD integration explains why a personal or
terraform logintoken will not do. - Maintainer access to the GitLab project, to add CI/CD variables.
- For the plan comment in the complete example: a GitLab project or group access token with the
apiscope.
What differs on this platform
Based on GitLab's CI/CD variable documentation, protected variables are only available on protected branches. If your pipeline runs on merge-request branches, uncheck Protect variable in Step 1 or protect those branches; otherwise terraform init runs without the token there.
Step 1: Store the token
- Go to your project on GitLab
- Navigate to Settings > CI/CD > Variables
- Click Add variable
- Key:
CTF_TOKEN - Value: paste the organization token
- Check Protect variable and Mask variable
For several environments, use GitLab Environments with separate variables per environment; see Several environments below.
Step 2: Configure the pipeline
When a GitLab CI pipeline already runs Terraform, you add only this line. The line is a global variable. Every Terraform job receives it:
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKENThe variable name is the host in your module source, with dots replaced by underscores; init, validate, plan and apply stay the commands you already run. Sources in the HTTPS form (https://soc2.compliance.tf/...) read .netrc instead; CI/CD integration has the step.
Complete example
This pipeline validates the configuration, runs terraform plan on merge requests, posts the plan as a merge-request comment, scans with Checkov, and applies on merge to main. It needs two CI/CD variables: CTF_TOKEN from Step 1, and GITLAB_API_TOKEN, the access token with api scope that the plan-comment job uses to post notes. Without GITLAB_API_TOKEN, the plan-comment job fails.
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
TF_INPUT: "false"
TF_IN_AUTOMATION: "true"
stages:
- validate
- plan
- scan
- apply
# -------------------------------------------------------
# Shared configuration
# -------------------------------------------------------
.terraform-base:
image:
name: hashicorp/terraform:1.12
entrypoint: [""] # the image's ENTRYPOINT is `terraform`; blank it so the runner shell starts
before_script:
- terraform init
cache:
key: terraform-plugins
paths:
- .terraform/providers/
# -------------------------------------------------------
# Stage: validate
# -------------------------------------------------------
terraform-validate:
extends: .terraform-base
stage: validate
script:
- terraform validate -no-color
# -------------------------------------------------------
# Stage: plan
# -------------------------------------------------------
terraform-plan:
extends: .terraform-base
stage: plan
script:
# tee captures the plan output for the MR comment job
- terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
- terraform show -json tfplan > plan.json
artifacts:
paths:
- tfplan
- plan.json
- plan_output.txt
expire_in: 90 days # set to the retention you agree with your assessor; see CI/CD integration
# -------------------------------------------------------
# Comment the plan on the merge request
# -------------------------------------------------------
plan-comment:
stage: plan
image: alpine:3.20
needs:
- job: terraform-plan
artifacts: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
before_script:
- apk add --no-cache curl jq
script:
- |
# Truncate long plans to stay under the GitLab note size limit
PLAN=$(head -c 60000 plan_output.txt)
BODY=$(jq -n \
--arg plan "$PLAN" \
--arg sha "$CI_COMMIT_SHORT_SHA" \
'{body: ("#### Terraform Plan\n\n<details><summary>Plan output</summary>\n\n```\n" + $plan + "\n```\n\n</details>\n\n*Commit: `" + $sha + "`*")}')
curl --fail --silent \
--header "PRIVATE-TOKEN: ${GITLAB_API_TOKEN}" \
--header "Content-Type: application/json" \
--data "$BODY" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"
# -------------------------------------------------------
# Stage: scan — Checkov verification (optional)
# -------------------------------------------------------
checkov-scan:
stage: scan
image:
name: bridgecrew/checkov:latest
entrypoint: [""]
needs:
- job: terraform-plan
artifacts: true
script:
- >
checkov
--file plan.json
--framework terraform_plan
--output cli --output json
--output-file-path console,checkov-report.json
--quiet
allow_failure: true
artifacts:
paths:
- checkov-report.json
expire_in: 90 days
when: always
# -------------------------------------------------------
# Stage: apply — manual gate, main branch only
# -------------------------------------------------------
terraform-apply:
extends: .terraform-base
stage: apply
script:
- terraform apply -auto-approve tfplan
dependencies:
- terraform-plan
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual- Shared configuration runs
terraform initinbefore_script, so every job that extends it is authenticated and validate has provider schemas. - Plan saves
tfplanfor apply,plan.jsonfor Checkov andplan_output.txtfor the comment. - Comment posts the plan as a merge-request note through the GitLab API.
- Apply runs only on
main, after manual approval, from thetfplanartifact, so the reviewed plan is the one applied.
How long to keep the artifacts is covered once, on CI/CD integration; set expire_in to the retention you agree with your assessor.
Several environments
CI/CD integration covers why environments differ. In GitLab, give each environment its own directory under environments/ and run the same jobs per directory, with a matrix or with one job per environment:
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
TF_INPUT: "false"
stages:
- validate
- plan
- apply
.terraform-env:
image:
name: hashicorp/terraform:1.12
entrypoint: [""] # the image's ENTRYPOINT is `terraform`; blank it so the runner shell starts
before_script:
- cd environments/$ENVIRONMENT
- terraform init
terraform-plan:
extends: .terraform-env
stage: plan
script:
- terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
- terraform show -json tfplan > plan.json
artifacts:
paths:
- environments/$ENVIRONMENT/tfplan
- environments/$ENVIRONMENT/plan.json
- environments/$ENVIRONMENT/plan_output.txt
expire_in: 90 days
parallel:
matrix:
- ENVIRONMENT: [dev, staging, prod]
environment:
name: $ENVIRONMENT
action: prepare
terraform-apply:
extends: .terraform-env
stage: apply
script:
- terraform apply -auto-approve tfplan
dependencies:
- terraform-plan
parallel:
matrix:
- ENVIRONMENT: [dev, staging, prod]
environment:
name: $ENVIRONMENT
action: start
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manualvariables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
TF_INPUT: "false"
stages:
- plan
- apply-dev
- apply-staging
- apply-prod
.terraform-env:
image:
name: hashicorp/terraform:1.12
entrypoint: [""] # the image's ENTRYPOINT is `terraform`; blank it so the runner shell starts
before_script:
- cd environments/$ENVIRONMENT
- terraform init
# --- Dev ---
plan-dev:
extends: .terraform-env
stage: plan
variables:
ENVIRONMENT: dev
script:
- terraform plan -no-color -out=tfplan
- terraform show -json tfplan > plan.json
artifacts:
paths:
- environments/dev/tfplan
- environments/dev/plan.json
expire_in: 90 days
environment:
name: dev
action: prepare
apply-dev:
extends: .terraform-env
stage: apply-dev
variables:
ENVIRONMENT: dev
script:
- terraform apply -auto-approve tfplan
dependencies:
- plan-dev
environment:
name: dev
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
# --- Staging ---
plan-staging:
extends: .terraform-env
stage: plan
variables:
ENVIRONMENT: staging
script:
- terraform plan -no-color -out=tfplan
- terraform show -json tfplan > plan.json
artifacts:
paths:
- environments/staging/tfplan
- environments/staging/plan.json
expire_in: 90 days
environment:
name: staging
action: prepare
apply-staging:
extends: .terraform-env
stage: apply-staging
variables:
ENVIRONMENT: staging
script:
- terraform apply -auto-approve tfplan
environment:
name: staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
# Only after the dev apply succeeds; the plan file comes from plan-staging
needs:
- job: apply-dev
artifacts: false
- job: plan-staging
artifacts: true
# --- Production ---
plan-prod:
extends: .terraform-env
stage: plan
variables:
ENVIRONMENT: prod
script:
- terraform plan -no-color -out=tfplan
- terraform show -json tfplan > plan.json
artifacts:
paths:
- environments/prod/tfplan
- environments/prod/plan.json
expire_in: 90 days
environment:
name: prod
action: prepare
apply-prod:
extends: .terraform-env
stage: apply-prod
variables:
ENVIRONMENT: prod
script:
- terraform apply -auto-approve tfplan
environment:
name: prod
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
needs:
- job: apply-staging
artifacts: false
- job: plan-prod
artifacts: trueGitLab supports environment-scoped CI/CD variables. If your environments use different compliance.tf framework hosts, create a separate TF_TOKEN_* variable for each, scoped to its environment.
Optional checks
Checkov
The checkov-scan job in the complete example runs Checkov against the plan JSON as a second-opinion scan. It is set to allow_failure: true, so it does not block deployment, and its JSON report is always saved as an artifact. Delete the job and the scan stage if you do not want it. Checkov also reports checks that compliance.tf does not enforce. Compare a finding against the framework page's enforced and mapped-only lists before you treat it as a gap.
Check the result
Open a merge-request pipeline. In the terraform-plan job log, terraform init lists each compliance.tf module as downloaded from its host, for example Downloading soc2.compliance.tf/terraform-aws-modules/s3-bucket/aws 5.0.0 for s3_bucket..., and the plan note appears on the merge request.
Troubleshooting
- Init fails on a merge-request branch only. The variable is protected and the branch is not; see What differs on this platform.
- The
plan-commentjob fails.GITLAB_API_TOKENis missing or lacks theapiscope. 401 Unauthorized,403 Forbiddenor a registry error at init. See Troubleshooting on CI/CD integration.
Next steps
- Audit evidence - turn the plan and scan artifacts into evidence an auditor accepts
- Turn controls on and off - relax or add controls per module in the
sourceline - CI/CD integration - multi-environment setup, retention guidance and shared troubleshooting