Sign in
Menu
Sign up
Docs
Check before applyCI/CD integration

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 login token 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 api scope.

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

  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 the organization token
  6. 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_TOKEN

The 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 init in before_script, so every job that extends it is authenticated and validate has provider schemas.
  • Plan saves tfplan for apply, plan.json for Checkov and plan_output.txt for the comment.
  • Comment posts the plan as a merge-request note through the GitLab API.
  • Apply runs only on main, after manual approval, from the tfplan artifact, 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: manual
variables:
  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: true

GitLab 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

Next steps

On this page

Ask AI about this

Help improve this page