Lab: Securing GitLab CI Pipelines — Protected Variables, Runners, and Environments

Overview

GitLab CI is the second most widely used CI/CD platform in the industry, powering millions of pipelines across organizations of every size. Its tight integration with source control makes it exceptionally convenient — but that same integration creates a broad attack surface if pipelines are not deliberately hardened.

In this hands-on lab you will walk through six exercises that progressively secure a GitLab CI pipeline. You will start with an intentionally insecure configuration where every variable is visible to every branch, shared runners handle all jobs, and there are no environment gates. By the end you will have a pipeline that enforces least-privilege variable access, scoped runners, protected environments with deployment approvals, restricted CI_JOB_TOKEN access, secure merge-request pipelines, and additional hardening controls including secret detection.

Every command, YAML snippet, and UI path in this lab is based on GitLab 16.x / 17.x and works on the free tier of GitLab.com.

Prerequisites

  • A GitLab account — the free tier on gitlab.com is sufficient for every exercise.
  • A test project containing a simple application (even a single index.html file will do) and a .gitlab-ci.yml file at the repository root.
  • Basic familiarity with GitLab CI syntax: stages, jobs, scripts, and rules.
  • (Optional) A Linux or macOS machine if you plan to register your own GitLab Runner in Exercise 2.

Environment Setup

Step 1 — Create a New GitLab Project

  1. Navigate to GitLab > New Project > Create blank project.
  2. Name it secure-pipeline-lab, set visibility to Private, and initialise with a README.
  3. Under Settings > Repository > Protected branches, confirm that main is listed as a protected branch (this is the default).

Step 2 — Add a Simple Application

Create index.html at the repository root:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Secure Pipeline Lab</title></head>
<body><h1>Hello, GitLab CI!</h1></body>
</html>

Step 3 — Create the Initial (Insecure) Pipeline

Add the following .gitlab-ci.yml. This is deliberately insecure — it is the starting point we will harden throughout the lab:

# .gitlab-ci.yml — INSECURE starting point
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "DB_PASSWORD is $DB_PASSWORD"   # Variable printed to logs!

test-job:
  stage: test
  script:
    - echo "Running tests..."
    - echo "API_KEY is $API_KEY"            # Variable printed to logs!

deploy-job:
  stage: deploy
  script:
    - echo "Deploying to production..."
    - echo "DEPLOY_TOKEN is $DEPLOY_TOKEN" # Variable printed to logs!

This pipeline has several problems:

  • All CI/CD variables are available to every branch, including branches created by external contributors.
  • Variables are echoed directly into job logs — anyone with log access can read them.
  • Jobs run on shared runners with no isolation guarantees.
  • There are no environment gates — the deploy job runs automatically on every push.

Commit this file to main and verify the pipeline runs. Now let us fix each of these issues.

Exercise 1: Protected and Masked Variables

GitLab CI/CD variables support three protection flags that dramatically reduce the blast radius of a compromised branch or fork.

Understanding the Three Flags

Flag Effect
Protected Variable is only injected into pipelines running on protected branches or tags. A pipeline triggered from a feature branch or a fork will never see the value.
Masked GitLab redacts the variable value from job logs. If a script accidentally echoes the value, the log shows [MASKED] instead.
Hidden (GitLab 17+) The variable value cannot be revealed in the UI after creation — not even by project maintainers. Useful for secrets managed by a platform team that developers should never see in plain text.

Step 1 — Create Variables

  1. Go to Settings > CI/CD > Variables > Expand > Add variable.
  2. Create the following variables:
Key Value (example) Protected Masked Hidden
DEPLOY_TOKEN glpat-xxxxxxxxxxxxxxxxxxxx Yes Yes No
DB_PASSWORD S3cur3P@ssw0rd!2024 Yes Yes Yes
API_KEY sk-test-abc123def456 No Yes No

Step 2 — Update the Pipeline

# .gitlab-ci.yml — Exercise 1
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "API_KEY value length = ${#API_KEY}"  # Safe: prints length, not value

test-job:
  stage: test
  script:
    - echo "Running tests..."
    # Attempting to print a masked variable:
    - echo "DB_PASSWORD is $DB_PASSWORD"
    # Output will show: DB_PASSWORD is [MASKED]

deploy-job:
  stage: deploy
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - echo "Token is $DEPLOY_TOKEN"
    # On main (protected): Token is [MASKED]
    # On feature branch: Token is <empty — variable not injected>
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Step 3 — Verify Protection Behaviour

  1. Push on main — the deploy job runs and DEPLOY_TOKEN is injected (log shows [MASKED]).
  2. Create a branch feature/test-vars, push a commit — the deploy job does not run (rules restrict it to main). Even if you modify the rules to let it run, DEPLOY_TOKEN and DB_PASSWORD are empty because the branch is not protected.
  3. API_KEY, which is masked but not protected, is available on both branches — its value is redacted in logs.

Key lesson: Always mark deployment credentials as both Protected and Masked. Use Hidden for secrets that developers should never retrieve from the UI.

Exercise 2: Runner Security and Scoping

Runners are the compute engines that execute your CI jobs. Choosing the right runner type — and scoping it correctly — is one of the most impactful security decisions you can make.

Runner Types

Type Scope Security Posture
Instance (shared) Available to every project on the GitLab instance Multi-tenant. Other projects’ jobs may run on the same machine. Risk of data leakage via shared file system, Docker socket, or cached layers.
Group Available to every project in a specific group Better isolation than instance runners, but still shared across projects within the group.
Project Available to a single project only Best isolation. You control the machine, the Docker configuration, and the network access.

Step 1 — Register a Project-Specific Runner

On a machine you control (a VM, a spare server, or even a local Docker host), install GitLab Runner and register it:

# Install GitLab Runner (Linux amd64)
sudo curl -L --output /usr/local/bin/gitlab-runner \
  https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
sudo chmod +x /usr/local/bin/gitlab-runner
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start

# Register the runner
# Find your registration token: Settings > CI/CD > Runners > Expand > New project runner
sudo gitlab-runner register \
  --non-interactive \
  --url https://gitlab.com/ \
  --token "$RUNNER_TOKEN" \
  --executor docker \
  --docker-image alpine:latest \
  --description "secure-deploy-runner" \
  --tag-list "secure-deploy" \
  --access-level ref_protected

The critical flag is --access-level ref_protected. This tells GitLab that the runner will only pick up jobs from protected branches or tags. A pipeline triggered by a feature branch or a fork merge request will never be scheduled on this runner.

Step 2 — Disable Shared Runners for Sensitive Jobs

Go to Settings > CI/CD > Runners and toggle Enable shared runners for this project to off — or leave them on for non-sensitive stages and use tags to steer sensitive jobs to your project runner.

Step 3 — Update the Pipeline with Tag-Based Runner Selection

# .gitlab-ci.yml — Exercise 2
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  # Runs on any available runner (shared is fine for builds)
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-job:
  stage: deploy
  tags:
    - secure-deploy            # Only runs on runner(s) with this tag
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - |
      curl --fail --silent --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \
        https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/releases
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Because the secure-deploy runner is registered with ref_protected access, this deploy job will only execute on the project-specific runner and only when the pipeline originates from a protected ref.

Exercise 3: Protected Environments and Deployment Approvals

Even with protected variables and scoped runners, you may want a human gate before code reaches production. GitLab’s protected environments provide exactly that.

Step 1 — Create Environments

  1. Navigate to Operate > Environments > New environment.
  2. Create two environments: staging and production.

Step 2 — Protect the Production Environment

  1. Go to Settings > CI/CD > Protected environments (available on Premium/Ultimate, or on self-managed free tier).
  2. Select production.
  3. Under Allowed to deploy, restrict to Maintainers (or a specific user).
  4. Under Required approvals, set to 1 (or more, depending on your policy).
  5. Add the designated approver(s).

Step 3 — Update the Pipeline with Environment Definitions

# .gitlab-ci.yml — Exercise 3
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual           # Requires a human click
  allow_failure: false        # Pipeline stays blocked until approved

How Approval Works

  1. A push to main triggers the pipeline.
  2. deploy-staging runs automatically.
  3. deploy-production shows a Play button in the pipeline UI.
  4. Clicking Play does not immediately run the job — GitLab checks the environment protection rules and presents an approval dialog to the designated approver(s).
  5. Only after the required number of approvals does the job start.

This two-layer gate — when: manual plus environment approval — ensures that no single person can push code directly to production without review.

Exercise 4: CI_JOB_TOKEN Scoping

Every GitLab CI job receives an automatic token in the CI_JOB_TOKEN variable. This token authenticates API and Git requests as the pipeline’s project. By default, its scope is dangerously broad.

The Risk

Without restrictions, a job in Project A can use CI_JOB_TOKEN to clone or call the API of any other project in the same group (or instance, depending on settings). If a malicious contributor injects a script into a CI job, they can exfiltrate code from unrelated repositories.

Step 1 — Restrict Token Scope

  1. Go to Settings > CI/CD > Token Access.
  2. Toggle Limit access to this project to Enabled.
  3. Under Allow CI job tokens from the following projects to access this project, add only the projects that genuinely need access (an allowlist model).
  4. Under Limit CI_JOB_TOKEN access to the following projects (outbound), add only the projects your pipeline needs to reach.

Step 2 — Test Access

# .gitlab-ci.yml — Exercise 4
stages:
  - test

test-token-allowed:
  stage: test
  script:
    - echo "Cloning an allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/allowed-project.git
    - echo "Success — access permitted"

test-token-denied:
  stage: test
  script:
    - echo "Cloning a non-allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/restricted-project.git
    # Expected output: remote: HTTP Basic: Access denied
    # fatal: Authentication failed — 403 Forbidden
  allow_failure: true

Step 3 — Verify

  1. Run the pipeline. test-token-allowed succeeds and clones the permitted project.
  2. test-token-denied fails with 403 Forbidden because restricted-project is not on the allowlist.

Key lesson: Always restrict CI_JOB_TOKEN to the smallest set of projects your pipeline actually needs. Treat the default “open” scope as a misconfiguration.

Exercise 5: Merge Request Pipeline Security

Merge request (MR) pipelines run when a contributor opens or updates a merge request. They are essential for code quality — but they can also be an attack vector if not configured carefully.

The Risk

When an external contributor forks your project and opens an MR, GitLab can run a pipeline on that MR. If the pipeline has access to protected variables or privileged runners, the contributor’s code could exfiltrate secrets.

Step 1 — Configure MR Pipeline Rules

# .gitlab-ci.yml — Exercise 5
stages:
  - validate
  - build
  - deploy

# --- Jobs that are safe to run on MR pipelines (no secrets needed) ---
lint:
  stage: validate
  script:
    - echo "Linting code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

unit-tests:
  stage: validate
  script:
    - echo "Running unit tests..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Jobs that require secrets — never run on MR pipelines ---
build-image:
  stage: build
  script:
    - echo "Building and pushing Docker image..."
    - echo "Using REGISTRY_TOKEN = $REGISTRY_TOKEN"  # Protected + Masked
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

How GitLab Handles Fork MR Pipelines

  • Pipelines triggered by merge_request_event from a fork automatically run with limited permissions.
  • Protected variables are never injected into fork MR pipelines.
  • CI_JOB_TOKEN in fork pipelines has a reduced scope — it can only access the source (fork) project, not the target.

By splitting your jobs into “safe for MR” (lint, test) and “requires secrets” (build, deploy), you ensure that contributors can validate their code without exposing credentials.

Best Practices for MR Pipelines

  • Never use only/except — prefer rules: for clarity and correctness.
  • Gate secret-dependent jobs with if: $CI_COMMIT_BRANCH == "main" (or another protected ref).
  • Consider enabling Pipelines must succeed under Settings > Merge requests to require the MR pipeline to pass before merging.
  • Enable Merged results pipelines to test the merge result rather than just the source branch — this catches integration issues earlier.

Exercise 6: Additional Hardening

With variables, runners, environments, tokens, and MR pipelines secured, several additional controls bring your pipeline to a production-grade security posture.

Job Timeouts

Unbounded jobs can be abused for crypto-mining or used to maintain persistent access. Set explicit timeouts:

deploy-production:
  stage: deploy
  timeout: 10 minutes
  script:
    - echo "Deploying..."

Interruptible Pipelines

Prevent resource waste and limit the window for malicious long-running jobs by marking non-critical jobs as interruptible:

lint:
  stage: validate
  interruptible: true     # Auto-cancelled if a newer pipeline starts
  script:
    - echo "Linting..."

Push Rules (Restrict Pipeline Creation)

Under Settings > Repository > Push rules, you can:

  • Reject unsigned commits — ensures every commit is GPG-signed.
  • Restrict branch names — enforce a naming convention (e.g., feature/*, bugfix/*).
  • Prevent pushing secrets — GitLab’s built-in push rule can block files matching common secret patterns.

Secret Detection with GitLab SAST

Add GitLab’s built-in Secret Detection template to catch accidentally committed credentials:

include:
  - template: Security/Secret-Detection.gitlab-ci.yml

This adds a secret_detection job that scans every commit for API keys, tokens, passwords, and other secret patterns. Results appear in the Security tab of merge requests.

The Final Hardened Pipeline

Below is the complete .gitlab-ci.yml combining every security control from this lab. Every security-relevant line is commented.

# .gitlab-ci.yml — Fully Hardened GitLab CI Pipeline

# Include GitLab's built-in secret detection scanner
include:
  - template: Security/Secret-Detection.gitlab-ci.yml  # Scans for leaked secrets

stages:
  - validate
  - build
  - deploy

# --- Default settings applied to all jobs ---
default:
  timeout: 10 minutes        # Prevent runaway/abused jobs

# --- Safe for merge request pipelines (no secrets required) ---
lint:
  stage: validate
  interruptible: true        # Cancel if a newer pipeline starts
  script:
    - echo "Linting source code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"  # Run on MRs
    - if: $CI_COMMIT_BRANCH == "main"                    # Run on main

unit-tests:
  stage: validate
  interruptible: true
  script:
    - echo "Running unit tests..."
    - echo "API_KEY length = ${#API_KEY}"  # Safe: prints length only
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Requires secrets — only runs on protected branch ---
build-image:
  stage: build
  script:
    - echo "Building Docker image..."
    - echo "Authenticating to registry..."  # Uses REGISTRY_TOKEN (Protected + Masked)
  rules:
    - if: $CI_COMMIT_BRANCH == "main"       # Only on protected branch

# --- Staging deployment — automatic on main ---
deploy-staging:
  stage: deploy
  environment:
    name: staging                            # Tracked environment
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# --- Production deployment — manual + approval required ---
deploy-production:
  stage: deploy
  tags:
    - secure-deploy                          # Runs on project-specific runner only
  environment:
    name: production                         # Protected environment with approvals
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
    - |
      curl --fail --silent \
        --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \   # Protected + Masked variable
        --request POST \
        "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/deployments"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual                           # Requires human trigger
  allow_failure: false                       # Pipeline blocks until approved
  timeout: 5 minutes                         # Tighter timeout for deploys

Cleanup

After completing the lab, clean up the test resources:

  1. Delete or archive the test project: Go to Settings > General > Advanced > Delete project.
  2. Remove CI/CD variables: If you plan to keep the project, go to Settings > CI/CD > Variables and delete the test variables (DEPLOY_TOKEN, DB_PASSWORD, API_KEY).
  3. Deregister the test runner:
# List registered runners
sudo gitlab-runner list

# Unregister the test runner
sudo gitlab-runner unregister --name "secure-deploy-runner"

# Optionally remove GitLab Runner entirely
sudo gitlab-runner stop
sudo gitlab-runner uninstall
sudo rm /usr/local/bin/gitlab-runner

Key Takeaways

  • Protect and mask every secret variable. Protected variables are only injected on protected branches, and masking prevents accidental log exposure. Use the Hidden flag for secrets that should never be readable in the UI.
  • Scope runners to the minimum required trust level. Use project-specific runners with ref_protected access for deployment jobs. Reserve shared runners for non-sensitive build and test steps.
  • Gate production deployments with environment protection and approvals. Combining when: manual with a protected environment and required approvers ensures no single person can push to production unchecked.
  • Restrict CI_JOB_TOKEN to an explicit allowlist. The default scope is too broad. Limit both inbound and outbound access to only the projects your pipeline actually needs.
  • Separate MR pipeline jobs from deployment jobs. Lint and test jobs are safe for merge request pipelines; build and deploy jobs that need secrets should only run on protected branches.
  • Layer additional controls: timeouts, interruptible jobs, push rules, and secret detection. Each layer addresses a different attack vector and together they create defense in depth.

Next Steps

Continue building your CI/CD security knowledge with these related guides: