Skip to content

CI/CD with GitLab CI

New to compliance.tf CI/CD? See the CI/CD overview for prerequisites and authentication concepts.

Store the Token

  1. Go to your project on GitLab
  2. Navigate to Settings > CI/CD > Variables
  3. Click Add variable
  4. Key: CTF_TOKEN
  5. Value: paste your token from the Access Tokens page
  6. 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:

.gitlab-ci.yml (excerpt)
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.

.gitlab-ci.yml
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.

The only line you add
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.

Validate stage
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

.gitlab-ci.yml
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
.gitlab-ci.yml
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.

ArtifactPurposeRetention
tfplanBinary plan file for exact apply90 days
plan.jsonHuman/machine-readable plan for audit review365 days
plan_output.txtPlain-text plan for MR comments90 days
checkov-report.jsonIndependent scan verification365 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
Example: extending retention for compliance
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.