Back to Software Engineering & Delivery Mastery Series CI/CD Platform Deep Dive

CI/CD Platform Deep Dive: GitLab CI/CD

May 14, 2026 Wasil Zafar 48 min read

Master GitLab CI/CD from pipeline fundamentals to advanced patterns — .gitlab-ci.yml syntax, DAG pipelines, includes and templates, Auto DevOps, container registry integration, security scanning, merge trains, and organizational CI/CD at scale.

Table of Contents

  1. Introduction
  2. Pipeline Fundamentals
  3. Job Configuration
  4. Pipeline Types
  5. Includes & Templates
  6. GitLab Runners
  7. Auto DevOps
  8. Container Registry
  9. Environments & Deployments
  10. Security Scanning
  11. Advanced Features
  12. Merge Request Pipelines
  13. Performance & Optimization
  14. Exercises

Introduction

GitLab CI/CD takes a fundamentally different approach than GitHub Actions or Jenkins. Where those tools bolt CI/CD onto a code hosting platform (or vice versa), GitLab was designed from day one as a single application for the entire DevOps lifecycle — from planning and source control through CI/CD, security scanning, monitoring, and incident management.

This "single platform" philosophy means CI/CD in GitLab isn't a separate service you configure — it's a native capability that activates the moment you add a .gitlab-ci.yml file to your repository root. No marketplace to browse, no third-party integrations to wire up, no separate authentication to manage.

Key Insight: GitLab's competitive advantage is depth of integration. Every feature — container registry, package registry, security scanning, environments, feature flags — is a first-party citizen. This eliminates the "glue code" problem that plagues multi-tool CI/CD setups where you spend more time integrating tools than building software.

GitLab vs GitHub Approach

The philosophical difference matters for pipeline design:

  • GitHub: Composable ecosystem — pick best-of-breed actions from the marketplace, wire them together. Maximum flexibility, more integration work.
  • GitLab: Integrated platform — everything built-in with consistent UX. Less flexibility, but zero integration overhead. Security scanning, container registry, environments, and monitoring all share the same configuration syntax.

Neither approach is "better" — they optimise for different constraints. GitLab excels in regulated environments where auditability and a single source of truth matter. GitHub excels in open-source and organisations that want maximum tool choice.

Pipeline Fundamentals

A GitLab pipeline is defined by a single file: .gitlab-ci.yml at the repository root. This file declares stages (ordered execution phases) and jobs (units of work assigned to stages).

GitLab Pipeline Architecture
flowchart LR
    subgraph Build Stage
        A[compile]
        B[lint]
    end
    subgraph Test Stage
        C[unit-tests]
        D[integration-tests]
        E[security-scan]
    end
    subgraph Deploy Stage
        F[staging]
        G[production]
    end
    
    A --> C
    A --> D
    B --> C
    C --> F
    D --> F
    E --> F
    F -->|Manual| G
    
    style A fill:#3B9797,color:#fff
    style B fill:#3B9797,color:#fff
    style C fill:#16476A,color:#fff
    style D fill:#16476A,color:#fff
    style E fill:#16476A,color:#fff
    style F fill:#132440,color:#fff
    style G fill:#BF092F,color:#fff
                            

Here's a complete, production-ready .gitlab-ci.yml:

# .gitlab-ci.yml
stages:
  - build
  - test
  - security
  - deploy

variables:
  NODE_VERSION: "20"
  DOCKER_DRIVER: overlay2
  GIT_DEPTH: 20

default:
  image: node:${NODE_VERSION}-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull
  before_script:
    - npm ci --prefer-offline

# Build Stage
compile:
  stage: build
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push  # This job populates the cache
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

lint:
  stage: build
  script:
    - npm run lint
  allow_failure: true

# Test Stage
unit-tests:
  stage: test
  script:
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      junit: reports/junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

integration-tests:
  stage: test
  services:
    - postgres:16-alpine
    - redis:7-alpine
  variables:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_pass
    DATABASE_URL: "postgres://test_user:test_pass@postgres:5432/test_db"
    REDIS_URL: "redis://redis:6379"
  script:
    - npm run test:integration

# Security Stage
sast:
  stage: security
  include:
    - template: Security/SAST.gitlab-ci.yml

dependency-scan:
  stage: security
  include:
    - template: Security/Dependency-Scanning.gitlab-ci.yml

# Deploy Stage
deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - ./deploy.sh staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  environment:
    name: production
    url: https://app.example.com
  script:
    - ./deploy.sh production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  needs:
    - deploy-staging

Pipeline Visualization

GitLab renders pipeline graphs in the UI showing job status, stage grouping, and dependency relationships. Jobs within a stage run in parallel by default; stages execute sequentially. The needs: keyword overrides this with explicit DAG (Directed Acyclic Graph) dependencies.

Job Configuration

Jobs are the fundamental building blocks of GitLab CI/CD. Each job runs in isolation (its own container) and can be configured with scripts, images, services, variables, artifacts, and rules.

# Complete job configuration reference
my-job:
  stage: test
  image: python:3.12-slim
  
  # Services: linked Docker containers (databases, caches, etc.)
  services:
    - name: postgres:16
      alias: db
      variables:
        POSTGRES_DB: myapp
        POSTGRES_PASSWORD: secret

  # Variables: environment-specific configuration
  variables:
    PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
    DATABASE_URL: "postgres://postgres:secret@db:5432/myapp"
  
  # Scripts: execution phases
  before_script:
    - pip install --cache-dir $PIP_CACHE_DIR -r requirements.txt
  script:
    - pytest tests/ --junitxml=report.xml --cov=app --cov-report=xml
  after_script:
    - echo "Cleanup tasks run even if script fails"

  # Cache: persisted between pipeline runs
  cache:
    key: pip-$CI_COMMIT_REF_SLUG
    paths:
      - .pip-cache/
  
  # Artifacts: passed to downstream jobs and available for download
  artifacts:
    paths:
      - coverage/
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
    expire_in: 7 days
    when: always  # Upload artifacts even on failure

  # Rules: control when job runs
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_TAG

  # Resource controls
  timeout: 30 minutes
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
  interruptible: true
  
  # Tags: select specific runners
  tags:
    - docker
    - linux

Artifacts vs Cache

Artifacts vs Cache Comparison

Artifacts pass data between jobs in the same pipeline. They're uploaded to GitLab after a job completes and downloaded by dependent jobs. Use for build outputs, test reports, compiled binaries.

Cache persists data between pipeline runs on the same runner. It's a performance optimization — never rely on cache for correctness (it can be evicted). Use for dependencies (node_modules, pip packages, Maven .m2).

Rule of thumb: If the pipeline would break without the data, use artifacts. If the pipeline would be slow without it, use cache.

Pipeline Types

GitLab supports four pipeline architectures, each suited to different complexity levels:

Basic Pipeline (Sequential Stages)

# Jobs within a stage run in parallel
# Stages run sequentially
stages:
  - build
  - test
  - deploy

build-app:
  stage: build
  script: make build

test-unit:
  stage: test
  script: make test-unit

test-e2e:
  stage: test
  script: make test-e2e

deploy:
  stage: deploy
  script: make deploy

DAG Pipeline (needs keyword)

DAG Pipeline — Jobs Start as Soon as Dependencies Complete
flowchart LR
    A[build-frontend] --> C[test-frontend]
    B[build-backend] --> D[test-backend]
    B --> E[test-integration]
    C --> F[deploy]
    D --> F
    E --> F
    
    style A fill:#3B9797,color:#fff
    style B fill:#3B9797,color:#fff
    style C fill:#16476A,color:#fff
    style D fill:#16476A,color:#fff
    style E fill:#16476A,color:#fff
    style F fill:#132440,color:#fff
                            
# DAG: jobs start immediately when their dependencies finish
# No waiting for entire stage to complete
stages:
  - build
  - test
  - deploy

build-frontend:
  stage: build
  script: cd frontend && npm run build
  artifacts:
    paths: [frontend/dist/]

build-backend:
  stage: build
  script: cd backend && go build -o bin/server
  artifacts:
    paths: [backend/bin/]

test-frontend:
  stage: test
  needs: [build-frontend]  # Starts immediately after build-frontend
  script: cd frontend && npm test

test-backend:
  stage: test
  needs: [build-backend]  # Doesn't wait for build-frontend
  script: cd backend && go test ./...

test-integration:
  stage: test
  needs: [build-backend]
  script: ./run-integration-tests.sh

deploy:
  stage: deploy
  needs: [test-frontend, test-backend, test-integration]
  script: ./deploy.sh

Parent-Child Pipelines

# Parent pipeline triggers child pipelines dynamically
# Useful for monorepos where each service has its own CI config

# .gitlab-ci.yml (parent)
stages:
  - trigger

trigger-frontend:
  stage: trigger
  trigger:
    include: frontend/.gitlab-ci.yml
    strategy: depend  # Parent waits for child to complete
  rules:
    - changes:
        - frontend/**/*

trigger-backend:
  stage: trigger
  trigger:
    include: backend/.gitlab-ci.yml
    strategy: depend
  rules:
    - changes:
        - backend/**/*

# frontend/.gitlab-ci.yml (child)
stages:
  - build
  - test

build:
  stage: build
  image: node:20
  script:
    - cd frontend
    - npm ci
    - npm run build

test:
  stage: test
  image: node:20
  script:
    - cd frontend
    - npm ci
    - npm test

Includes & Templates

GitLab's include: keyword lets you compose pipelines from reusable components. This is how organisations standardise CI/CD across hundreds of projects.

# Include types
include:
  # Local: file in the same repository
  - local: '/ci/templates/docker-build.yml'
  
  # Remote: file from any URL
  - remote: 'https://example.com/ci/shared-pipeline.yml'
  
  # Template: GitLab's built-in templates
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  
  # Component: CI/CD catalog component (GitLab 16.0+)
  - component: gitlab.com/my-org/ci-components/docker-build@1.0.0
    inputs:
      image_name: my-app
      registry: registry.example.com
  
  # Project: file from another GitLab project
  - project: 'my-org/ci-templates'
    ref: main
    file:
      - '/templates/node-pipeline.yml'
      - '/templates/deploy.yml'

Creating a reusable template with extends: and hidden jobs:

# ci/templates/node-pipeline.yml (shared template)

# Hidden jobs (prefixed with .) serve as templates
.node-base:
  image: node:${NODE_VERSION:-20}-alpine
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
  before_script:
    - npm ci --prefer-offline

.deploy-base:
  image: alpine:3.19
  before_script:
    - apk add --no-cache curl kubectl
    - kubectl config set-cluster k8s --server=$KUBE_SERVER
    - kubectl config set-credentials deployer --token=$KUBE_TOKEN
    - kubectl config set-context deploy --cluster=k8s --user=deployer
    - kubectl config use-context deploy

# Consumers extend these templates:
# my-project/.gitlab-ci.yml
include:
  - project: 'my-org/ci-templates'
    file: '/ci/templates/node-pipeline.yml'

test:
  extends: .node-base
  stage: test
  script:
    - npm test

deploy:
  extends: .deploy-base
  stage: deploy
  script:
    - kubectl apply -f k8s/

GitLab Runners

GitLab Runners are agents that execute CI/CD jobs. They connect to your GitLab instance and poll for available work. Runners can be registered at instance, group, or project level.

Runner Executor Types

Docker executor: Most common. Each job runs in a fresh container. Clean isolation, reproducible builds. Requires Docker on the runner host.

Kubernetes executor: Spawns pods for each job. Auto-scales with cluster capacity. Ideal for cloud-native teams running on EKS/GKE/AKS.

Shell executor: Runs jobs directly on the runner's OS. No isolation between jobs. Only use for specialised hardware (GPUs, embedded devices).

Docker Machine executor (legacy): Auto-provisions VMs in the cloud. Being replaced by the Kubernetes executor and GitLab's fleeting model.

# Runner registration and configuration (config.toml)
concurrent = 10
check_interval = 3

[[runners]]
  name = "docker-runner-01"
  url = "https://gitlab.example.com"
  token = "REGISTRATION_TOKEN"
  executor = "docker"
  
  [runners.docker]
    image = "alpine:3.19"
    privileged = false
    disable_entrypoint_overwrite = false
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
    pull_policy = ["if-not-present"]
    
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "s3.amazonaws.com"
      BucketName = "gitlab-runner-cache"
      BucketLocation = "us-east-1"

Auto DevOps

Auto DevOps is GitLab's zero-configuration CI/CD. When enabled, it automatically detects your project type, builds a Docker image, runs tests, performs security scans, and deploys to Kubernetes — all without writing a single line of CI/CD configuration.

Auto DevOps stages:

  • Auto Build — Detects language (Node, Python, Java, Go, etc.) via buildpack and creates a Docker image
  • Auto Test — Runs language-specific tests (npm test, pytest, go test)
  • Auto Code Quality — Analyses code with CodeClimate
  • Auto SAST/DAST — Static and dynamic application security testing
  • Auto Dependency Scanning — Checks dependencies for known vulnerabilities
  • Auto Container Scanning — Scans Docker images for OS-level vulnerabilities
  • Auto Review Apps — Deploys each branch to a temporary environment
  • Auto Deploy — Deploys to staging/production Kubernetes clusters
  • Auto Monitoring — Configures Prometheus metrics collection
# Enable Auto DevOps with customizations
include:
  - template: Auto-DevOps.gitlab-ci.yml

variables:
  # Override Auto DevOps defaults
  AUTO_DEVOPS_DEPLOY_DEBUG: "true"
  POSTGRES_ENABLED: "true"
  STAGING_ENABLED: "true"
  INCREMENTAL_ROLLOUT_ENABLED: "true"
  
  # Kubernetes namespace
  KUBE_NAMESPACE: my-app-production
  
  # Custom Dockerfile (skip buildpack auto-detection)
  AUTO_DEVOPS_BUILD_IMAGE_EXTRA_ARGS: "--build-arg NODE_ENV=production"

# Override specific Auto DevOps jobs
test:
  variables:
    CI_DEBUG_TRACE: "true"
  script:
    - npm ci
    - npm run test:ci
    - npm run test:e2e
When to Use Auto DevOps: Ideal for new projects, hackathons, internal tools, and teams without dedicated DevOps engineers. For production services with complex requirements, most teams eventually outgrow Auto DevOps and switch to custom .gitlab-ci.yml files — but Auto DevOps gives you a solid starting point to customise from.

Container Registry Integration

GitLab includes a built-in container registry — no external Docker Hub or ECR needed. Every project gets a registry at registry.gitlab.com/<namespace>/<project>, authenticated with your GitLab credentials.

# Build and push Docker images to GitLab Container Registry
build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build --pull -t $IMAGE_TAG .
    - docker push $IMAGE_TAG
    # Tag as latest for main branch
    - |
      if [ "$CI_COMMIT_BRANCH" == "main" ]; then
        docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest
        docker push $CI_REGISTRY_IMAGE:latest
      fi

# Multi-stage build with BuildKit and caching
build-optimized:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_BUILDKIT: "1"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - |
      docker build \
        --cache-from $CI_REGISTRY_IMAGE:cache \
        --build-arg BUILDKIT_INLINE_CACHE=1 \
        -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA \
        -t $CI_REGISTRY_IMAGE:cache \
        .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    - docker push $CI_REGISTRY_IMAGE:cache

Environments & Deployments

GitLab environments track where your code is deployed. They provide deployment history, rollback capabilities, and integration with monitoring. Environments can be static (production, staging) or dynamic (per-branch review apps).

# Static environments with protection
deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
    on_stop: stop-staging
  script:
    - kubectl apply -f k8s/ --namespace=staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  environment:
    name: production
    url: https://app.example.com
  script:
    - kubectl apply -f k8s/ --namespace=production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  resource_group: production  # Only one deploy at a time

# Dynamic environments (Review Apps)
deploy-review:
  stage: deploy
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.example.com
    on_stop: stop-review
    auto_stop_in: 1 week
  script:
    - helm upgrade --install review-$CI_COMMIT_REF_SLUG ./chart
      --set image.tag=$CI_COMMIT_SHORT_SHA
      --set ingress.host=$CI_COMMIT_REF_SLUG.review.example.com
  rules:
    - if: $CI_MERGE_REQUEST_IID

stop-review:
  stage: deploy
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  script:
    - helm uninstall review-$CI_COMMIT_REF_SLUG
  rules:
    - if: $CI_MERGE_REQUEST_IID
      when: manual

Security Scanning

GitLab provides integrated security scanning as part of CI/CD — results appear directly in merge requests, and vulnerabilities are tracked in the Security Dashboard.

# Enable all security scanners via includes
include:
  - template: Security/SAST.gitlab-ci.yml
  - template: Security/Secret-Detection.gitlab-ci.yml
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  - template: Security/Container-Scanning.gitlab-ci.yml
  - template: Security/DAST.gitlab-ci.yml
  - template: Security/License-Scanning.gitlab-ci.yml

# Customize SAST settings
variables:
  SAST_EXCLUDED_PATHS: "spec,test,vendor"
  SAST_BANDIT_EXCLUDED_PATHS: "*/test/*"
  
# Container scanning targets your built image
container_scanning:
  variables:
    CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

# DAST targets your deployed review app
dast:
  variables:
    DAST_WEBSITE: https://$CI_COMMIT_REF_SLUG.review.example.com
  needs: [deploy-review]

Security scan results integrate with merge requests — reviewers see new vulnerabilities introduced by the change before merging. Critical vulnerabilities can block the merge via approval rules.

Advanced Features

Rules vs Only/Except

# Modern: rules (recommended — more powerful and explicit)
deploy:
  rules:
    - if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"
      when: on_success
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
      when: on_success
    - when: never  # Explicit default

# Legacy: only/except (deprecated — avoid in new projects)
deploy:
  only:
    - main
    - tags
  except:
    - schedules

Multi-Project Pipelines

# Trigger a pipeline in another project
trigger-deploy-repo:
  stage: deploy
  trigger:
    project: my-org/infrastructure/deploy
    branch: main
    strategy: depend
  variables:
    UPSTREAM_PROJECT: $CI_PROJECT_PATH
    UPSTREAM_SHA: $CI_COMMIT_SHA
    UPSTREAM_REF: $CI_COMMIT_REF_NAME

Resource Groups (Deployment Mutex)

# Ensure only one deployment runs at a time per environment
deploy-production:
  resource_group: production
  script: ./deploy.sh
  # If two pipelines trigger simultaneously, the second
  # waits for the first to complete before starting

Merge Request Pipelines

GitLab offers three levels of merge request pipeline sophistication:

  • Detached pipelines — Run on the source branch HEAD. Fast but may miss merge conflicts.
  • Merge result pipelines — Run on a temporary merge of source into target. Catches integration issues before merging.
  • Merge trains — Queue multiple MRs and test them merged sequentially. Ensures main branch never breaks even under high merge velocity.
# Configure merge request pipelines
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_TAG

# Jobs only run in MR context
test:
  script: npm test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
Merge Trains: When enabled, GitLab creates a "train" of queued merge requests. Each MR is tested with all preceding MRs merged. If MR #3 fails, it's removed from the train and MR #4 is retested without it. This guarantees that the target branch always passes CI, even with 50+ merges per day.

Performance & Optimization

Pipeline Optimization Strategies

1. Use DAG pipelines — Replace sequential stages with needs: to start jobs as soon as dependencies finish, not when entire stages complete.

2. Cache aggressively — Use cache:key:files to create lockfile-based cache keys. Set policy: pull on most jobs and pull-push only on the job that installs dependencies.

3. Interruptible jobs — Mark jobs as interruptible: true to auto-cancel them when a new pipeline starts for the same branch.

4. Rules-based filtering — Use rules:changes to skip jobs when their relevant files haven't changed.

5. Parent-child pipelines — Split monorepo pipelines into independent child pipelines that run in parallel.

# Optimized pipeline with interruptible jobs and change-based rules
workflow:
  auto_cancel:
    on_new_commit: interruptible

test-frontend:
  interruptible: true
  rules:
    - changes:
        - frontend/**/*
        - package.json
  script: cd frontend && npm test

test-backend:
  interruptible: true
  rules:
    - changes:
        - backend/**/*
        - go.mod
  script: cd backend && go test ./...

Exercises

Exercise 1: Complete CI/CD Pipeline

Create a .gitlab-ci.yml for a Node.js application with: build → test (unit + integration with PostgreSQL service) → security scan (SAST + dependency scanning) → deploy to staging (automatic) → deploy to production (manual with environment protection). Use caching for node_modules and artifacts for build output.

Exercise 2: Monorepo with Parent-Child Pipelines

Design a parent pipeline that detects changes in frontend/, backend/, and infrastructure/ directories. Each changed directory should trigger a child pipeline with its own build, test, and deploy stages. Use strategy: depend so the parent reports child pipeline status.

Exercise 3: Organizational CI/CD Templates

Create a shared CI/CD template project with reusable hidden jobs for: Docker image building, Kubernetes deployment, and Slack notifications. Configure a consumer project to include these templates and extend them with project-specific configuration.

Exercise 4: Dynamic Review Environments

Implement dynamic review environments that create a unique deployment for every merge request. Include auto-stop after 1 week, a manual stop job, and a Helm-based deployment. The environment URL should be $CI_COMMIT_REF_SLUG.review.example.com.