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.htmlfile will do) and a.gitlab-ci.ymlfile 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
- Navigate to GitLab > New Project > Create blank project.
- Name it
secure-pipeline-lab, set visibility to Private, and initialise with a README. - Under Settings > Repository > Protected branches, confirm that
mainis 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
- Go to Settings > CI/CD > Variables > Expand > Add variable.
- 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
- Push on
main— the deploy job runs andDEPLOY_TOKENis injected (log shows[MASKED]). - Create a branch
feature/test-vars, push a commit — the deploy job does not run (rules restrict it tomain). Even if you modify the rules to let it run,DEPLOY_TOKENandDB_PASSWORDare empty because the branch is not protected. 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
- Navigate to Operate > Environments > New environment.
- Create two environments:
stagingandproduction.
Step 2 — Protect the Production Environment
- Go to Settings > CI/CD > Protected environments (available on Premium/Ultimate, or on self-managed free tier).
- Select
production. - Under Allowed to deploy, restrict to
Maintainers(or a specific user). - Under Required approvals, set to 1 (or more, depending on your policy).
- 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
- A push to
maintriggers the pipeline. deploy-stagingruns automatically.deploy-productionshows a Play button in the pipeline UI.- Clicking Play does not immediately run the job — GitLab checks the environment protection rules and presents an approval dialog to the designated approver(s).
- 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
- Go to Settings > CI/CD > Token Access.
- Toggle Limit access to this project to Enabled.
- Under Allow CI job tokens from the following projects to access this project, add only the projects that genuinely need access (an allowlist model).
- 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
- Run the pipeline.
test-token-allowedsucceeds and clones the permitted project. test-token-deniedfails with 403 Forbidden becauserestricted-projectis 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_eventfrom a fork automatically run with limited permissions. - Protected variables are never injected into fork MR pipelines.
CI_JOB_TOKENin 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— preferrules: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:
- Delete or archive the test project: Go to Settings > General > Advanced > Delete project.
- 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). - 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_protectedaccess for deployment jobs. Reserve shared runners for non-sensitive build and test steps. - Gate production deployments with environment protection and approvals. Combining
when: manualwith 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:
- CI/CD Execution Models and Trust Assumptions — Understand the security implications of different CI/CD architectures and where trust boundaries lie.
- Separation of Duties and Least Privilege in CI/CD Pipelines — Learn how to design pipelines where no single role or token has more access than necessary.