15. Administration, Automation and Migration
The earlier chapters looked at GitLab CI/CD from inside one project. This chapter takes the view of the people who run it for many projects at once: administrators who set instance-wide defaults and limits, platform teams who give hundreds of teams shared building blocks and enforce standards, and engineers who automate GitLab through its APIs, its CLI and Terraform. It ends with moving existing pipelines from Jenkins, GitHub Actions, CircleCI and Azure DevOps to GitLab without stopping delivery along the way.
Tiers at a glance. Instance settings, plan limits, object storage and instance-level variables are available on all tiers, but only to administrators of a self-managed instance. GitLab Dedicated exposes some of them through its own console, and GitLab.com exposes none. CI/CD components, the CI/CD Catalog, the REST and GraphQL APIs,
glab, the Terraform provider and external CI configuration files also work on all tiers. The instance template repository, custom project templates, compliance frameworks, protected environment approvals, compute quotas for instance runners on self-managed, and CI/CD for external repositories require Premium. Pipeline execution policies and scan execution policies require Ultimate.
<a id="151-instance-level-cicd-administration"></a>
15.1 Instance-Level CI/CD Administration
On a self-managed instance, one administrator decision affects every project. This subchapter covers the settings that control CI/CD defaults, the limits that protect the instance from runaway pipelines, where artifacts and job logs are physically stored, how long logs live, and how to offer teams ready-made templates. Every topic here requires administrator access (Admin area) on a self-managed instance.
Admin area (instance) Top-level group Project
┌──────────────────────┐ ┌────────────────┐ ┌────────────────────┐
│ defaults │ ──────► │ may override │ ──────► │ may override │
│ (artifact expiry, │ │ some defaults │ │ some defaults │
│ Auto DevOps, ...) │ └────────────────┘ └────────────────────┘
│ │
│ ceilings and limits │ ──────► apply everywhere; only an administrator can raise them
│ (artifact size, jobs │
│ per pipeline, ...) │
└──────────────────────┘
Instance CI/CD Settings
Why it matters. A self-managed GitLab instance may host thousands of projects. Configuring each one by hand is not realistic, so most CI/CD behavior starts from instance-wide defaults. Think of them as a building code: the city sets minimum rules once, and each owner can furnish their own house within them. Some instance settings are only defaults that groups or projects can override. Others are ceilings that only an administrator can change. Before you change one, know which kind it is.
Where to find them. Most settings are in Admin area > Settings > CI/CD, under Continuous Integration and Deployment. All of them can also be read and changed through the Application settings API (/application/settings), which is what you should use to keep the configuration reviewable and repeatable.
| Setting (UI) | API attribute | Default | Effect | Kind |
|---|---|---|---|---|
| Default to Auto DevOps pipeline for all projects | auto_devops_enabled | On | Projects without a .gitlab-ci.yml run Auto DevOps (section 12.6) | Default |
| Enable instance runners for new projects | shared_runners_enabled | On | New projects can use instance runners | Default |
| Maximum artifacts size (MB) | max_artifacts_size | 100 | Largest artifact archive a job can upload | Ceiling (administrators can set it per group or project) |
| Default artifacts expiration | default_artifacts_expire_in | 30 days | Applies to jobs that do not set artifacts:expire_in | Default |
| Keep the latest artifacts for all jobs in the latest successful pipelines | keep_latest_artifact | On | Artifacts of the latest successful pipeline per ref never expire | Default (projects can turn it off) |
| Archive jobs | archive_builds_in_human_readable | Not set | Jobs older than this can no longer be retried or run | Instance-wide |
| Default CI/CD configuration file | default_ci_config_path | .gitlab-ci.yml | Path used by new projects | Default |
| Maximum includes | ci_max_includes | 150 | Number of include entries a pipeline can resolve | Ceiling |
| Protect CI/CD variables by default | protected_ci_variables | Off | New variables are created as protected | Default |
| Instance runner compute quota (Premium) | shared_runners_minutes | 0 (unlimited) | Compute minutes per namespace per month on instance runners | Ceiling (overridable per namespace) |
The exact list changes between releases. Read the CI/CD settings page of your version's documentation before an upgrade, because new settings sometimes come with a new default.
Reading and changing settings through the API. Store the desired values in a repository and apply them from a pipeline or a script, so that every change is reviewed and recorded:
# Read the current CI/CD-related settings (requires an administrator token)
curl --silent --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/application/settings" |
jq '{auto_devops_enabled, shared_runners_enabled, max_artifacts_size,
default_artifacts_expire_in, keep_latest_artifact,
archive_builds_in_human_readable, ci_max_includes}'
# Change them: shorter default expiry, larger artifacts, Auto DevOps off by default
curl --silent --request PUT --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/application/settings" \
--data "default_artifacts_expire_in=7 days" \
--data "max_artifacts_size=250" \
--data "auto_devops_enabled=false" \
--data "archive_builds_in_human_readable=1 year" # jobs older than a year become read-only
Instance-level CI/CD variables. Variables defined in Admin area > Settings > CI/CD > Variables (or through /admin/ci/variables) are available to every job on the instance. They have the lowest precedence of all stored variables: group and project variables with the same key override them (section 5.1).
# Corporate HTTP proxy, available to every job on the instance
curl --request POST --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/admin/ci/variables" \
--data "key=HTTPS_PROXY" \
--data "value=http://proxy.corp.example.com:3128" \
--data "protected=false" # also needed on feature branches
Instance variables are a good place for infrastructure facts that every job needs: proxy settings, the hostname of an internal registry or package mirror, a certificate bundle. They are a poor place for secrets. Any user who can edit a .gitlab-ci.yml on the instance can print a variable that reaches their job, so an instance-wide secret is effectively shared with every developer. Keep secrets at the narrowest scope possible, and preferably in an external secrets manager (section 5.4).
Key takeaways
- Instance settings are either defaults (groups and projects can override them) or ceilings (only an administrator can raise them). Know which kind a setting is before you change it.
- Manage settings through the Application settings API from version control, not through clicks in the Admin area.
- Shorter default artifact expiry and "Archive jobs" are the simplest instance-wide levers on storage and database growth.
- Instance variables have the lowest precedence and reach every job. Use them for shared infrastructure facts, never for secrets.
Practice
- (Beginner) On a test instance, find the default artifact expiration and the maximum artifact size in the Admin area, then read the same values through the API with
curlandjq.- (Intermediate) Write a small script that stores the desired CI/CD application settings in a JSON file, compares them with the live values, and prints the differences before applying them. Run it from a scheduled pipeline in an "instance-config" project.
- Interview question: "A team asks you to add their deployment token as an instance-level CI/CD variable because 'every project needs it'. What do you answer?" Hint: who can read variables in a job, precedence, group variables, and an external secrets manager with OIDC.
Pipeline and Job Limits
Why it matters. A shared instance has to be protected from mistakes. A typo in a parallel:matrix can create thousands of jobs, a trigger loop can spawn downstream pipelines endlessly, and a test that prints a stack trace in a loop can produce a log of several gigabytes. Limits work like circuit breakers: they stop one project from exhausting resources that everyone else depends on. As an administrator, you need to know which limits exist, where they are configured, and what users see when they hit one.
Categories of limits.
| Category | Examples | Where it is configured |
|---|---|---|
| Configuration size | Maximum YAML size and nesting depth, maximum number of includes | Application settings |
| Pipeline shape | Jobs per pipeline (ci_pipeline_size), jobs listed in needs (ci_needs_size_limit, default 50), pipelines in one parent-child or multi-project hierarchy (pipeline_hierarchy_size, default 1000), jobs created by one parallel or parallel:matrix (200) | Plan limits (the 200 job limit for parallel is fixed) |
| Activity | Jobs in active pipelines (ci_active_jobs), pipeline schedules per project (ci_pipeline_schedules), pipeline subscriptions (ci_project_subscriptions), pipeline creation rate per project, user and commit | Plan limits, Admin area > Settings > Network |
| Data size | Job log size (ci_jobs_trace_size_limit, default 100 MB), artifact size (overall and per report type), dotenv report size and number of variables | Plan limits, application settings |
| Time | Project job timeout (default 60 minutes), the runner's maximum_timeout | Project settings, runner settings (section 6.3) |
| Runner side | concurrent, limit, output_limit (log size the runner sends) | Runner config.toml (section 6.3) |
Plan limits. Many CI limits are stored per plan. GitLab.com has several plans (Free, Premium, Ultimate). A self-managed instance normally has only one, called default, so changing default changes the limit for the whole instance. On self-managed, a value of 0 usually means "no limit".
# Inspect the CI limits of the default plan
curl --silent --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/application/plan_limits?plan_name=default" |
jq '{ci_pipeline_size, ci_active_jobs, ci_needs_size_limit,
ci_pipeline_schedules, ci_project_subscriptions, pipeline_hierarchy_size}'
# Cap pipelines at 2,000 jobs and active jobs at 5,000 per project
curl --silent --request PUT --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/application/plan_limits" \
--data "plan_name=default" \
--data "ci_pipeline_size=2000" \
--data "ci_active_jobs=5000"
Some limits are not exposed through the API and are changed from the Rails console on a GitLab node:
# sudo gitlab-rails console
limits = Plan.default.actual_limits
limits.update!(ci_jobs_trace_size_limit: 200) # maximum job log size, in MB
limits.update!(dotenv_variables: 50, # variables a dotenv report may define
dotenv_size: 10.kilobytes) # maximum size of the dotenv file
What users see. When a limit is reached, GitLab refuses to create the pipeline or the job, or truncates the data, and it says why. Common symptoms:
| Symptom | Limit involved | Usual fix (before raising the limit) |
|---|---|---|
| Pipeline not created, error about too many jobs | ci_pipeline_size | Split into parent-child pipelines (section 3.3) |
| Pipeline not created, error about too many active jobs | ci_active_jobs | Auto-cancel redundant pipelines, mark jobs interruptible (section 4.4) |
| YAML error: a job needs too many jobs | ci_needs_size_limit | Use stages or an aggregation job instead of a very long needs list |
| YAML error: too many includes, or the configuration is too large | ci_max_includes, YAML size | Merge small includes, use components with inputs |
| Downstream pipeline not created | pipeline_hierarchy_size, downstream rate limit | Look for trigger loops; reduce fan-out |
| Log ends with a "log exceeded limit" message | Runner output_limit or ci_jobs_trace_size_limit | Reduce verbosity; save the full output as an artifact |
dotenv variables missing in later jobs | dotenv_variables, dotenv_size | Pass fewer values, or use a JSON artifact |
A sound policy. A limit hit is a symptom. Before raising one, ask whether the pipeline should be designed differently. Raise a limit when the need is legitimate (a large monorepo, a test matrix that really needs 300 combinations), and document why. Keep a record of changed limits next to the application settings in version control, because limits are easy to forget during an upgrade or a migration to a new instance.
Key takeaways
- Limits protect a shared instance from runaway pipelines: too many jobs, trigger loops, huge logs and artifacts.
- Most CI limits are plan limits. On self-managed, the
defaultplan applies to the whole instance, and you change it through the Plan limits API or the Rails console. - Runner-side limits (
output_limit,concurrent,maximum_timeout) are configured separately, on the runner. - Treat a limit hit as a design problem first, and raise limits deliberately and with a written reason.
Practice
- (Beginner) Read the
defaultplan limits of a test instance through the API and write down the values ofci_pipeline_size,ci_needs_size_limitandci_pipeline_schedules.- (Intermediate) Set
ci_pipeline_sizeto 10 on a test instance, then push a pipeline with 12 jobs. Read the error, restore the limit, and rewrite the pipeline as a parent with two child pipelines that stay under the limit.- Interview question: "Users complain that pipelines 'sometimes don't start' after a busy release day. How do you find out which limit, if any, is responsible?" Hint: the error shown on the pipeline page,
ci_active_jobs, the pipeline creation rate limit, and the instance logs.
Artifact and Log Object Storage
Why it matters. Artifacts and job logs are the fastest-growing data in most GitLab instances. By default they are written to the local disk of the GitLab node. That works for a single small server and fails as soon as the instance grows: disks fill up, backups become slow, and with several web and Sidekiq nodes every node needs access to the same files. Object storage (Amazon S3, Google Cloud Storage, Azure Blob Storage, or an S3-compatible system such as MinIO) solves all three problems. It scales without resizing disks, it is cheaper per gigabyte, and every node can reach it.
What GitLab stores, and where.
| Data | Default local path (Linux package) | Object type in the configuration |
|---|---|---|
| Job artifacts, including archived job logs | /var/opt/gitlab/gitlab-rails/shared/artifacts | artifacts |
| Live job log chunks (default mode) | /var/opt/gitlab/gitlab-ci/builds | Not used unless incremental logging is enabled |
| Package registry files | /var/opt/gitlab/gitlab-rails/shared/packages | packages |
| Terraform state | /var/opt/gitlab/gitlab-rails/shared/terraform_state | terraform_state |
| Secure files | /var/opt/gitlab/gitlab-rails/shared/ci_secure_files | ci_secure_files |
| LFS objects, uploads, merge request diffs, Pages, dependency proxy | Various paths under shared/ | lfs, uploads, external_diffs, pages, dependency_proxy |
The runner's distributed cache is separate: it is configured on the runner, not in GitLab (section 8.2).
How a job log travels. Understanding the path of a log explains most storage questions:
Runner GitLab (Rails / Sidekiq) Storage
┌───────────┐ PATCH chunks ┌───────────────────────────────────┐
│ job runs, │ ─────────────► │ 1. live log is appended │ ──► local disk (builds dir)
│ streams │ │ (or kept in Redis and object │ or Redis + object storage
│ output │ │ storage with incremental │ (incremental logging)
└───────────┘ │ logging) │
│ 2. job finishes: Sidekiq archives │
│ the log as a "trace" artifact │ ──► artifacts storage
└───────────────────────────────────┘ (local or object storage)
Once a job finishes, its log is stored like an artifact. Moving artifacts to object storage therefore moves archived logs as well. Live logs are different: in the default mode they are written to a local directory, which every Rails node must share. Incremental logging keeps live chunks in Redis and object storage instead, removing the need for shared storage in a multi-node installation. It is enabled with the ci_enable_live_trace feature flag and requires object storage.
Configuring object storage (Linux package). The consolidated form in /etc/gitlab/gitlab.rb defines the connection once and one bucket per object type. Use a separate bucket for each type:
# /etc/gitlab/gitlab.rb
gitlab_rails['object_store']['enabled'] = true
gitlab_rails['object_store']['proxy_download'] = false # clients download directly with pre-signed URLs
gitlab_rails['object_store']['connection'] = {
'provider' => 'AWS',
'region' => 'eu-central-1',
'use_iam_profile' => true # instance role instead of static keys
}
gitlab_rails['object_store']['objects']['artifacts']['bucket'] = 'acme-gitlab-artifacts'
gitlab_rails['object_store']['objects']['packages']['bucket'] = 'acme-gitlab-packages'
gitlab_rails['object_store']['objects']['terraform_state']['bucket'] = 'acme-gitlab-terraform-state'
gitlab_rails['object_store']['objects']['ci_secure_files']['bucket'] = 'acme-gitlab-secure-files'
gitlab_rails['object_store']['objects']['lfs']['bucket'] = 'acme-gitlab-lfs'
gitlab_rails['object_store']['objects']['uploads']['bucket'] = 'acme-gitlab-uploads'
gitlab_rails['object_store']['objects']['external_diffs']['bucket'] = 'acme-gitlab-mr-diffs'
gitlab_rails['object_store']['objects']['dependency_proxy']['bucket'] = 'acme-gitlab-dependency-proxy'
gitlab_rails['object_store']['objects']['pages']['bucket'] = 'acme-gitlab-pages'
Apply it with sudo gitlab-ctl reconfigure. On Helm chart installations, the equivalent settings live under global.appConfig.object_store and per-type global.appConfig.<type>.bucket.
Moving existing data. Changing the configuration affects only new files. Existing files stay on disk until you migrate them:
# Move existing artifacts (including archived job logs) to object storage
sudo gitlab-rake gitlab:artifacts:migrate
# Other CI-related data
sudo gitlab-rake gitlab:packages:migrate
sudo gitlab-rake gitlab:terraform_states:migrate
sudo gitlab-rake gitlab:ci_secure_files:migrate
# Check that nothing is left behind before deleting local files
sudo find /var/opt/gitlab/gitlab-rails/shared/artifacts -type f | grep -v tmp | wc -l
Operational points.
proxy_download. Withfalse, users and runners download directly from the bucket using pre-signed URLs, which keeps load off GitLab. Set it totruewhen clients cannot reach the bucket directly, for example from an isolated network.- Do not let bucket lifecycle rules delete GitLab data. GitLab's database keeps a record of every file. If a lifecycle rule deletes objects, the records stay and downloads fail. Expire data through GitLab (
expire_in, pipeline cleanup) so that the database and the bucket stay consistent. Lifecycle rules are safe for storage classes (moving old objects to cheaper tiers) only if GitLab can still read them without a restore step. - Backups. The GitLab backup does not include object storage. Back the buckets up separately, for example with versioning and cross-region replication.
- Least privilege. Prefer instance roles or workload identity over static access keys, and grant GitLab access only to its own buckets.
Key takeaways
- Object storage is the standard for artifacts, logs and packages beyond a single small server: it scales, costs less, and every node can reach it.
- Archived job logs are stored as artifacts, so they follow the
artifactsobject storage configuration. Live logs need incremental logging to leave local disk. - Use the consolidated configuration with one bucket per object type, then migrate existing data with the
gitlab:*:migrateRake tasks. - Delete data through GitLab, not through bucket lifecycle rules, and back up buckets separately.
Practice
- (Beginner) On a test instance with MinIO, configure the consolidated object storage for artifacts only. Run a job that uploads an artifact and find the object in the bucket.
- (Intermediate) Migrate existing artifacts with
gitlab:artifacts:migrate. Verify that old artifacts and old job logs can still be downloaded and viewed, and measure how much local disk space was freed.- Interview question: "To save money, someone added a lifecycle rule that deletes objects older than 90 days from the artifacts bucket. What problems will users see, and what would you do instead?" Hint: database records pointing to missing files,
keep_latest_artifact, archived logs,expire_inand pipeline cleanup.
Job Log Retention
Why it matters. Unlike artifacts, job logs have no expiration by default. An instance running a few thousand jobs a day accumulates terabytes of logs over the years. Logs also carry risk: a secret printed by mistake stays readable for as long as the log exists. On the other hand, some organizations must keep build and deployment logs for a fixed time as audit evidence. A retention policy reconciles these needs: keep what you must, delete what you can.
What does not delete logs.
artifacts:expire_inand the default artifact expiration remove artifacts, not the job log.- The Archive jobs setting makes old jobs read-only (they can no longer be retried), but keeps their logs.
- Deleting objects from the bucket directly breaks the database references (see the previous topic).
What does delete logs.
| Mechanism | Scope | Removes | Notes |
|---|---|---|---|
Erase job (UI or POST /projects/:id/jobs/:job_id/erase) | One job | Log and artifacts of that job | For leaked secrets; requires Maintainer |
Delete pipeline (UI or DELETE /projects/:id/pipelines/:pipeline_id) | One pipeline | The pipeline, its jobs, logs and artifacts | Requires Owner |
Automatic pipeline cleanup (project setting on recent versions, API attribute ci_delete_pipelines_in_seconds) | One project | Pipelines older than the configured age, with everything attached | The easiest durable policy. Check whether your version has it |
| Script over the Pipelines API | Any set of projects | Pipelines older than a cutoff | Works on every version. Shown below |
Designing a policy. Decide retention per class of project rather than per project:
| Class | Example | Suggested retention | Reason |
|---|---|---|---|
| Regulated production services | Payments, health data | As required by the regulation (often 1 to 7 years), for deployment pipelines at least | Audit evidence |
| Ordinary services | Most product teams | 6 to 12 months | Enough to investigate regressions |
| Experiments, sandboxes | Training projects | 30 to 90 days | No long-term value |
For audit evidence, a full job log is often not the best record anyway. Release evidence (section 8.4), deployment records in environments (section 12.1) and audit events (section 11.3) are smaller, structured and easier to show an auditor.
Example: a retention job over the API. This script deletes pipelines older than a cutoff in every project of a group, except projects marked as regulated. It runs as a scheduled pipeline in an administration project:
# prune_pipelines.py: delete pipelines older than RETENTION_DAYS in a group
# Usage: GITLAB_TOKEN=glpat-... python3 prune_pipelines.py acme 180 [--dry-run]
import datetime
import os
import sys
import gitlab
group_path, days = sys.argv[1], int(sys.argv[2])
dry_run = "--dry-run" in sys.argv
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=days)).isoformat()
gl = gitlab.Gitlab("https://gitlab.example.com", private_token=os.environ["GITLAB_TOKEN"])
group = gl.groups.get(group_path)
for ref in group.projects.list(include_subgroups=True, archived=False, iterator=True):
project = gl.projects.get(ref.id)
if "retention:regulated" in project.topics: # opt-out for audited projects
continue
old = project.pipelines.list(updated_before=cutoff, iterator=True)
for pipeline in old:
print(f"{project.path_with_namespace} #{pipeline.id} {pipeline.updated_at}")
if not dry_run:
pipeline.delete() # removes jobs, logs and artifacts
Run it with --dry-run first and review the output. Deletion is irreversible.
Handling a leaked secret. When a secret appears in a log, retention policy is too slow. Erase the job immediately, rotate the secret, and check whether other jobs printed it too, for example with the log search technique from section 14.1.
Key takeaways
- Job logs never expire by default. Artifact expiration and "Archive jobs" do not remove them.
- Delete logs through GitLab (erase job, delete pipeline, automatic pipeline cleanup, API scripts) so that the database and storage stay consistent.
- Define retention per class of project, balancing audit requirements, risk and storage cost.
- For audit purposes, structured evidence (releases, deployments, audit events) is usually better than raw logs.
Practice
- (Beginner) Erase the log of one job through the API and confirm in the UI that the log and the artifacts are gone but the job entry remains.
- (Intermediate) Run the retention script with
--dry-runagainst a test group, add a project topic to exclude one project, and schedule the script to run weekly with a project access token.- Interview question: "Your security team wants all job logs deleted after 30 days, and your compliance team wants deployment logs kept for five years. How do you satisfy both?" Hint: classes of projects, separate deployment projects or pipelines, structured evidence instead of raw logs.
Instance Template Repository
Why it matters. When a developer creates a new file in the GitLab web editor and chooses Apply a template, GitLab offers built-in templates for .gitlab-ci.yml, Dockerfile, .gitignore and LICENSE files. Those are generic. Most organizations want to offer their own starting points: a .gitlab-ci.yml that already includes the company's components, a Dockerfile based on the approved base image. The instance template repository (Premium, self-managed) adds these organization templates to the same dropdown.
How it works. An administrator selects a project in Admin area > Settings > Templates. Files in specific top-level directories of that project's default branch appear in the template dropdowns across the instance:
platform/file-templates (project selected as the instance template repository)
├── gitlab-ci/
│ ├── node-service.yml -> appears under ".gitlab-ci.yml" templates as "node-service"
│ └── java-library.yml
├── Dockerfile/
│ └── python-api.dockerfile -> appears under "Dockerfile" templates
├── gitignore/
│ └── acme-java.gitignore
└── LICENSE/
└── acme-proprietary.txt
A template in gitlab-ci/ should be short and point to centrally maintained configuration, not contain it:
# gitlab-ci/node-service.yml
# Starting point for Node.js services. Do not copy jobs into this file:
# update the pipeline template instead, so every service benefits.
include:
- project: platform/pipeline-templates
ref: v3.2.0 # pin a released version (section 15.2)
file: templates/node-service.yml
inputs:
node_version: "22"
deploy: true
The key limitation: templates are copied, not linked. Applying a template copies its contents into the project once. Later changes to the template do not reach projects that already used it. That is why the template above only contains an include: the copied part stays trivial, and the real logic lives where it can be updated centrally.
Related features compared.
| Feature | What it provides | Updates propagate? | Tier |
|---|---|---|---|
Built-in templates (include:template) | GitLab's own CI templates, included by reference (section 9.4) | Yes, with GitLab upgrades | All |
| Instance template repository | Organization file templates in the web editor dropdowns | No, copied once | Premium |
| Custom project templates (instance or group) | Whole projects to start new repositories from | No, copied once | Premium |
include:project and CI/CD components | Shared configuration referenced at run time (sections 9.1, 9.3) | Yes, according to the pinned version | All |
The usual pattern combines them: a custom project template or file template gives a new project the right skeleton, and that skeleton references centrally versioned configuration.
Key takeaways
- The instance template repository adds organization-specific
.gitlab-ci.yml,Dockerfile,.gitignoreandLICENSEtemplates to the web editor (Premium, self-managed). - Files go into the
gitlab-ci/,Dockerfile/,gitignore/andLICENSE/directories of the selected project. - Templates are copied once. Keep CI templates down to an
includeof centrally maintained configuration. - These templates are not available through
include:template, which only serves GitLab's built-in templates.
Practice
- (Beginner) Create a template project with one file in
gitlab-ci/, select it as the instance template repository, and apply the template in a new project through the web editor.- (Intermediate) Create a custom project template for a Node.js service that includes a README, a
Dockerfileand a.gitlab-ci.ymlwith a pinnedinclude:project. Create two projects from it and change the central pipeline template. Confirm that both projects pick up the change on their next pipeline.- Interview question: "Why is a 300-line
.gitlab-ci.ymltemplate in the instance template repository a maintenance problem two years later?" Hint: copying versus referencing, drift, and how many copies you would have to fix for one security change.
<a id="152-governance-at-scale"></a>
15.2 Governance at Scale
With five projects, every team can write its own pipeline. With five hundred, that freedom becomes a cost: the same Docker build is solved forty different ways, a security fix has to be applied in hundreds of places, and nobody can say which projects actually run the mandatory scans. This subchapter covers how a platform team turns pipeline configuration into a product, through shared component libraries, standardized pipeline templates and a centrally run runner fleet, and how it enforces the parts that are not optional. The guiding principle is to make the right way the easy way, and to enforce only what must be enforced.
Governance model: pave the road, then guard the edges
┌───────────────────────────────────────────────────────────────────────────┐
│ ENFORCE (cannot be removed by projects) │
│ pipeline execution policies, scan execution policies, approval policies │
├───────────────────────────────────────────────────────────────────────────┤
│ STANDARDIZE (default path, easy to adopt, possible to extend) │
│ golden pipeline templates ──uses──► component library │
├───────────────────────────────────────────────────────────────────────────┤
│ PROVIDE (shared infrastructure) │
│ instance and group runners, registries, secrets, object storage │
└───────────────────────────────────────────────────────────────────────────┘
Organization-Wide Component Libraries
Why it matters. Section 9.3 explained how to write a single CI/CD component. At organization scale, the challenge is not writing components but running a library of them: deciding who owns each one, how versions are released and retired, how teams find them, and how the platform team knows who depends on what. A component library works like an internal standard library for pipelines. It is only useful if people trust it, and they trust it only if it is maintained like a product.
Structure the library. A common layout is one group for all components, with one project per domain. Each project can contain several related components and is published to the CI/CD Catalog:
platform/ci-components (group, owned by the platform team)
├── build components: node-build, java-build, go-build, container-image
├── test components: junit-merge, coverage-gate, e2e-playwright
├── security components: dependency-audit, sbom, image-sign
├── deploy components: helm-deploy, k8s-rollout, release-notes
└── catalog-docs handbook: how to use, contribute, and request components
Grouping components by domain, rather than one project per component, keeps the number of projects manageable while still letting each domain release independently. Each project follows the structure from section 9.3: a templates/ directory, a README with examples for every input, and a pipeline that tests the components and releases them.
Make ownership and contribution explicit.
| Concern | Recommended practice |
|---|---|
| Ownership | One owning team per component project, recorded in CODEOWNERS and in the README |
| Contributions | Inner source: any team can open a merge request, and the owners review it |
| Versioning | Strict semantic versioning. Renaming or removing an input, or changing a default in a way that changes behavior, is a major release |
| Changelog | Every release lists changes and migration steps. The release description appears in the Catalog |
| Deprecation | Announce deprecated inputs or components in the changelog and in job output, support the previous major version for a fixed period (for example 6 months), then archive |
| Quality | Every component has tests that run it in a real pipeline before release (section 9.3) |
| Support | A channel or issue tracker where users report problems, with an agreed response time |
Choose a version policy for consumers. How consumers reference a component decides how changes reach them:
include:
# Pinned exact version: nothing changes until someone bumps it (with Renovate or a merge request)
- component: $CI_SERVER_FQDN/platform/ci-components/build/node-build@2.4.1
inputs:
node_version: "22"
# Major version only: receives minor and patch releases automatically
- component: $CI_SERVER_FQDN/platform/ci-components/security/sbom@1
# Latest release: convenient in experiments, risky in production pipelines
- component: $CI_SERVER_FQDN/platform/ci-components/test/junit-merge@~latest
| Reference | Updates arrive | Good for | Risk |
|---|---|---|---|
@2.4.1 | Only when bumped | Regulated or critical pipelines | Projects fall behind, including on security fixes |
@2.4 or @2 | Automatically, within the minor or major version | Most projects, if the library follows semver strictly | A bad release reaches everyone at once |
@~latest | Every release, including majors | Sandboxes, component testing | Breaking changes arrive without warning |
@<commit SHA> | Never | Reproducing an old pipeline exactly | No updates at all |
A practical policy for most organizations is "pin the major version, and the library guarantees semver". Teams with stricter needs pin exact versions and let a bot such as Renovate open merge requests for new releases.
Self-managed instances and GitLab.com components. A self-managed Catalog only lists components hosted on that instance. To use a component published on GitLab.com (for example components/sast), mirror its project into your instance, then reference the mirrored path. Mirroring also gives you a chance to review what you run.
Measure adoption. The Catalog shows how many projects used each component recently. To see exactly which project uses which version, ask GitLab for the resolved includes of each project. The CI Lint API returns the list of included files, components included:
# component_usage.py: which projects in a group include which components, at which version
# Usage: GITLAB_TOKEN=glpat-... python3 component_usage.py acme platform/ci-components
import collections
import os
import sys
import gitlab
group_path, library = sys.argv[1], sys.argv[2]
gl = gitlab.Gitlab("https://gitlab.example.com", private_token=os.environ["GITLAB_TOKEN"])
usage = collections.defaultdict(list)
for ref in gl.groups.get(group_path).projects.list(include_subgroups=True, archived=False, iterator=True):
project = gl.projects.get(ref.id)
try:
lint = project.ci_lint.get() # lints the default branch configuration
except gitlab.GitlabError:
continue # no pipeline configuration or no access
for inc in getattr(lint, "includes", []) or []:
if inc.get("type") == "component" and library in inc.get("location", ""):
usage[inc["location"]].append(project.path_with_namespace)
for location, projects in sorted(usage.items()):
print(f"{location}: {len(projects)} projects")
The same data answers two operational questions: "who is still on major version 1 before we stop supporting it?" and "who would be affected by this bug?".
Key takeaways
- Run components as a product: clear ownership, inner-source contributions, strict semver, changelogs, deprecation periods and tests.
- Organize the library as one group with one project per domain, each containing related components.
- Pick a consumer version policy deliberately. Pinning the major version works when the library guarantees semver, and exact pins work best with automated update merge requests.
- Mirror external components into a self-managed instance, and track adoption with Catalog usage data or the resolved includes from the CI Lint API.
Practice
- (Beginner) Create a
platform/ci-components/buildproject with two components, publish version1.0.0to the Catalog, and use one of them from another project with@1.- (Intermediate) Release
1.1.0with a new optional input and2.0.0that renames an existing input. Verify which consumers pick up which release automatically, then use the usage script to list the projects still on major version 1.- Interview question: "Your component library has 60 components and three maintainers. Teams complain that their changes wait weeks for review. How do you scale the library without losing quality?" Hint: domain ownership,
CODEOWNERS, contribution guidelines, automated component tests, and a maturity level per component.
Standardized Pipeline Templates
Why it matters. Components are building blocks. Most teams do not want to assemble a pipeline from twenty blocks. They want a pipeline that works for "a Java service deployed to Kubernetes" out of the box. A standardized pipeline template, often called a golden path, is a complete pipeline for one type of project, assembled from components, with a small set of documented options. A team adopts it with a few lines of YAML and gets builds, tests, scans and deployments that follow every company standard.
The layered model. Each layer is maintained by the people best placed to do it:
┌──────────────────────────────────────────────┐
│ Project .gitlab-ci.yml (team) │ inputs, a few extra jobs,
│ include: java-service template @v3 │ documented overrides
└───────────────────────┬──────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Pipeline template "java-service" (platform) │ stages, rules, ordering,
│ uses components with chosen inputs │ environments, defaults
└───────────────────────┬──────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Components (component library owners) │ one task each: build,
│ java-build, container-image, helm-deploy │ test, scan, deploy
└──────────────────────────────────────────────┘
A template. A pipeline template is a YAML file with spec:inputs, stored in a versioned project. It can be included with include:project (or published as a component in its own right):
# platform/pipeline-templates: templates/java-service.yml
spec:
inputs:
java_version:
default: "21"
options: ["17", "21"]
image_name:
default: $CI_PROJECT_NAME
deploy_to_production:
type: boolean
default: true
helm_chart_path:
default: chart/
---
workflow:
rules: # one pipeline per MR or branch, no duplicates
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
stages: [build, test, package, deploy]
include:
- component: $CI_SERVER_FQDN/platform/ci-components/build/java-build@3
inputs:
stage: build
java_version: $[[ inputs.java_version ]]
- component: $CI_SERVER_FQDN/platform/ci-components/build/container-image@2
inputs:
stage: package
image_name: $[[ inputs.image_name ]]
- component: $CI_SERVER_FQDN/platform/ci-components/security/sbom@1
inputs:
stage: package
- component: $CI_SERVER_FQDN/platform/ci-components/deploy/helm-deploy@4
inputs:
stage: deploy
chart_path: $[[ inputs.helm_chart_path ]]
production: $[[ inputs.deploy_to_production ]]
The consuming project needs only this:
# .gitlab-ci.yml of a Java service
include:
- project: platform/pipeline-templates
ref: v3.4.0 # released tag of the template project
file: templates/java-service.yml
inputs:
java_version: "17"
deploy_to_production: false # internal tool: staging only
# Documented extension point: extra jobs in the template's stages are allowed
contract-tests:
stage: test
image: registry.example.com/tools/pact-cli:1.4
script:
- pact-verifier --provider-base-url "$STAGING_URL"
Design the extension points. A template that cannot be extended gets copied and edited, and the copy drifts. A template that can be changed in any way gives no guarantees. Decide in advance, and document, what teams may do:
| Change | Allowed? | How |
|---|---|---|
| Choose versions, paths, toggles | Yes | Inputs |
| Add jobs | Yes | Jobs in existing stages, or .pre / .post |
Change a template job's script | Discouraged | Overriding the job by name works (section 9.1), but breaks on template updates |
| Skip tests or scans | No | Enforce with policies (see "Enforcing Pipeline Standards Across Groups") |
Roll out template changes safely. Release the template project with semantic version tags, just like components. Projects pin a tag, and a bot opens update merge requests, so each team sees the change in its own pipeline before merging it. For urgent fixes (for example a vulnerable base image), release a patch version and use the update merge requests or a short-lived policy to reach everyone quickly.
Detect drift. Over time some projects stop using the template, or pin a version that is no longer supported. The resolved includes from the CI Lint API (shown in the previous topic) reveal which template and which ref each project uses. Report the result on a dashboard or in a regular issue, rather than as a surprise during an audit.
Key takeaways
- A standardized pipeline template is a complete, versioned pipeline for one type of project, assembled from components and configured through inputs.
- Layer responsibilities: components do one task, templates define pipeline structure, projects provide inputs and documented extras.
- Design and document extension points. A template that cannot be extended gets copied and drifts.
- Release templates with semantic versions, update consumers through automated merge requests, and detect drift with the resolved includes.
Practice
- (Beginner) Write a template for a static website (build, test, deploy to Pages) with one input for the Node.js version, and adopt it in two projects with
include:projectand a pinned tag.- (Intermediate) Add a boolean input
run_e2ethat adds an end-to-end test job only when true. Releasev1.1.0, configure Renovate (or a script) to open update merge requests in the consuming projects, and merge one of them.- Interview question: "A team says the golden pipeline is too rigid and wants to maintain their own
.gitlab-ci.ymlfrom scratch. How do you respond?" Hint: find out what is missing, extension points and new inputs, what must stay enforced regardless, and the cost of maintaining a fork.
Centralized Runner Strategy
Why it matters. Section 6 explained how runners work. At organization scale the question is who runs them. If every team registers its own runners, you get dozens of under-used machines, inconsistent security settings, outdated runner versions and no view of cost. If one central team runs everything, special needs (GPUs, macOS for iOS builds, hardware in a lab) become bottlenecks. A runner strategy decides which runners are central, which are delegated, and the rules that apply to both.
Ownership models.
| Model | Description | Advantages | Drawbacks |
|---|---|---|---|
| Decentralized | Each team registers and runs its own project or group runners | Full team autonomy | Duplicated effort, inconsistent security, no cost overview |
| Centralized | The platform team runs instance runners for everyone | Consistency, efficiency, one place to patch | Bottleneck for special needs |
| Hybrid (recommended) | Central instance runners cover standard workloads. Group runners for special needs follow a platform-provided module and rules | Efficiency for common cases, autonomy where it matters | Requires clear rules and some tooling |
A hybrid fleet.
Instance runners (platform team) Group runners (teams, platform module)
┌─────────────────────────────────────────────┐ ┌────────────────────────────────────┐
│ linux-small 2 vCPU / 4 GB autoscaled │ │ ml-team: gpu, cuda-12 │
│ linux-medium 4 vCPU / 16 GB autoscaled │ │ mobile-team: macos, xcode-16 │
│ linux-large 16 vCPU / 64 GB autoscaled │ │ firmware: hil-lab (hardware) │
│ docker-build rootless image builds │ └────────────────────────────────────┘
└─────────────────────────────────────────────┘
Protected runners (platform team, separate cluster and cloud account)
┌─────────────────────────────────────────────┐
│ deploy-prod protected, only protected │
│ branches and tags │
└─────────────────────────────────────────────┘
Standards that apply to every runner.
- A tag taxonomy. Publish the list of tags and what they mean: size (
linux-small,linux-large), platform (macos,windows), capability (gpu,docker-build), trust level (deploy-prod). Teams should not invent new tags for the same thing. - Isolation by trust level. Deployment runners are protected and run in a separate environment with their own credentials (section 6.3). Projects that run untrusted code, such as public projects accepting forks, never share runners with production deployments.
- Configuration as code. Runner managers are deployed with Helm values or Terraform from a repository, never configured by hand. Runner versions are upgraded on a schedule (section 6.4).
- Monitoring. All runners export Prometheus metrics, and the Runner Fleet Dashboard shows usage and queue times (section 6.4).
Control who can create runners. Administrators decide where runners may be created (Admin area > Settings > CI/CD > Runners), and group owners decide whether projects may use instance runners. The group setting shared_runners_setting accepts enabled, disabled_and_overridable (subgroups and projects can turn instance runners back on) and disabled_and_unoverridable:
# Platform team: create an instance runner and keep its token for the Helm deployment
curl --silent --request POST --header "PRIVATE-TOKEN: $ADMIN_TOKEN" \
"https://gitlab.example.com/api/v4/user/runners" \
--data "runner_type=instance_type" \
--data "description=linux-medium autoscaled (k8s prod-ci cluster)" \
--data "tag_list=linux-medium" \
--data "run_untagged=false" | jq -r '.token' # glrt-... token, store it in the secrets manager
# Group owner: this group's projects must use the group's own GPU runners only
curl --silent --request PUT --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/groups/acme%2Fml" \
--data "shared_runners_setting=disabled_and_unoverridable"
Make cost visible. On self-managed Premium and Ultimate, administrators can set a compute quota for instance runners per namespace, and usage is reported per group and project. Even without hard quotas, showing each team what its pipelines consume changes behavior more effectively than any rule (section 13.2).
Key takeaways
- A hybrid model works best for most organizations: central, autoscaled instance runners for standard work, and governed group runners for special hardware.
- Publish a tag taxonomy, isolate runners by trust level, and manage every runner manager as code.
- Control runner creation through the Admin area and
shared_runners_settingon groups. - Make runner cost visible per namespace with usage reports and, where needed, compute quotas.
Practice
- (Beginner) Write down a tag taxonomy for an organization with Linux services, one machine learning team with GPUs and one iOS team, and map each type of job to a tag.
- (Intermediate) Create an instance runner through the API, deploy it with the GitLab Runner Helm chart using the returned token from a Kubernetes secret, and restrict one group to its own group runners with
shared_runners_setting.- Interview question: "A team has run its own unmanaged runner under a desk for three years, and it deploys to production. How do you bring it under a central strategy without stopping their deployments?" Hint: inventory, protected runners, credentials, a migration window, and running the old and new runners side by side for a period.
Enforcing Pipeline Standards Across Groups
Why it matters. Templates and components make the right way easy, but they are optional. A team can stop including the template, override a scan job, or simply never adopt it. For most standards that is acceptable. For a few, such as secret detection, license checks, evidence for auditors or deployment approvals, the organization needs a guarantee. Enforcement should be limited to those few, because every enforced rule costs teams flexibility and costs the platform team support time.
An enforcement ladder. Choose the lightest mechanism that achieves the goal:
| Level | Mechanism | Projects can opt out? | Tier |
|---|---|---|---|
| 1. Recommend | Templates, components, documentation | Yes | All |
| 2. Detect | Reports on resolved includes and project settings, compliance center | Yes, but it is visible | All (compliance center: Ultimate) |
| 3. Protect the configuration | CODEOWNERS approval for CI files (section 11.4), external CI configuration file | Only with approval from the owners | All (code owner approval: Premium) |
| 4. Enforce jobs | Scan execution policies, pipeline execution policies (section 11.3) | No | Ultimate |
| 5. Enforce merging and deploying | Merge request approval policies, protected environments with approvals, "Pipelines must succeed" | No | Premium and Ultimate |
Scope enforcement with compliance frameworks. Not every project needs the same controls. Label projects with compliance frameworks (section 11.3) and scope policies to them, so that a payment service runs PCI controls and an internal wiki does not.
External CI configuration files. On any tier, a project's CI/CD configuration file can live in another project. Set the project's CI/CD configuration file to a path in the form path/to/file.yml@group/project, optionally followed by :ref. Developers of the service cannot change the pipeline, because they have no write access to the configuration project:
# The payments service runs the pipeline maintained by the platform team
curl --request PUT --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/acme%2Fpayments%2Fcheckout" \
--data-urlencode "ci_config_path=pipelines/payments-service.yml@platform/locked-pipelines"
This is strict and coarse: the team loses all control of its pipeline. Pipeline execution policies (Ultimate) are usually better, because they enforce only the mandatory jobs and leave the rest to the team.
Close the loopholes. Enforced jobs are pointless if a pipeline can be started in a way that bypasses them. Combine policies with these settings:
| Loophole | Setting that closes it |
|---|---|
Running a pipeline with variables that disable a check (SAST_DISABLED=true) | Restrict pipeline variables: ci_pipeline_variables_minimum_override_role set to maintainer or no_one_allowed |
Pushing with -o ci.skip or [skip ci] | Pipeline execution policies with skip_ci: allowed: false |
| Merging without a pipeline | "Pipelines must succeed" on the project, or enforced through an approval policy |
| Deploying from a branch other than the default | Protected branches and protected environments (section 12.1) |
| Editing the enforcement configuration itself | Policies and pipeline configuration projects owned by a small group, with CODEOWNERS and protected branches |
Audit continuously. Policies cover jobs, but many standards are project settings. A scheduled audit catches projects that drift away from the baseline:
# settings_audit.py: report projects that do not meet the CI/CD baseline
# Usage: GITLAB_TOKEN=glpat-... python3 settings_audit.py acme
import os
import sys
import gitlab
BASELINE = {
"only_allow_merge_if_pipeline_succeeds": True,
"ci_pipeline_variables_minimum_override_role": "maintainer",
"auto_cancel_pending_pipelines": "enabled",
}
gl = gitlab.Gitlab("https://gitlab.example.com", private_token=os.environ["GITLAB_TOKEN"])
for ref in gl.groups.get(sys.argv[1]).projects.list(include_subgroups=True, archived=False, iterator=True):
project = gl.projects.get(ref.id)
problems = [f"{key}={getattr(project, key, None)!r}"
for key, expected in BASELINE.items()
if getattr(project, key, None) != expected]
default_branch = project.default_branch or "main"
if not any(b.name == default_branch for b in project.protectedbranches.list(iterator=True)):
problems.append(f"default branch '{default_branch}' is not protected")
if problems:
print(f"{project.path_with_namespace}: " + "; ".join(problems))
Publish the result to the owning teams, and fix settings automatically only where teams have agreed to it, for example through the Terraform provider described in section 15.3.
Plan for exceptions. Every rule will meet a legitimate exception: a project that cannot run a scanner, a team that must deploy during a freeze. Define how an exception is requested, who approves it, and when it expires. Implement exceptions as data (an exclusion in the policy scope, a framework label) rather than as one-off edits, so they remain visible and can be reviewed.
Key takeaways
- Enforce only what must be guaranteed. Use the lightest mechanism that works: recommend, detect, protect the configuration, enforce jobs, enforce merging and deploying.
- Scope enforced controls with compliance frameworks so that each project gets the controls that apply to it.
- External CI configuration files enforce a whole pipeline on any tier. Pipeline execution policies enforce only the mandatory jobs.
- Close the loopholes (pipeline variables,
ci.skip, merging without pipelines), audit project settings on a schedule, and handle exceptions through a documented, time-limited process.
Practice
- (Beginner) Move a test project's CI configuration to another project with
ci_config_path, then confirm that a developer of the first project cannot change the pipeline.- (Intermediate) Run the settings audit against a test group, extend it with one more check (for example that
CODEOWNERScovers.gitlab-ci.yml), and schedule it to open or update an issue with the findings.- Interview question: "Your organization requires secret detection in every pipeline. Describe three ways a determined developer could avoid it, and how you would prevent each." Hint: pipeline variables, skip options, overriding jobs, merging without a pipeline, and policy scope.
<a id="153-programmatic-pipeline-management"></a>
15.3 Programmatic Pipeline Management
Everything you can do with pipelines in the web interface, and many things you cannot, is also available programmatically. This subchapter covers the four main tools and when to use each one: the REST API for scripts and integrations, the GraphQL API for nested data and precise queries, the glab CLI for people at a terminal and for short CI scripts, and the Terraform provider for managing GitLab's own configuration as code.
| Tool | Best for | Typical user |
|---|---|---|
REST API (/api/v4) | Scripts, integrations, anything that changes state | Tooling, bots, other systems |
GraphQL API (/api/graphql) | Reading nested data (pipelines, jobs, stages) in one request | Dashboards, reports |
glab CLI | Interactive work and short scripts | Developers, CI jobs |
| Terraform provider | Desired-state configuration of groups, projects, variables, runners and settings | Platform teams |
Pipelines REST API
Why it matters. Pipelines often need to be started, watched or cleaned up by something other than a git push: a release tool that starts a deployment pipeline and waits for the result, a nightly script that cancels pipelines stuck on old branches, a dashboard that reports failures. The REST API is the most complete and most widely supported interface for all of these. Section 3.5 covered starting pipelines with trigger tokens. This topic covers the whole pipeline lifecycle.
Authentication.
| Token | Use it for | Notes |
|---|---|---|
| Personal access token | Your own scripts and experiments | Acts as you, with all your permissions. Avoid in shared automation |
| Project or group access token | Automation for one project or group | A bot user with a chosen role and scopes (api, read_api). Has an expiry date |
CI_JOB_TOKEN | Calls from inside a job | Short-lived, limited to a subset of endpoints and to projects in its allowlist (section 11.4) |
| OAuth 2.0 token | Applications acting on behalf of users | For integrations with a user interface |
The main endpoints.
| Action | Endpoint |
|---|---|
List pipelines (filter by status, ref, sha, source, scope, updated_after, updated_before) | GET /projects/:id/pipelines |
| Get one pipeline, or the latest one for a ref | GET /projects/:id/pipelines/:pipeline_id, GET /projects/:id/pipelines/latest?ref=main |
| Create a pipeline with variables | POST /projects/:id/pipeline |
| Retry failed jobs, cancel running jobs | POST /projects/:id/pipelines/:pipeline_id/retry, .../cancel |
| Delete a pipeline | DELETE /projects/:id/pipelines/:pipeline_id |
| Jobs and trigger jobs of a pipeline | GET /projects/:id/pipelines/:pipeline_id/jobs, .../bridges |
| Test report and its summary | GET /projects/:id/pipelines/:pipeline_id/test_report, .../test_report_summary |
| Variables a pipeline was started with | GET /projects/:id/pipelines/:pipeline_id/variables |
| Start a manual job, retry or cancel one job | POST /projects/:id/jobs/:job_id/play, .../retry, .../cancel |
| Pipeline schedules | /projects/:id/pipeline_schedules (create, update, play, take ownership) |
:id is the numeric project ID or the URL-encoded path (acme%2Fshop%2Fapi). A pipeline's status is one of created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual or scheduled.
Example: start a pipeline and wait for it. A release tool starts a deployment pipeline with variables, then waits until it finishes and returns its result as an exit code:
#!/usr/bin/env bash
# deploy_and_wait.sh: start a deployment pipeline and wait for its result
# Usage: GITLAB_TOKEN=glpat-... ./deploy_and_wait.sh acme%2Fshop%2Fapi v2.8.0 staging
set -euo pipefail
API="https://gitlab.example.com/api/v4/projects/$1"
AUTH=(--header "PRIVATE-TOKEN: $GITLAB_TOKEN")
pipeline=$(curl --fail --silent "${AUTH[@]}" --header "Content-Type: application/json" \
--request POST "$API/pipeline" \
--data "{\"ref\": \"$2\", \"variables\": [
{\"key\": \"DEPLOY_ENV\", \"value\": \"$3\"},
{\"key\": \"TRIGGERED_BY\", \"value\": \"release-tool\"}]}")
id=$(jq -r '.id' <<<"$pipeline")
echo "Started pipeline $(jq -r '.web_url' <<<"$pipeline")"
while true; do
status=$(curl --fail --silent "${AUTH[@]}" "$API/pipelines/$id" | jq -r '.status')
case "$status" in
success) echo "Pipeline $id succeeded"; exit 0 ;;
failed|canceled|skipped) echo "Pipeline $id ended with status $status"; exit 1 ;;
manual) echo "Pipeline $id waits for a manual action"; exit 2 ;;
*) sleep 15 ;; # created, pending, running, ...
esac
done
Variables passed this way are subject to the project's pipeline variable restrictions (section 15.2). Recent GitLab versions also accept inputs for pipelines whose configuration defines spec:inputs. Prefer inputs over free-form variables when your version supports them.
Example: bulk operations with python-gitlab. For anything longer than a few calls, a client library handles pagination, retries and errors. This script cancels running or pending pipelines on branches that have not been updated for a day, which frees runners after a busy day:
# cancel_stale.py: cancel pipelines still running or pending after 24 hours
# Usage: GITLAB_TOKEN=glpat-... python3 cancel_stale.py acme/shop/api
import datetime
import os
import sys
import gitlab
gl = gitlab.Gitlab("https://gitlab.example.com",
private_token=os.environ["GITLAB_TOKEN"],
retry_transient_errors=True) # retry on 429 and 5xx responses
project = gl.projects.get(sys.argv[1])
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(hours=24)).isoformat()
for status in ("running", "pending"):
for pipeline in project.pipelines.list(status=status, updated_before=cutoff, iterator=True):
if pipeline.ref == project.default_branch:
continue # never touch the default branch
print(f"Canceling #{pipeline.id} on {pipeline.ref} (updated {pipeline.updated_at})")
pipeline.cancel()
Working well with the API.
- Paginate. List endpoints return 20 items by default and at most 100 per page (
per_page=100). Follow theLinkorx-next-pageheaders, or use keyset pagination where available, instead of assuming that one page holds everything. - Respect rate limits. A
429 Too Many Requestsresponse comes with aRetry-Afterheader. Wait as instructed, and spread large batch jobs over time. - Use the narrowest token.
read_apifor reports,apionly for scripts that change things, and one token per integration, so that you can see in audit events which integration did what and revoke it alone.
Key takeaways
- The REST API covers the full pipeline lifecycle: list, create with variables, watch, retry, cancel, delete, and read jobs and test reports.
- Use project or group access tokens with minimal scopes for shared automation, and
CI_JOB_TOKENfrom inside jobs where it is supported. - When waiting for a pipeline, treat
success,failed,canceled,skippedandmanualas end states for your script. - Use a client library such as python-gitlab for bulk work, and always handle pagination and rate limits.
Practice
- (Beginner) With
curlandjq, list the last 10 failed pipelines of a project with their ref, creation time and URL.- (Intermediate) Extend
deploy_and_wait.shwith a timeout (for example 30 minutes) after which it cancels the pipeline and exits with a distinct code. Test it with a pipeline that sleeps longer than the timeout.- Interview question: "A script that retries all failed pipelines every night has started to slow down GitLab for everyone. What is wrong with it, and how would you redesign it?" Hint: blind retries, pagination, rate limits,
failure_reason, and limiting the retry to infrastructure failures.
GraphQL API for CI/CD
Why it matters. REST endpoints each return one kind of object. Answering "for the last 20 pipelines on main, which jobs failed, in which stage, and how long did each take?" takes one call for the pipelines plus one call per pipeline for its jobs: 21 requests, most of their fields unused. GraphQL lets the client describe exactly the data it needs, including nested objects, and returns it in one response. GitLab's own web interface uses GraphQL for most pipeline pages.
How it works. There is a single endpoint, /api/graphql, which takes a query in its request body. The response has the same shape as the query. The interactive explorer at https://gitlab.example.com/-/graphql-explorer lists every type and field, with auto-completion, and is the fastest way to build a query.
# Last 20 pipelines on main, with their failed jobs
query FailedJobs($fullPath: ID!, $after: String) {
project(fullPath: $fullPath) {
pipelines(ref: "main", first: 20, after: $after) {
pageInfo { hasNextPage endCursor } # for pagination
nodes {
iid
status
duration # seconds
createdAt
jobs(statuses: [FAILED]) {
nodes {
name
duration
webPath
stage { name }
}
}
}
}
}
}
# Send the query with curl; the query text is kept in a file and wrapped into JSON by jq
jq -n --rawfile q failed_jobs.graphql \
'{query: $q, variables: {fullPath: "acme/shop/api"}}' |
curl --silent --request POST "https://gitlab.example.com/api/graphql" \
--header "Authorization: Bearer $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data @- |
jq -r '.data.project.pipelines.nodes[]
| .iid as $p | .jobs.nodes[]
| "#\($p)\t\(.stage.name)\t\(.name)\t\(.duration)s"'
Mutations. Changes are made with mutations. They identify objects with global IDs of the form gid://gitlab/<Class>/<id>, which queries return in their id fields:
mutation {
pipelineRetry(input: { id: "gid://gitlab/Ci::Pipeline/98765" }) {
pipeline { id status }
errors # always check: mutations report errors here
}
}
CI-related mutations include pipelineRetry, pipelineCancel, pipelineDestroy, jobPlay, jobRetry, jobCancel, pipelineScheduleCreate and runnerCreate. A mutation can return a successful HTTP response and still fail, so always read its errors field.
Limits. GitLab limits each query's complexity (roughly, how many objects and fields it may touch) and returns at most 100 nodes per page. Deeply nested queries over many pipelines may be rejected. Request fewer fields, reduce first, or split the query, and paginate with pageInfo.endCursor passed as after.
REST or GraphQL?
| Question | REST | GraphQL |
|---|---|---|
| Fetching nested data (pipelines with jobs and stages) | Many requests | One request |
| Coverage of features | Most complete | Good for CI, still incomplete in some areas |
| Changing things | Straightforward | Mutations with global IDs |
| Learning curve and tooling | curl is enough | Explorer helps; queries are more verbose |
| Caching and simple scripts | Easier | Harder |
A practical rule is to use GraphQL for reading complex, nested data in reports and dashboards, and REST for scripts that change state.
Key takeaways
- GraphQL returns exactly the nested data a query asks for, in one request, which makes it ideal for pipeline reports and dashboards.
- Build queries in the GraphQL explorer, paginate with
pageInfoandafter, and stay within the complexity limit. - Mutations use global IDs (
gid://gitlab/Ci::Pipeline/123) and report failures in theirerrorsfield. - Use GraphQL to read nested data and REST to change state, unless one of them lacks the field or action you need.
Practice
- (Beginner) In the GraphQL explorer, write a query that returns the status and duration of the last 5 pipelines of a project.
- (Intermediate) Write a script that pages through 200 pipelines on
mainwith GraphQL and prints the five jobs that failed most often, with their stage. Compare the number of HTTP requests with a REST-based version.- Interview question: "Why might a dashboard built on GraphQL suddenly start receiving errors after someone adds three fields to its query?" Hint: query complexity limits, page size, and how to split the query.
glab CLI
Why it matters. Switching to the browser to check a pipeline, retry a job or read a log interrupts work. glab, GitLab's official command-line tool, brings these actions to the terminal and knows the project of the current Git repository, so most commands need no project ID. It is equally useful in CI jobs and short scripts, where it replaces hand-written curl calls.
Setting up.
# Authenticate against a self-managed instance (opens a browser or asks for a token)
glab auth login --hostname gitlab.example.com
glab auth status
# Inside a CI job: authenticate with the job token
glab auth login --job-token "$CI_JOB_TOKEN" --hostname "$CI_SERVER_HOST" --api-protocol "$CI_SERVER_PROTOCOL"
Pipeline commands.
| Command | What it does |
|---|---|
glab ci status | Status of the latest pipeline on the current branch (--live keeps refreshing) |
glab ci view [branch] | Interactive view of the pipeline's jobs: navigate, read logs, retry, start manual jobs |
glab ci list --status failed | List pipelines of the project |
glab ci run --branch main --variables KEY:value | Create a pipeline, optionally with variables |
glab ci trace [job-id] | Stream the log of a job |
glab ci retry <job-id> | Retry a job |
glab ci lint [file] | Validate a CI configuration file (section 1.3) |
glab ci delete <pipeline-id> | Delete a pipeline |
glab job artifact <ref> <job> | Download the artifacts of a job's latest successful run (section 8.1) |
glab schedule list, glab schedule create, glab schedule run <id> | Manage and run pipeline schedules |
glab variable list, glab variable set, glab variable export | Manage CI/CD variables of a project or group (--group) |
Command names and flags change between glab releases, so check glab <command> --help for the version you use.
A typical terminal session.
git push origin feature/cart
glab ci status --live # watch the pipeline of the branch just pushed
glab ci view # a job failed: open the interactive view and read its log
glab ci retry 554433 # flaky job: retry it once
# Start a pipeline on main with a variable, then follow it
glab ci run --branch main --variables DEPLOY_ENV:staging
glab ci status --branch main --live
# Nightly pipeline at 02:00 in the project's time zone
glab schedule create --cron "0 2 * * *" --ref main --description "Nightly full test suite"
# A masked and protected variable, scoped to the production environment
glab variable set DEPLOY_KEY "$(cat deploy_key.txt)" --masked --protected --scope production
glab api: everything else. When no dedicated command exists, glab api sends an authenticated request to any REST or GraphQL endpoint. Placeholders such as :id and :branch are filled from the current repository:
# Cancel a pipeline through the REST API
glab api --method POST "projects/:id/pipelines/98765/cancel"
# All failed pipelines on the current branch, across every page
glab api --paginate "projects/:id/pipelines?status=failed&ref=:branch" | jq -r '.[].web_url'
# A GraphQL query
glab api graphql -f query='
query { project(fullPath: "acme/shop/api") {
pipelines(first: 3) { nodes { iid status duration } } } }'
In CI jobs. The official image registry.gitlab.com/gitlab-org/cli contains glab. A typical use is a job that comments on the merge request or creates a release (section 8.4):
comment-bundle-size:
stage: .post
image: registry.gitlab.com/gitlab-org/cli:latest # pin a version in production pipelines
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
needs: [build-web]
script:
- glab auth login --job-token "$CI_JOB_TOKEN" --hostname "$CI_SERVER_HOST" --api-protocol "$CI_SERVER_PROTOCOL"
- SIZE=$(du -sh dist | cut -f1)
- glab mr note "$CI_MERGE_REQUEST_IID" --message "Bundle size of this MR: $SIZE"
The job token gives glab only the endpoints that CI_JOB_TOKEN supports. When a command fails with a permission error inside a job, the endpoint is probably not available to job tokens, and you need a project access token stored as a masked variable instead.
Key takeaways
glabbrings pipelines to the terminal: status, interactive view, logs, retries, new pipelines, schedules and variables, without looking up project IDs.glab apireaches any REST or GraphQL endpoint with authentication, placeholders and pagination handled for you.- In CI jobs, use the official
glabimage and authenticate withCI_JOB_TOKENwhere its permissions are sufficient. - Commands and flags evolve between releases. Check
--helpfor your version.
Practice
- (Beginner) Authenticate
glabagainst your instance, push a branch, and follow its pipeline withglab ci status --live. Then read a job's log withglab ci trace.- (Intermediate) Write a shell function
ci-rerun-failedthat usesglab apito retry only the failed jobs of the latest pipeline on the current branch whosefailure_reasonisrunner_system_failure.- Interview question: "When would you use
glabin a CI job instead ofcurl, and when would you avoid it?" Hint: readability, image size and pinning, job token permissions, and stability of command output for parsing.
Managing GitLab with the Terraform Provider
Why it matters. Groups, projects, protected branches, CI/CD variables, runners and pipeline schedules are configuration too. When they are changed by hand in the web interface, nobody can say why a setting has its value, two "identical" projects drift apart, and recreating a group after a mistake is guesswork. The GitLab Terraform provider (gitlabhq/gitlab) manages this configuration as code: desired state in a repository, changes reviewed in merge requests, applied by a pipeline. It is the same idea as infrastructure as code (section 12.5), applied to GitLab itself.
What it can manage (selection).
| Area | Resources |
|---|---|
| Structure | gitlab_group, gitlab_project, gitlab_group_membership, gitlab_project_membership |
| CI/CD variables | gitlab_instance_variable, gitlab_group_variable, gitlab_project_variable |
| Runners | gitlab_user_runner (creates a runner and returns its authentication token) |
| Pipelines | gitlab_pipeline_schedule, gitlab_pipeline_schedule_variable, gitlab_pipeline_trigger, gitlab_project_job_token_scopes |
| Protection | gitlab_branch_protection, gitlab_tag_protection, gitlab_project_protected_environment |
| Compliance | gitlab_compliance_framework, gitlab_project_compliance_framework |
| Instance (administrators) | gitlab_application_settings |
Example: a standard service project.
terraform {
required_providers {
gitlab = {
source = "gitlabhq/gitlab"
version = "~> 18.0" # provider versions follow GitLab versions
}
}
backend "http" {} # GitLab-managed Terraform state (section 12.5)
}
provider "gitlab" {
base_url = "https://gitlab.example.com/api/v4/"
# The token is read from the GITLAB_TOKEN environment variable
}
data "gitlab_group" "payments" {
full_path = "acme/payments"
}
resource "gitlab_project" "checkout" {
name = "checkout"
namespace_id = data.gitlab_group.payments.id
default_branch = "main"
only_allow_merge_if_pipeline_succeeds = true
auto_cancel_pending_pipelines = "enabled"
build_timeout = 3600 # job timeout in seconds
ci_default_git_depth = 20
lifecycle {
prevent_destroy = true # a typo must not delete a real project
}
}
resource "gitlab_branch_protection" "main" {
project = gitlab_project.checkout.id
branch = "main"
push_access_level = "no one" # changes only through merge requests
merge_access_level = "maintainer"
}
resource "gitlab_project_variable" "registry_mirror" {
project = gitlab_project.checkout.id
key = "REGISTRY_MIRROR"
value = "registry-mirror.example.com"
protected = false
}
resource "gitlab_pipeline_schedule" "nightly" {
project = gitlab_project.checkout.id
description = "Nightly full test suite"
ref = "refs/heads/main" # full ref avoids ambiguity with tags
cron = "0 2 * * *"
cron_timezone = "Europe/Berlin"
}
resource "gitlab_user_runner" "payments_group" {
runner_type = "group_type"
group_id = data.gitlab_group.payments.id
description = "payments: PCI-isolated runner"
tag_list = ["payments-pci"]
untagged = false
}
output "payments_runner_token" {
value = gitlab_user_runner.payments_group.token # pass to the runner deployment
sensitive = true
}
Scaling with for_each. The real benefit appears when many projects share one definition:
locals {
services = toset(["checkout", "invoicing", "refunds", "ledger"])
}
resource "gitlab_project_job_token_scopes" "allowlist" {
for_each = local.services
project = "acme/payments/${each.key}"
target_group_ids = [data.gitlab_group.payments.id] # only projects in this group may use their job tokens
}
Running it. Keep the configuration in a dedicated project (for example platform/gitlab-config), plan in merge requests and apply on the default branch with a manual job, exactly like the infrastructure pipelines in section 12.5. The token is a group access token with the api scope and the Owner role for group-level resources, or an administrator's token for instance settings. Store it as a masked, protected variable.
Adopting existing configuration. Most organizations start with years of hand-made settings. Bring them under management gradually with import blocks (Terraform 1.5 and later, or OpenTofu), then run plan until it shows no changes:
import {
to = gitlab_project.checkout
id = "acme/payments/checkout" # existing project, now managed by Terraform
}
Pitfalls.
| Pitfall | Consequence | Mitigation |
|---|---|---|
Secret values in gitlab_*_variable resources | Secrets are stored in plain text in the Terraform state | Restrict access to the state, or keep secrets in a secrets manager and use OIDC (section 5.4) |
| Editing managed settings in the web interface | The next apply silently reverts the change | Document which settings are managed, and run plan on a schedule to detect drift |
| Managing everything from one state | Slow plans, API rate limits, one mistake affects everything | Split states per group or domain |
| A powerful token in the pipeline | Anyone who can change the pipeline gains that power | Protected branches and variables, CODEOWNERS, and the smallest role that works |
| Resource deletion on rename | Renaming a resource address destroys and recreates it | moved blocks, prevent_destroy on projects and groups |
Key takeaways
- The
gitlabhq/gitlabprovider manages GitLab configuration as code: groups, projects, protections, variables, runners, schedules, compliance frameworks and instance settings. - Use
for_eachto apply one standard to many projects, andimportblocks to adopt existing configuration gradually. - Run it from a dedicated project with plan-on-MR and manual apply, using a narrowly scoped token.
- Watch for secrets in the state, drift from manual edits, and destructive renames. Use
prevent_destroyandmovedblocks.
Practice
- (Beginner) Create a test project with the provider, add a protected
mainbranch and a pipeline schedule, and verify both in the web interface.- (Intermediate) Import three existing projects with
importblocks, apply a common baseline (merge only if the pipeline succeeds, auto-cancel, job timeout) withfor_each, and set up a scheduled pipeline that runsterraform plan -detailed-exitcodeto detect drift.- Interview question: "Your platform team manages 400 projects with Terraform, and teams complain that their settings 'keep changing back'. What is happening, and how do you resolve it organizationally and technically?" Hint: ownership of each setting, drift,
ignore_changes, and moving some settings out of Terraform.
<a id="154-migrating-to-gitlab-cicd"></a>
15.4 Migrating to GitLab CI/CD
Few organizations start with GitLab CI/CD on a blank page. Most arrive with years of pipelines in Jenkins, GitHub Actions, CircleCI or Azure DevOps. Translating the syntax is the easy part. The real work is translating concepts: the old system's plugins, marketplace actions and implicit behaviors, and moving hundreds of projects without stopping delivery. This subchapter maps each major platform's concepts to GitLab's, shows a typical pipeline before and after, lists the traps specific to each platform, and ends with strategies for migrating gradually and safely.
Every migration in this subchapter follows the same translation process:
1. Inventory 2. Map concepts 3. Rebuild 4. Verify and switch
┌───────────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌───────────────────────┐
│ jobs, plugins │ ──► │ stages, triggers │ ──► │ .gitlab-ci.yml using │ ──► │ run both, compare, │
│ secrets, envs │ │ secrets, caches, │ │ templates/components │ │ switch required │
│ agents, deps │ │ artifacts, gates │ │ (not 1:1 copies) │ │ checks, retire old CI │
└───────────────┘ └──────────────────┘ └──────────────────────┘ └───────────────────────┘
AI assistants, including GitLab Duo Chat, can produce a useful first draft of a translated file. Treat that draft like any other unreviewed code: check each job's triggers, secrets, caches and artifacts against the concept tables below.
Migrating from Jenkins
Why it is different. Jenkins is a general automation server with a large plugin ecosystem. A Jenkinsfile is Groovy code, and many Jenkins pipelines contain real programming: loops, conditions, shared libraries and calls into plugins. Jenkins agents are often long-lived machines with tools preinstalled and workspaces that persist between builds. GitLab CI/CD is declarative YAML, every job usually starts in a fresh container, and the only state that passes between jobs is what you declare as artifacts or cache. Migrating from Jenkins therefore means two things: translating syntax, and moving logic and hidden state out of the pipeline definition.
Concept map.
| Jenkins | GitLab CI/CD | Notes |
|---|---|---|
Jenkinsfile (declarative or scripted) | .gitlab-ci.yml | Scripted pipelines need the most rework |
agent { docker { image ... } }, agent { label 'x' } | image:, tags: | Tools come from images, not from preinstalled agents |
stage / steps / sh | stage:, jobs, script: | A Jenkins stage often becomes one GitLab job |
parallel { ... } | Several jobs in the same stage, parallel:matrix | GitLab jobs run in parallel by default |
matrix { axes { ... } } | parallel:matrix | |
environment { X = 'y' } | variables: | |
credentials('id') and withCredentials | Masked, protected CI/CD variables (file type for files and keys), or external secrets (section 5.4) | |
when { branch 'main' }, when { expression { ... } } | rules: | |
input | when: manual, protected environments with approvals | |
post { always / success / failure } | after_script:, jobs with when: always / on_failure, notifications (section 14.3) | |
stash / unstash, archiveArtifacts | artifacts: with needs: or dependencies: | |
junit '...' | artifacts:reports:junit | |
options { timeout(...) }, retry(...) | timeout:, retry: | |
buildDiscarder(logRotator(...)) | artifacts:expire_in, pipeline cleanup (section 15.1) | |
triggers { cron(...) } | Pipeline schedules | |
parameters { ... } | spec:inputs, or variables with description (shown in the Run pipeline form) | |
lock('resource') | resource_group: | |
Upstream and downstream jobs (build job: ...) | Multi-project pipelines (trigger:) (section 3.4) | |
| Shared libraries | CI/CD components, includes, and scripts in images | Groovy logic must be rewritten |
| Multibranch pipeline | Default behavior | Every branch and merge request runs its pipeline |
Before and after.
// Jenkinsfile
pipeline {
agent { docker { image 'maven:3.9-eclipse-temurin-21' } }
options { timeout(time: 30, unit: 'MINUTES') }
triggers { cron('H 2 * * *') }
environment {
NEXUS = credentials('nexus-deploy') // sets NEXUS_USR and NEXUS_PSW
}
stages {
stage('Build') {
steps { sh 'mvn -B package -DskipTests' }
}
stage('Test') {
parallel {
stage('Unit') {
steps { sh 'mvn -B test' }
post { always { junit 'target/surefire-reports/*.xml' } }
}
stage('Lint') {
steps { sh 'mvn -B checkstyle:check' }
}
}
}
stage('Publish') {
when { branch 'main' }
steps {
input message: 'Publish to Nexus?'
sh 'mvn -B deploy -s settings.xml'
}
}
}
post { failure { mail to: 'team@example.com', subject: "Build failed: ${env.JOB_NAME}" } }
}
# .gitlab-ci.yml
default:
image: maven:3.9-eclipse-temurin-21
timeout: 30m
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
# NEXUS_USR and NEXUS_PSW: masked, protected CI/CD variables in the project settings
cache: # Jenkins kept ~/.m2 on the agent; here it must be declared
key:
files: [pom.xml]
paths: [.m2/repository/]
stages: [build, test, publish]
build:
stage: build
script: mvn -B package -DskipTests
artifacts:
paths: [target/*.jar]
expire_in: 1 week
unit-tests:
stage: test
script: mvn -B test
artifacts:
when: always
reports:
junit: target/surefire-reports/*.xml
lint:
stage: test
script: mvn -B checkstyle:check
publish:
stage: publish
script: mvn -B deploy -s settings.xml
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # replaces "input"
The nightly cron trigger becomes a pipeline schedule, and the failure email becomes a notification setting or a chat integration (section 14.3). Neither belongs in the YAML.
Jenkins-specific traps.
- Hidden state on agents. Jenkins builds often depend on tools installed on the agent, on files left by previous builds, or on a warm local Maven or npm cache. In GitLab, put tools in the image, declare caches, and pass files between jobs as artifacts.
- Groovy logic. Loops and conditions in a
Jenkinsfilecannot be translated literally. Move logic into scripts in the repository (make, shell or Python), or generate configuration with a dynamic child pipeline (section 3.3) when the set of jobs must be computed. - Plugins. List every plugin the pipelines use. Most map to a CLI tool in an image, a GitLab feature (test reports, environments, notifications) or a component. A few have no equivalent and need a decision.
- Credentials. Jenkins credentials cannot be exported in clear text in a supported way, and they should not be. Recreate them in GitLab or, better, move them to a secrets manager and use OIDC.
Coexistence. During the migration, GitLab can host the code while Jenkins still builds it. The Jenkins integration in GitLab triggers Jenkins jobs on pushes and merge requests, and the GitLab plugin for Jenkins reports the results back as commit statuses (section 14.3). Merge requests then show Jenkins results while projects move over one by one.
Key takeaways
- Jenkins pipelines mix configuration and code. Translate the configuration, and move the code into scripts or dynamic child pipelines.
- Replace agent state with images, declared caches and artifacts. Each GitLab job starts clean.
- Inventory plugins and credentials first: they are where most of the effort hides.
- Use the Jenkins integration and commit statuses to run both systems side by side during the transition.
Practice
- (Beginner) Translate a declarative
Jenkinsfilewith build, test and a manual deploy stage into.gitlab-ci.yml, and compare the number of lines and the run time.- (Intermediate) Take a scripted
Jenkinsfilethat loops over a list of services to build each one. Rebuild it in GitLab withparallel:matrix, then with a dynamic child pipeline, and explain when each approach is the better fit.- Interview question: "Your Jenkins installation has 140 plugins and a shared library of 8,000 lines of Groovy. How do you estimate the migration effort?" Hint: inventory actual plugin and library usage per pipeline, group projects by pattern, and identify what becomes components.
Migrating from GitHub Actions
Why it is different. GitHub Actions and GitLab CI/CD are both YAML-based and look similar, which makes the migration seem trivial. The important differences are structural. A GitHub repository can have many workflow files, each with its own triggers. GitLab has one pipeline definition per project (which can include others). Much of a typical workflow consists of uses: steps that call marketplace actions, and those have no direct equivalent: each one must be replaced by a command, an image or a component. Also, a GitHub job is a sequence of steps on one runner, while in GitLab the job's script is the sequence and the job runs in an image you choose.
Concept map.
| GitHub Actions | GitLab CI/CD |
|---|---|
.github/workflows/*.yml (several files) | .gitlab-ci.yml, with include: or child pipelines to split it |
on: push / pull_request | rules: with $CI_COMMIT_BRANCH / $CI_PIPELINE_SOURCE == "merge_request_event" |
on: schedule | Pipeline schedules, with $CI_PIPELINE_SOURCE == "schedule" in rules |
on: workflow_dispatch with inputs | Run pipeline form or API, with spec:inputs |
paths: filters | rules:changes: |
runs-on: | tags: (and image: for the environment) |
steps: with run: | script: |
uses: actions/checkout | Not needed: the runner clones the repository |
uses: <marketplace action> | A CLI command in an image, or a CI/CD component |
needs: | needs: |
strategy.matrix | parallel:matrix |
if: | rules: |
env: | variables: |
secrets.X | Masked, protected CI/CD variables, or external secrets |
environment: with protection rules | environment: with protected environments and approvals |
concurrency: | resource_group:, interruptible: with auto-cancel |
actions/cache | cache: |
actions/upload-artifact / download-artifact | artifacts: with needs: |
services: / container: | services: / image: |
| Reusable workflows, composite actions | CI/CD components, include: |
GITHUB_TOKEN | CI_JOB_TOKEN |
permissions: id-token: write (OIDC) | id_tokens: |
| Context value | Predefined variable |
|---|---|
${{ github.sha }} | $CI_COMMIT_SHA |
${{ github.ref_name }} | $CI_COMMIT_REF_NAME |
${{ github.event_name }} | $CI_PIPELINE_SOURCE (values differ) |
${{ github.repository }} | $CI_PROJECT_PATH |
${{ github.run_id }} | $CI_PIPELINE_ID |
${{ github.actor }} | $GITLAB_USER_LOGIN |
${{ github.workspace }} | $CI_PROJECT_DIR |
Before and after.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
# .gitlab-ci.yml
workflow:
rules: # "on: pull_request" and "on: push to main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
stages: [test, deploy]
test:
stage: test
image: node:${NODE_VERSION} # replaces actions/setup-node
parallel:
matrix:
- NODE_VERSION: ["20", "22"]
cache: # replaces "cache: npm"
key:
files: [package-lock.json]
paths: [.npm/]
script: # no checkout step: the runner clones the repository
- npm ci --cache .npm --prefer-offline
- npm test
deploy:
stage: deploy
image: node:22
environment: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- ./deploy.sh # API_TOKEN: masked variable, protected, scoped to "production"
GitHub-specific traps.
- Marketplace actions. Inventory every
uses:line across all workflows. For each one, find the CLI tool it wraps (most actions are thin wrappers), an existing component in the CI/CD Catalog, or a GitLab feature (releases, test reports, environments). This is usually the largest part of the work. - Several workflows, one pipeline. Workflows with different triggers become jobs with different
rules, or separate child pipelines. Useworkflow:rulesto avoid duplicate pipelines for branches with an open merge request. - Event semantics.
pull_requestin GitHub runs on a merge commit of the PR. GitLab merge request pipelines run on the source branch by default, and merged results pipelines (Premium) run on the merge result (section 3.1). - Secrets cannot be exported. GitHub never reveals secret values. Recreate them in GitLab from their source, and use the opportunity to move to OIDC where possible.
Moving the rest of the project. GitLab's GitHub importer brings over the repository, issues, pull requests (as merge requests), wikis and releases. It does not convert workflows or copy secrets. If the code must stay on GitHub for a while, CI/CD for external repositories (Premium) runs GitLab pipelines for a GitHub repository and reports statuses back to it (section 14.3).
Key takeaways
- The YAML looks similar, but GitHub workflows are many files of steps calling actions, while GitLab has one pipeline of jobs running scripts in images.
- Replace each marketplace action with a CLI tool, a component or a built-in GitLab feature. Checkout steps disappear.
- Map triggers to
rulesandworkflow:rules, contexts to predefined variables,GITHUB_TOKENtoCI_JOB_TOKEN, and OIDC permissions toid_tokens. - The GitHub importer moves code, issues and pull requests but not workflows or secrets.
Practice
- (Beginner) Translate a GitHub workflow with a test matrix and a cache into
.gitlab-ci.yml, and verify that merge request pipelines and default branch pipelines behave like the original triggers.- (Intermediate) Take a repository with three workflows (CI on pull requests, nightly security scan, release on tags) and merge them into one GitLab configuration using
rulesandworkflow:rules. Confirm that each trigger runs only its own jobs.- Interview question: "A repository uses 25 different marketplace actions. How do you approach replacing them, and which ones worry you most?" Hint: thin wrappers versus actions with real logic, actions that use the GitHub API, and security review of what you adopt instead.
Migrating from CircleCI
Why it is different. CircleCI's model is close to GitLab's: YAML, Docker images as execution environments, jobs connected by workflows. The differences lie in how state moves between jobs (workspaces), in reusable packages (orbs), in how secondary containers are reached, and in the built-in test splitting. Most CircleCI configurations translate cleanly once these four are understood.
Concept map.
| CircleCI | GitLab CI/CD | Notes |
|---|---|---|
.circleci/config.yml | .gitlab-ci.yml | |
executors, docker: - image: | image:, default: | |
Secondary images in docker: | services: | Reached by hostname, not localhost (see traps) |
resource_class | Runner tags: (on GitLab.com: hosted runner sizes) | |
jobs with steps / run | Jobs with script: | |
checkout | Not needed | |
workflows with requires | stages: and needs: | |
filters: branches / tags | rules: | |
type: approval job | when: manual | |
persist_to_workspace / attach_workspace | artifacts: with needs: | |
save_cache / restore_cache with checksum keys | cache: with key:files: | |
store_artifacts / store_test_results | artifacts: / artifacts:reports:junit | |
parallelism + circleci tests split | parallel: N with $CI_NODE_INDEX and $CI_NODE_TOTAL | Split tests with the test tool's own sharding |
| Orbs | CI/CD components | |
| Contexts | Group-level CI/CD variables, protected and environment-scoped | |
| Pipeline parameters | spec:inputs | |
| Dynamic configuration (setup workflows) | Dynamic child pipelines | |
| Scheduled pipelines | Pipeline schedules |
Before and after.
# .circleci/config.yml
version: 2.1
executors:
node-pg:
docker:
- image: cimg/node:22.4
- image: cimg/postgres:16.3
environment:
POSTGRES_PASSWORD: test
jobs:
build:
executor: node-pg
steps:
- checkout
- restore_cache:
keys: [deps-{{ checksum "package-lock.json" }}]
- run: npm ci
- save_cache:
key: deps-{{ checksum "package-lock.json" }}
paths: [~/.npm]
- run: npm run build
- persist_to_workspace:
root: .
paths: [dist]
test:
executor: node-pg
parallelism: 4
steps:
- checkout
- run: npm ci
- run: DATABASE_URL=postgres://postgres:test@localhost:5432/postgres npx jest $(circleci tests glob "test/**/*.test.js" | circleci tests split --split-by=timings)
- store_test_results:
path: reports
deploy:
executor: node-pg
steps:
- checkout
- attach_workspace:
at: .
- run: ./deploy.sh
workflows:
main:
jobs:
- build
- test:
requires: [build]
- hold:
type: approval
requires: [test]
filters:
branches:
only: main
- deploy:
requires: [hold]
context: production
# .gitlab-ci.yml
default:
image: node:22.4
stages: [build, test, deploy]
.npm-cache:
cache:
key:
files: [package-lock.json] # same idea as the checksum key
paths: [.npm/]
build:
stage: build
extends: .npm-cache
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts: # replaces persist_to_workspace
paths: [dist/]
expire_in: 1 day
test:
stage: test
extends: .npm-cache
parallel: 4 # replaces parallelism: 4
services:
- name: postgres:16.3
alias: postgres
variables:
POSTGRES_PASSWORD: test
DATABASE_URL: postgres://postgres:test@postgres:5432/postgres # hostname, not localhost
script:
- npm ci --cache .npm --prefer-offline
- npx jest --shard="$CI_NODE_INDEX/$CI_NODE_TOTAL" --reporters=default --reporters=jest-junit
artifacts:
when: always
reports:
junit: junit.xml # jest-junit's default output file
deploy:
stage: deploy
needs: [build, test] # build provides dist/ (replaces attach_workspace)
environment: production # "context: production" -> variables scoped to this environment
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual # replaces the approval job
script:
- ./deploy.sh
CircleCI-specific traps.
localhostversus service hostnames. CircleCI's secondary containers share the network of the primary container, so services are reached onlocalhost. With GitLab's Docker executor, services are reached through their alias (postgresabove). With the Kubernetes executor they run in the same pod and are onlocalhostagain. Make the host configurable through a variable.- Test splitting by timings.
circleci tests split --split-by=timingsbalances shards using stored timing data. GitLab provides the shard number and count. Use the test tool's own sharding (jest --shard,pytest-split,knapsack), and accept that the shards may be less balanced until timing data is in place. - Orbs. Like GitHub actions, each orb must be replaced by commands, images or components. Look at the orb's source to see which commands it actually runs.
- Convenience images.
cimg/*images can be used in GitLab, but they run as a non-root user and expect CircleCI's layout. Official images (node,postgres) are usually simpler.
Key takeaways
- CircleCI maps closely to GitLab: executors to images, workflows to stages and
needs, workspaces to artifacts, approval jobs to manual jobs, contexts to group variables. - Secondary containers become
services, reached by hostname on the Docker executor instead oflocalhost. - Replace
circleci tests splitwithparallel: Nand the test tool's own sharding based onCI_NODE_INDEXandCI_NODE_TOTAL. - Replace orbs with components or plain commands after reading what each orb does.
Practice
- (Beginner) Translate a CircleCI configuration with a workspace and an approval job into GitLab, and check that the deploy job receives the build output.
- (Intermediate) Migrate a test job with
parallelism: 4and timing-based splitting. Implement sharding in GitLab withparallel: 4, then compare the duration of the slowest shard with the CircleCI run and propose how to rebalance it.- Interview question: "After migrating from CircleCI, every integration test fails with 'connection refused' on port 5432, but only on the Docker runners and not on the Kubernetes runners. Why?" Hint: how each executor networks services, and where
localhostis hard-coded.
Migrating from Azure DevOps
Why it is different. Azure DevOps Pipelines has two generations: the older classic pipelines, built in a web editor and stored as JSON, and YAML pipelines. Classic build and release pipelines have no YAML to translate and must be rebuilt from their documented behavior. YAML pipelines translate well, but they rely heavily on tasks (packaged steps such as DotNetCoreCLI@2 or AzureWebApp@1), service connections for credentials, variable groups and environments with checks and approvals. Each of these has a GitLab counterpart with a different shape.
Concept map.
| Azure DevOps | GitLab CI/CD | Notes |
|---|---|---|
azure-pipelines.yml | .gitlab-ci.yml | |
trigger: / pr: | rules:, workflow:rules: | |
pool: vmImage: / self-hosted agent pools | image: and runner tags: | |
stages / jobs / steps | stages: / jobs / script: | An ADO job with many steps usually becomes one GitLab job |
script:, bash:, pwsh: | script: | Choose an image with the right shell |
Tasks (DotNetCoreCLI@2, AzureWebApp@1, ...) | CLI commands (dotnet, az) or components | |
dependsOn: | needs: | |
condition: | rules:, when: | |
| Runtime parameters | spec:inputs | |
variables:, variable groups | variables:, group-level CI/CD variables | |
| Key Vault-linked variable groups | External secrets (section 5.4) | |
| Library secure files | Project secure files | |
| Service connections | OIDC with id_tokens: (workload identity federation), or variables | |
| Environments with approvals and checks | Environments, protected environments with deployment approvals (Premium) | |
Deployment jobs (strategy: runOnce, canary, rolling) | Jobs with environment:, deployment patterns (section 12.3) | |
template: / extends: | include:, CI/CD components with inputs | |
PublishPipelineArtifact / DownloadPipelineArtifact | artifacts: with needs: | |
PublishTestResults | artifacts:reports:junit | |
$(Build.SourceVersion), $(Build.SourceBranchName), $(Build.BuildId) | $CI_COMMIT_SHA, $CI_COMMIT_REF_NAME, $CI_PIPELINE_ID | $(var) macro syntax becomes $VAR |
$(System.PullRequest.PullRequestId) | $CI_MERGE_REQUEST_IID | |
Multiple repository checkout (resources: repositories) | Clone with CI_JOB_TOKEN and the job token allowlist, or submodules |
Before and after.
# azure-pipelines.yml
trigger:
branches:
include: [main]
pr:
branches:
include: ['*']
pool:
vmImage: ubuntu-latest
variables:
buildConfiguration: Release
stages:
- stage: Build
jobs:
- job: Build
steps:
- task: UseDotNet@2
inputs:
version: 8.x
- script: dotnet test --configuration $(buildConfiguration) --logger trx
- task: PublishTestResults@2
inputs:
testResultsFormat: VSTest
testResultsFiles: '**/*.trx'
- script: dotnet publish -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)
- publish: $(Build.ArtifactStagingDirectory)
artifact: app
- stage: Deploy
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: Production
environment: production # approvals configured on the environment
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
azureSubscription: prod-service-connection
appName: shop-api
package: $(Pipeline.Workspace)/app
# .gitlab-ci.yml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # "pr:"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # "trigger: main"
stages: [build, deploy]
variables:
BUILD_CONFIGURATION: Release
build:
stage: build
image: mcr.microsoft.com/dotnet/sdk:8.0 # replaces UseDotNet@2
script:
# JUnit output needs the JunitXml.TestLogger package in the test project
- dotnet test --configuration "$BUILD_CONFIGURATION" --logger "junit;LogFilePath=$CI_PROJECT_DIR/reports/{assembly}.xml"
- dotnet publish -c "$BUILD_CONFIGURATION" -o publish/
- apt-get update -qq && apt-get install -y -qq zip # or use a build image that already has zip
- (cd publish && zip -qr ../app.zip .)
artifacts:
paths: [app.zip]
reports:
junit: reports/*.xml # replaces PublishTestResults@2
deploy-production:
stage: deploy
image: mcr.microsoft.com/azure-cli:2.63.0
environment: production # protected environment with approvals (Premium)
id_tokens:
AZURE_ID_TOKEN:
aud: api://AzureADTokenExchange # replaces the service connection
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- az login --service-principal --username "$AZURE_CLIENT_ID" --tenant "$AZURE_TENANT_ID" --federated-token "$AZURE_ID_TOKEN"
- az webapp deploy --resource-group shop --name shop-api --src-path app.zip --type zip
The Azure side needs a federated credential on the app registration (or managed identity) that trusts your GitLab instance as issuer and the project and branch as subject, for example project_path:acme/shop-api:ref_type:branch:ref:main.
Azure DevOps-specific traps.
- Classic pipelines. There is nothing to translate mechanically. Export the definitions as JSON for reference, document what each step does, and rebuild the pipeline in YAML. Release pipelines with several stages and approvals map to deployment jobs on protected environments.
- Tasks hide commands. A task such as
DotNetCoreCLI@2orAzureCLI@2adds behavior such as authentication, default arguments and result publishing. Read the task's documentation to see what it does beyond running the CLI. - Approvals and checks. Azure DevOps attaches checks (approvals, business hours, required templates) to environments and resources. In GitLab, approvals belong to protected environments, deployment freezes cover time windows, and required configuration is enforced with policies (section 15.2).
- Beyond pipelines. Azure Repos can be imported into GitLab by URL with an access token. Work items in Azure Boards need a separate migration plan.
Key takeaways
- YAML pipelines translate directly. Classic pipelines must be rebuilt from their documented behavior.
- Replace tasks with CLI commands in suitable images, and service connections with OIDC
id_tokensand federated credentials. - Variable groups become group-level variables or external secrets, and environment checks become protected environments with approvals and deployment freezes.
- Translate
$(Macro)variables to GitLab predefined variables, and plan separately for repositories that check out several repos and for Boards.
Practice
- (Beginner) Translate an
azure-pipelines.ymlwith build and test steps for a .NET project into.gitlab-ci.yml, including JUnit test reports.- (Intermediate) Configure workload identity federation between your GitLab project and an Azure app registration, and deploy to a test web app without storing any client secret in GitLab.
- Interview question: "Your company has 300 classic release pipelines in Azure DevOps with manual approvals per stage. How would you plan their migration?" Hint: group them by pattern, one standardized deployment template, protected environments, and who approves in GitLab.
Incremental Migration Strategies
Why it matters. A migration that moves every project at once on a fixed date carries all its risk on that day. If something breaks (a missing secret, a different cache behavior, a deployment that runs twice), it breaks everywhere at once, while teams are also trying to ship their own work. Incremental migration spreads the risk: a small number of projects move at a time, both systems run in parallel while results are compared, and each step can be rolled back. It works like replacing the engines of an aircraft one at a time while it keeps flying.
Phases.
Phase 0 Phase 1 Phase 2 Phase 3 Phase 4
Prepare Pilot Waves Cutover per project Decommission
───────── ────────── ───────────────── ─────────────────── ──────────────
inventory 2-5 projects groups of 10-50 switch required disable old jobs
runners, of different projects, simplest checks, enable revoke old
secrets, types, build first; templates GitLab deploys, credentials,
templates the templates reused, not invented disable old deploys archive configs
Phase 0: inventory and foundations. Before migrating the first project, collect facts and build the shared pieces:
| Inventory item | Why |
|---|---|
| Number of pipelines and their patterns (language, build tool, deploy target) | Group projects into waves and decide which templates to build |
| Plugins, actions, orbs or tasks in use, with counts | Each one needs a replacement |
| Secrets and where they are used | Recreate them in GitLab or move them to a secrets manager |
| Agents and their special hardware or tools | Size and tag the runner fleet (section 15.2) |
| Deployment targets and their credentials | Plan OIDC trust and protected environments |
| Owners and contacts for each project | Schedule waves with the teams, not around them |
The runner fleet, the secrets approach and the first templates should exist before the pilot. Otherwise every pilot team solves these problems on its own, differently.
Coexistence patterns. While a project moves, the old and the new system have to live side by side:
| Pattern | How it works | Good for |
|---|---|---|
| Old CI, GitLab code | GitLab hosts the repository and triggers the old CI. The old CI reports back with commit statuses (section 14.3) | Moving source control first, CI later |
| GitLab CI, external code | Code stays on GitHub or another host, and GitLab runs the pipelines (CI/CD for external repositories, Premium) | Moving CI first, source control later |
| Shadow mode | Both systems run on every change. The GitLab pipeline is not yet required, and deployment jobs are disabled | Verifying parity before the switch |
| Strangler by stage | GitLab takes over build and test first, the old CI keeps deployment until GitLab's deployment path is trusted | Projects with complex or risky deployments |
Shadow mode in practice. In shadow mode, the GitLab pipeline must not deploy, and its failures must not block anyone. A migration flag, set as a project or group variable, does both:
# During shadow mode: MIGRATION_PHASE = "shadow" (group variable). At cutover: "live".
.deploy-guard:
rules:
- if: $MIGRATION_PHASE == "shadow"
when: never # never deploy while the old system is authoritative
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-production:
extends: .deploy-guard
stage: deploy
environment: production
script:
- ./deploy.sh
Compare both systems for one or two weeks: success rate, duration, test counts (a lower number of tests in GitLab usually means a missing test step, not faster code) and produced artifacts. The REST and GraphQL scripts from section 15.3 and the analytics from section 14.2 provide the GitLab side of the comparison.
Cutover checklist for one project.
- Shadow pipelines have matched the old system for an agreed period, with equal test counts.
- All secrets exist in GitLab (or the secrets manager), and nothing in the pipeline still reaches the old system's credentials.
- Protected branches, protected environments, approvals and "Pipelines must succeed" are configured.
- Set
MIGRATION_PHASEtolive, and in the same change window disable deployments in the old system. Two systems deploying the same application is the most dangerous failure of a migration. - Make the GitLab pipeline the required check, and remove the old one.
- Keep the old configuration, read-only, for a rollback period (for example two weeks). Rolling back means re-enabling it and setting the flag back to
shadow. - After the rollback period, delete the old jobs, revoke their credentials and archive their configuration.
Order the waves. Start with projects that are simple and representative, so that the templates built for them are reused by many later projects. Leave the most complex projects (heavy plugin use, unusual hardware, critical deployments) for later waves, when the templates and the team's experience are mature. Track progress publicly, with a simple dashboard of projects per state: not started, shadow, live, decommissioned.
People, not just pipelines. Migrations stall when teams see them as extra work imposed from outside. Short training sessions, a migration guide with before-and-after examples for your own common patterns, office hours, and a few "champions" in product teams who have already migrated do more for the timeline than any tool.
Key takeaways
- Migrate incrementally: prepare foundations, run a pilot, migrate in waves, cut over project by project, then decommission.
- Build runners, secrets handling and templates before the pilot, so that teams reuse them instead of solving the same problems separately.
- Use coexistence patterns (commit statuses, external repositories, shadow mode, strangler by stage) to keep delivery running during the move.
- At cutover, never let two systems deploy the same application. Switch deployments and required checks together, keep a rollback path, and revoke old credentials when the old system is retired.
Practice
- (Beginner) Write the inventory for three of your existing pipelines: triggers, plugins or actions, secrets, deployment targets and agents. Group them by pattern.
- (Intermediate) Run one project in shadow mode for a week using the
MIGRATION_PHASEguard. Write a script that compares success rates, durations and test counts between the old system and GitLab, and present the result as a go or no-go decision for cutover.- Interview question: "Halfway through a migration of 400 projects, a production incident is traced to a deployment that ran in both Jenkins and GitLab. What went wrong in the process, and what controls would you add?" Hint: cutover checklist, deploy guards, resource groups, credentials still valid in the old system, and who owns each cutover.