CI/CD with GitLab CI
New to compliance.tf CI/CD? See the CI/CD overview for prerequisites and authentication concepts.
Store the Token
- Go to your project on GitLab
- Navigate to Settings > CI/CD > Variables
- Click Add variable
- Key:
CTF_TOKEN - Value: paste your token from the Access Tokens page
- Check Protect variable and Mask variable
Protected variables and feature branches
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 or protect those branches.
For multi-environment setups, use GitLab Environments with separate variables per environment. See Multi-Environment Setup below.
Configure Your Pipeline
Add the token as a global variable in your .gitlab-ci.yml so every job that runs Terraform can authenticate with the compliance.tf registry:
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
stages:
- validate
- plan
- apply
terraform-plan:
stage: plan
image: hashicorp/terraform:1.12
script:
- terraform init
- terraform plan -out=tfplan
artifacts:
paths:
- tfplan
expire_in: 7 days
Do not echo the token
Never add echo or printenv statements that could expose the token in job logs. GitLab masks variables marked with Mask variable, but avoid unnecessary exposure.
Complete Pipeline Example
This is a production-grade pipeline for running compliance.tf modules in GitLab CI. It validates configuration, runs terraform plan on merge requests, posts the plan as a merge-request comment, scans with Checkov, and applies on merge to main.
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: hashicorp/terraform:1.12
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:
- 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
reports:
terraform: plan.json
# -------------------------------------------------------
# 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
What is new compared to a standard Terraform pipeline
If you already have a GitLab CI pipeline for Terraform, the only addition is the TF_TOKEN_soc2_compliance_tf variable. The Terraform commands are identical.
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
Everything else — init, validate, plan, apply — is the same pipeline you already run.
Stage Breakdown
Validate
The terraform-validate job catches syntax errors and invalid references before the more expensive plan step runs. It uses the shared .terraform-base configuration, which runs terraform init in before_script so that provider schemas are available for validation.
terraform-validate:
extends: .terraform-base
stage: validate
script:
- terraform validate -no-color
Plan
The terraform-plan job generates a plan file (tfplan) and exports it as JSON (plan.json). Both are saved as artifacts for downstream jobs — plan.json is consumed by the Checkov scan, tfplan is consumed by the apply job, and plan_output.txt is used by the MR comment job.
Terraform report artifact
Setting reports: terraform: plan.json lets GitLab display infrastructure changes directly in the merge-request widget. See GitLab Terraform integration for details.
Merge Request Comment
The plan-comment job posts the plan output as a note on the merge request using the GitLab API. This gives reviewers immediate visibility into infrastructure changes without opening the job log.
Requires a GITLAB_API_TOKEN variable
Create a project or group access token with the api scope and store it as a CI/CD variable named GITLAB_API_TOKEN. This is separate from the compliance.tf token.
Checkov Scan
The checkov-scan job runs Checkov against the plan JSON. Compliance.tf modules are designed to pass Checkov checks. If Checkov flags an issue on a compliance.tf module, contact support — it may indicate a gap in control coverage.
The job is set to allow_failure: true so it does not block deployment, but the JSON report is always saved as an artifact for audit evidence.
Apply
The terraform-apply job runs only on the main branch and requires manual approval (when: manual). It consumes the tfplan artifact produced by the plan job to ensure the exact reviewed plan is applied.
Multi-Environment Setup
Use GitLab environments to manage separate dev, staging, and production deployments. Each environment can have its own variables, approval rules, and deployment history.
Directory structure
environments/
dev/
main.tf
backend.tf
staging/
main.tf
backend.tf
prod/
main.tf
backend.tf
Pipeline configuration
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
TF_INPUT: "false"
stages:
- validate
- plan
- apply
.terraform-env:
image: hashicorp/terraform:1.12
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: manual
variables:
TF_TOKEN_soc2_compliance_tf: $CTF_TOKEN
TF_INPUT: "false"
stages:
- plan
- apply-dev
- apply-staging
- apply-prod
.terraform-env:
image: hashicorp/terraform:1.12
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
dependencies:
- plan-staging
environment:
name: staging
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
# Only after dev apply succeeds
needs:
- apply-dev
# --- 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
dependencies:
- plan-prod
environment:
name: prod
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
needs:
- apply-staging
Environment-scoped variables
GitLab supports environment-scoped CI/CD variables. If your environments use different compliance.tf framework endpoints, create separate TF_TOKEN_* variables scoped to each environment.
Artifact Archival for Audit Evidence
CI artifacts are the evidence auditors need. By saving plan output, plan JSON, and scan reports, every pipeline run becomes a compliance record.
Recommended artifacts
| Artifact | Purpose | Retention |
|---|---|---|
tfplan | Binary plan file for exact apply | 90 days |
plan.json | Human/machine-readable plan for audit review | 365 days |
plan_output.txt | Plain-text plan for MR comments | 90 days |
checkov-report.json | Independent scan verification | 365 days |
Retention settings
Set expire_in to match your audit retention requirements:
- SOC 2 — 12 months minimum
- PCI DSS — 12 months minimum
- HIPAA — 6 years minimum
artifacts:
paths:
- plan.json
- checkov-report.json
expire_in: 365 days
Long-term storage
For retention periods beyond GitLab's artifact limits, add a job that copies artifacts to an S3 bucket or other long-term storage. GitLab's artifact API can also be used to programmatically download artifacts for archival.
For a complete guide to building an audit evidence package, see the Audit Evidence Guide.