3. GitLab CI
GitLab CI
TL;DR
- GitLab CI is a pipeline system built directly into the GitLab platform — pipelines, container registry, and merge requests all live in one product, removing most of the integration glue standalone CI tools require.
- Pipelines are defined in
.gitlab-ci.yml; theneeds:keyword turns a strict stage-by-stage pipeline into a DAG (directed acyclic graph) where jobs start as soon as their actual dependencies finish. - Production secrets need both
protected(branch/tag restriction) andmasked(log redaction) set together — either alone leaves a real exposure gap. - Runner registration tokens are scoped at the group level by default, meaning any project in the group can register a runner and pick up jobs, including production deploys — protected runners close that gap.
- Biggest gotcha: a merge train advances sequentially at pipeline speed, so a slow pipeline becomes the new bottleneck the moment a merge train is enabled — pipeline speed has to be fixed first, not after.
Core Concepts
GitLab Runners and the registration-token risk
GitLab Runners execute jobs. The security model around them matters most at the registration-token level: a group-level registration token lets any project in that group register a runner and pick up its jobs — including production deployment jobs. Protected runners, restricted to only run jobs from protected branches, prevent an arbitrary feature branch from accessing the same runner (and its network access, credentials, and capabilities) as a production deploy job.
Pipelines and the DAG model
The needs: keyword separates a simple stage-by-stage pipeline from one that parallelizes intelligently. Without needs:, jobs run strictly stage-by-stage — everything in validate must finish before anything in test starts. With needs: [lint] on a test job, that job starts the moment lint finishes, regardless of what else is still running in the validate stage. On a pipeline with many loosely-dependent jobs, this DAG-based ordering can meaningfully cut total pipeline time compared to strict staging.
# .gitlab-ci.yml
stages: [validate, test, build, deploy]
lint:
stage: validate
script: golangci-lint run ./...
test:
stage: test
script: go test -race -coverprofile=coverage.out ./...
needs: [lint] # starts once lint finishes, not once the whole validate stage does
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy-staging:
stage: deploy
needs: [build]
environment: { name: staging }
script: kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
Variables: protected vs. masked
Production secrets need two settings applied together, not either alone: protected restricts the variable to protected branches/tags only, preventing an arbitrary feature branch pipeline from reading a production secret; masked hides the value from job console output. Setting only one leaves a real gap — protected-but-unmasked means the value can still leak into logs on the branches where it is available; masked-but-unprotected means any branch can access the value even if it doesn’t print visibly.
Child pipelines
Child pipelines, triggered via trigger:, solve the monorepo structure problem: a parent pipeline dynamically triggers separate, independently-structured child pipelines for a frontend and backend component based on which paths changed, rather than cramming both components’ very different pipeline logic into one enormous, tightly-coupled top-level file.
Templates
Templates, included via include:, solve duplication the same way Jenkins Shared Libraries do, with an important distinction: a template is referenced at pipeline run time via include:, not copy-pasted. Updating the template updates every project that includes it going forward, instead of requiring a manual, error-prone copy-paste sweep across every project carrying its own drifted snapshot of the “shared” logic.
Merge trains
Merge trains test merge results before actually merging, avoiding the “passed individually, breaks together” problem. The practical consequence teams often miss: the train processes merges sequentially, at the pipeline’s speed. A 30-minute pipeline means the train advances at roughly 30 minutes per merge — pipeline speed needs to be optimized before enabling a merge train, or the train itself becomes the new bottleneck.
Stages vs. needs/DAG Pipelines
| Aspect | Stage-based (default) | needs: / DAG pipeline |
|---|---|---|
| Execution order | Strict — every job in a stage waits for the entire previous stage | Jobs start as soon as their explicit needs: dependencies finish |
| Parallelism | Limited to jobs within the same stage | Can parallelize across stage boundaries |
| Configuration complexity | Simple, implicit ordering | Requires explicitly declaring each job’s real dependencies |
| Best fit | Small pipelines with genuine sequential dependencies | Larger pipelines with many loosely-coupled jobs |
Jenkins vs GitLab CI vs GitHub Actions
| Aspect | Jenkins | GitLab CI | GitHub Actions |
|---|---|---|---|
| Hosting model | Self-hosted only | SaaS (GitLab.com) or self-managed | SaaS or self-hosted runners |
| Pipeline definition | Jenkinsfile (Groovy) | .gitlab-ci.yml (YAML) |
Workflow YAML in .github/workflows |
| Native SCM integration | None — bolted on via plugins | Native — same platform as repo, registry, merge requests | Native — same platform as repo |
| Dependency graph support | Manual, via plugins or scripted logic | Native needs: DAG |
Native needs: between jobs |
| Duplication mechanism | Shared Libraries | Templates via include: |
Composite actions / reusable workflows |
| Best fit | Complex, heterogeneous toolchains needing deep customization | Teams already on GitLab wanting one integrated platform | Teams already on GitHub wanting the fastest path to CI |
Command / Configuration Reference
# Protected + masked variable (set via GitLab UI or API, not YAML directly)
# Settings > CI/CD > Variables:
# Protected: restricted to protected branches/tags
# Masked: hidden from job logs
# Child pipeline triggered from a parent pipeline
trigger-frontend:
trigger:
include: frontend/.gitlab-ci.yml
strategy: depend # parent waits for child pipeline result
rules:
- changes: [frontend/**/*]
# Including a shared template
include:
- project: 'platform/ci-templates'
file: '/templates/security-scan.yml'
# Register a runner (scoped to a project, not the group, where possible)
gitlab-runner register --url https://gitlab.com/ --registration-token <token>
Common Pitfalls
- Pitfall: A group-level runner registration token lets an unintended project’s pipeline pick up jobs meant for a more sensitive project. Why: Registration tokens are scoped at the group level by default, not per project. Fix: Use project-scoped tokens and mark production-capable runners as protected.
- Pitfall: Pipelines run strictly stage-by-stage with no
needs:DAG, leaving available parallelism unused. Why: The default pipeline model waits for the entire previous stage regardless of actual job dependencies. Fix: Addneeds:to express real dependencies and let independent jobs run concurrently. - Pitfall: A production secret is marked masked but not protected, technically accessible from any feature branch. Why: Masking only redacts log output; it does not restrict which branches can read the variable. Fix: Mark all production secrets both protected and masked.
- Pitfall: Copy-pasted
.gitlab-ci.ymllogic across many projects drifts out of sync. Why: Each project holds its own snapshot instead of a single referenced source. Fix: Extract a shared template and pull it in viainclude:. - Pitfall: A merge train is enabled on a 30-minute pipeline and merges become slower, not faster. Why: The train processes merges sequentially at pipeline speed. Fix: Optimize pipeline duration before enabling a merge train.
Interview Questions
- “Explain exactly what
needs:changes about job execution order, with a before/after example.” — Tests genuine DAG understanding versus vague familiarity with the keyword. - “A production secret variable is masked but not protected. What’s the actual exposure, precisely?” — Tests whether the two settings’ distinct protections are understood, not treated as redundant.
- “A team wants to enable a GitLab merge train on a pipeline that takes 30 minutes. What do you tell them first?” — Tests whether pipeline-speed-before-merge-train sequencing is the immediate, correct instinct.
- “How do GitLab CI templates differ operationally from copy-pasting a YAML snippet between projects?” — Tests understanding of reference-vs-copy semantics and long-term maintainability.
- “A monorepo has a frontend and backend with very different build needs. How would you structure the pipeline?” — Tests whether child pipelines via
trigger:come up as the natural answer.