GitLab's built-in CI/CD is one of the most complete pipelines-as-code systems available — and it integrates cleanly with Terraform for infrastructure deployments. This post walks through bootstrapping a private GitLab group, registering runners, and setting up a full Terraform CI/CD pipeline from scratch.

GitLab group and project structure

Start with a group to organize related repositories under shared CI/CD variables and runners. In GitLab, groups are the right scope for enterprise settings — you define runners, variables, and access controls once at the group level rather than per-project.

# Recommended structure for an infrastructure group
infra-group/
  ├── terraform-modules/   # shared module library
  ├── networking/          # VPC, subnets, DNS
  ├── platform/            # ECS, RDS, core services
  └── apps/                # application-layer infra

Add group-level CI/CD variables (Settings → CI/CD → Variables) for shared secrets: AWS credentials, Terraform backend config, Vault tokens. These inherit to all projects in the group so you don't repeat them.

Registering a GitLab Runner

For infrastructure pipelines touching AWS, I prefer self-hosted runners over GitLab's shared runners. They run inside your VPC, can use instance profiles instead of long-lived credentials, and aren't subject to shared runner resource limits.

# Install on an EC2 instance (Amazon Linux 2)
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo yum install gitlab-runner -y

# Register the runner
sudo gitlab-runner register \
  --url "https://gitlab.com/" \
  --registration-token "YOUR_GROUP_TOKEN" \
  --executor "docker" \
  --docker-image "hashicorp/terraform:1.5" \
  --description "infra-runner" \
  --tag-list "infra,terraform" \
  --run-untagged false \
  --locked false

Tag the runner with infra and terraform. Only jobs that specify these tags in .gitlab-ci.yml will route to this runner — keeping infra jobs isolated from application build jobs.

The .gitlab-ci.yml pipeline

A Terraform pipeline has four natural stages: validate, plan, apply (with manual gate), and destroy (restricted to authorized users).

stages:
  - validate
  - plan
  - apply

variables:
  TF_ROOT: ${CI_PROJECT_DIR}/terraform
  TF_STATE_NAME: ${CI_PROJECT_NAME}

default:
  image: hashicorp/terraform:1.5
  tags:
    - infra
    - terraform
  before_script:
    - cd ${TF_ROOT}
    - terraform init
      -backend-config="bucket=${TF_STATE_BUCKET}"
      -backend-config="key=${TF_STATE_NAME}/terraform.tfstate"
      -backend-config="region=us-east-1"
      -input=false

validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check -recursive

plan:
  stage: plan
  script:
    - terraform plan -out=tfplan -input=false
    - terraform show -no-color tfplan | tee plan.txt
  artifacts:
    name: tfplan
    paths:
      - ${TF_ROOT}/tfplan
      - ${TF_ROOT}/plan.txt
    expire_in: 1 week

apply:
  stage: apply
  script:
    - terraform apply -input=false tfplan
  dependencies:
    - plan
  when: manual
  only:
    - main

Environment-specific pipelines with rules

For multi-environment setups, use GitLab's rules to control which jobs run on which branches:

plan:dev:
  extends: .plan_template
  variables:
    TF_WORKSPACE: dev
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

plan:prod:
  extends: .plan_template
  variables:
    TF_WORKSPACE: prod
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Storing plan output as an artifact

Always store the plan file as a GitLab artifact. This does two things: the apply stage can use the exact saved plan (no drift between plan and apply), and reviewers can download plan.txt from the pipeline UI before approving the manual apply job.

A manual apply gate is non-negotiable for production environments. The reviewer should read the plan output, not just click approve because the pipeline is green.

Handling AWS credentials securely

Prefer IAM roles over long-lived access keys wherever possible. For self-hosted runners on EC2, attach an instance profile with the required Terraform permissions — no credentials needed in CI/CD variables at all. For GitLab.com shared runners, use OIDC federation with AWS so the runner gets short-lived tokens per job instead of static keys.

Wrapping up

GitLab + Terraform is a solid combination for infrastructure CI/CD. The key pieces: a tagged self-hosted runner inside your network, group-level variable management, a pipeline with an explicit plan artifact and manual apply gate, and proper credential handling. Get those right and you have a production-grade IaC pipeline with a clean audit trail.