GitLab CI/CD
GitLab's pitch is not "our pipeline runner is faster than yours." It's that you shouldn't need a pipeline runner, a separate source host, a separate container registry, and a separate security-scanning vendor in the first place. GitLab CI/CD is the delivery engine inside one application that also holds your git repository, your merge requests, your container and package registries, and a set of security scanners that run as ordinary pipeline jobs. This page covers the mechanics — .gitlab-ci.yml, the GitLab Runner architecture that actually executes a job, and the built-in SAST/DAST/dependency/container/secret scanning — and then puts the single-application model head-to-head against the assemble-it-yourself approach of Jenkins and GitHub Actions plus a shelf of separate tools.
Some kitchens make you buy a stove from one store, a fridge from another, and hire a separate inspector to check the wiring before you're allowed to cook. GitLab is the kitchen that arrives already built: stove, fridge, and inspector all under one roof, already talking to each other. You still have to know how to cook — nobody else writes your recipe — but you're not spending Tuesday afternoon on the phone with three different delivery companies arguing about whose truck missed the appointment.
The single-application pitch
☺ Like you're 10: Most CI tools are one piece of a kitchen; GitLab tries to be the whole kitchen, so the pieces don't have to be wired together by hand.
Start with what a "pipeline" actually needs around it in production. Code has to live somewhere with review and branch protection. Built artifacts and container images need a registry. Dependencies need to be checked against known vulnerabilities. Someone has to scan the running application, not just the source, for real security holes. And every one of those results needs to show up where a reviewer is already looking — the merge request — or nobody will act on it before it merges. Jenkins ships none of that: it is a job scheduler with a plugin marketplace, and every capability above — Git integration, a registry, SAST, dependency scanning — is a plugin or a separate product (SonarQube, Snyk, Artifactory) you install, configure, and keep patched yourself. GitHub Actions has closed some of the gap with GitHub Advanced Security (CodeQL, Dependabot, secret scanning) and GHCR as a built-in registry, but DAST, IaC scanning, license compliance, and fuzz testing are still third-party additions, not first-party pipeline templates. GitLab's answer is to fold source control, CI/CD, a container and package registry, and a set of security scanners into one product with one permissions model and one merge request UI, so the scan results, the test results, and the code review happen in the same screen instead of three browser tabs.
This is not a free lunch — it trades tool-picking flexibility for integration, and a self-managed GitLab instance is itself a system you now operate. But for a team that would otherwise be gluing Jenkins to Bitbucket to Artifactory to Snyk by hand, "fewer seams" is a real, measurable reduction in what can silently drift out of sync. See the DevOps toolchain for how this bundling tendency shows up across the industry, and CI/CD pipelines for the vendor-neutral pipeline concepts this page assumes.
GitLab Runner — the thing that actually executes a job
☺ Like you're 10: GitLab decides what to run and writes it down; a completely separate program called the Runner is what actually goes and does it, on whatever machine it happens to live on.
GitLab itself never runs your job. It parses .gitlab-ci.yml, creates a pipeline, and creates jobs — but the code that clones your repo, pulls an image, and executes script: lines is a separate open-source binary called GitLab Runner, installed wherever you want compute to exist: a laptop, a bare-metal box, a Kubernetes cluster, or a fleet of cloud VMs GitLab.com manages for you. A runner registers itself against a GitLab instance (or GitLab.com), then polls for jobs that match its tags and its visibility scope — shared (instance-wide, what GitLab.com provides by default), group (available to every project in a group), or project (locked to specific projects). A job with tags: [docker, arm64] only ever runs on a runner that was registered with both of those tags; nothing else in the system enforces that match, which is exactly why it's the single most common reason a pipeline sits at "pending" forever.
The executor is the environment a runner drops a job into, and picking the right one is most of what "operating runners" means in practice: shell runs the job directly on the runner's host (fast, but jobs share state and can leave the machine dirty); docker runs each job in a fresh, disposable container (the common default, and what makes "clean, reproducible builds" a property of the system rather than a hope); kubernetes schedules each job as a pod in a cluster, which is how most GitLab.com and large self-managed fleets scale runner capacity elastically; and docker-autoscaler (the modern replacement for the deprecated docker+machine executor, built on GitLab Runner's Fleeting plugin architecture) provisions and tears down cloud VMs on demand so idle capacity costs nothing. GitLab.com itself provides GitLab-hosted runners on Linux, Windows, and macOS out of the box, billed against a monthly compute-unit quota that varies by plan — self-managed instances get none of that for free and must register their own.
Older guides show gitlab-runner register taking a single shared registration token per project or group — anyone with that token could register a runner with access to your CI/CD variables. Recent GitLab versions replaced this with per-runner authentication tokens (the glrt-… prefix) created individually in the UI or API, each scoped and revocable on its own. If you're following a tutorial that only mentions a registration token, check it against current GitLab docs before you copy it — this is exactly the kind of detail that moves between releases.
# register a runner against a GitLab instance (self-managed or GitLab.com)
$ gitlab-runner register \
--url https://gitlab.com/ \
--token glrt-XXXXXXXXXXXXXXXXXXXX \
--executor docker \
--docker-image alpine:latest \
--tag-list "docker,linux,arm64"
$ gitlab-runner run # start polling GitLab for matching jobs
$ gitlab-runner exec docker build-job # run ONE job locally, no server round trip — the fast debug loopThe .gitlab-ci.yml you actually write
☺ Like you're 10: One file at the root of your repo lists the steps, in order, and the rules for when each one runs.
The whole pipeline is one YAML file, checked into the repo it builds. stages declares the phases and their order; every job picks one with stage: and jobs in the same stage run in parallel by default. rules is the modern way to control whether a job runs at all — it replaced the older only/except keywords, which you'll still see in older pipelines but shouldn't mix with rules in the same job. needs turns the pipeline from a strict stage-by-stage waterfall into a DAG: a job can start the moment the specific jobs it needs finish, regardless of what else in an earlier stage is still running — the single highest-leverage change for cutting pipeline wall-clock time on anything with real parallelism to exploit.
# .gitlab-ci.yml — one file, repo root, one source of truth
stages:
- build
- test
- deploy
default:
image: node:20-alpine
interruptible: true # a newer pipeline on the same ref cancels this one automatically
workflow:
rules:
# without this, a push AND its merge request each spawn a separate pipeline — the classic duplicate
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
build-job:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week # artifacts are STORED and count against quota — they are not free forever
unit-tests:
stage: test
needs: ["build-job"] # DAG edge: start as soon as build-job finishes, ignore stage order
script:
- npm run test -- --coverage
artifacts:
reports:
coverage_report: { coverage_format: cobertura, path: coverage/cobertura-coverage.xml }
deploy-staging:
stage: deploy
needs: ["unit-tests"]
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # the gate: pipeline pauses here for a human to click "deploy"
script:
- ./deploy.sh stagingTwo more keywords carry most real-world pipeline hygiene. extends is a CI-aware deep merge of one job's keys into another — the right tool for de-duplicating near-identical jobs — and it is worth distinguishing from plain YAML anchors (&name / *name), which splice text with no awareness of CI semantics and merge less predictably once jobs get complex. include pulls in YAML from elsewhere — a local path, another project, a remote URL, or one of GitLab's own maintained templates — which is exactly the mechanism the security scanners below are delivered through.
# a hidden job (leading dot) is a template — it never runs on its own
.deploy-template:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
before_script:
- kubectl config use-context $KUBE_CONTEXT
deploy-eu:
extends: .deploy-template # CI-aware merge, not a text splice
variables: { KUBE_CONTEXT: eu-prod }
script: [kubectl apply -f k8s/]
deploy-us:
extends: .deploy-template
variables: { KUBE_CONTEXT: us-prod }
script: [kubectl apply -f k8s/]cache is a best-effort speed-up: a compressible blob (typically node_modules/ or a package manager's download cache) keyed by a string you choose, shared opportunistically across pipelines and branches, allowed to miss with no error. artifacts are guaranteed: files a job promises to produce, versioned per pipeline, downloadable from the UI, and the only mechanism that reliably moves a file from one job to the next (needs pulls the artifacts of the jobs it names). Cache a dependency directory; artifact a build output, a test report, or anything a later stage or a human actually depends on existing.
Built-in security scanning
☺ Like you're 10: Instead of hiring a separate inspector, GitLab hands you inspector jobs you drop straight into the same pipeline, and the results show up right next to the code they're about.
This is the other half of the single-application pitch, and it's delivered through the exact same mechanism as everything else on this page — include:. GitLab publishes maintained CI templates for each scanner category; add the template, and the scan runs as an ordinary pipeline job whose findings post directly into the merge request as a diff of new vulnerabilities against what was already there, not a wall of every historical finding on every merge.
include: - template: Jobs/SAST.gitlab-ci.yml # static analysis of your own source - template: Jobs/Secret-Detection.gitlab-ci.yml # committed API keys, tokens, private keys - template: Jobs/Dependency-Scanning.gitlab-ci.yml # known CVEs in your lockfile's dependencies - template: Jobs/Container-Scanning.gitlab-ci.yml # known CVEs in your built image's layers - template: DAST.gitlab-ci.yml # attacks a RUNNING target, not source variables: DAST_WEBSITE: https://staging.example.com # DAST needs something live to point at SAST_EXCLUDED_PATHS: "spec, test, tests, tmp"
Six categories cover most of what teams reach for third-party tools to do elsewhere: SAST scans your own source for insecure patterns without running it; Secret Detection catches committed credentials, and can also run as a push-time check that blocks a secret from ever reaching the repository rather than only flagging it after the fact; Dependency Scanning checks your lockfile's packages against the GitLab Advisory Database for known CVEs; Container Scanning does the same for the layers of an image you've built; DAST is different in kind from the other four — it attacks a genuinely running application (a review app or a staging URL), the way an external attacker would, and so it catches classes of bug static analysis structurally cannot; and IaC Scanning applies SAST-style rules to Terraform, CloudFormation, Kubernetes manifests, and Dockerfiles, catching a public S3 bucket or a container running as root before it's ever applied. License Compliance and coverage-guided/API fuzz testing round out the set for teams that need them.
Which of these scanners are available on GitLab Free versus gated to Premium or Ultimate has changed more than once, and the underlying open-source engines behind individual analyzers shift too (GitLab has, at various points, consolidated SAST analysis behind a Semgrep-based engine and container scanning behind Trivy). Don't design a security gate around "X is free" from a blog post, a training course, or this page — check GitLab's current pricing and feature-availability pages before you commit a team to a specific tier.
Findings live in a project- or group-level Vulnerability Report with a triage workflow — Detected, Confirmed, Dismissed, Resolved — so a scan producing noise doesn't have to mean re-litigating the same false positive on every future pipeline. See Shift-Left Security for DevOps for why catching these earlier in the pipeline matters more than the specific tool, Supply-Chain Security & SBOM for how dependency and container scanning feed a software bill of materials, and Secrets & Credential Management for what to do once Secret Detection actually finds one.
Day-to-day workflow
☺ Like you're 10: There's a way to check your pipeline file for mistakes before you push it, and a command-line tool so you never have to leave the terminal to see if a build passed.
The pipeline editor in the GitLab UI lints .gitlab-ci.yml as you type, including resolving every include; the same check is available as an API endpoint (POST /projects/:id/ci/lint) for pre-push automation. glab, GitLab's official CLI (the gh of the GitLab world), covers the rest of the loop without a browser tab:
$ glab ci lint # validate .gitlab-ci.yml syntax and includes before you push $ glab ci status # pipeline status for the current branch $ glab ci view -w # open the live job log in a browser $ glab mr create --fill --target-branch main $ glab mr view --web
A merge_request_event pipeline runs against the merge result, not just the source branch in isolation — catching a conflict-free-looking change that would actually break once combined with what's already on main. On Premium and above, merge trains queue several approved MRs and pipeline-verify each one against the state left by the ones ahead of it, so merges land in the order they were queued without the last-one-in silently invalidating an earlier one's green pipeline. Protected branches and protected variables are the access-control layer underneath all of this: a variable marked protected is only ever exposed to jobs running on a protected branch or tag, so a feature-branch pipeline never sees a production deploy credential.
Gotchas and failure modes
☺ Like you're 10: Almost every "GitLab is broken" moment is really one of a handful of well-known traps, and every one of them has a known fix.
Docker-in-Docker, and why it's the wrong default
Building a container image inside a job that is itself running in a Docker executor requires a Docker daemon available to that job — the classic setup adds services: [docker:dind] and runs the job in privileged mode, since a container needs elevated kernel access to run another Docker daemon inside itself. Privileged mode means that job can, in principle, break out to the host — a real risk on shared runner infrastructure, not a theoretical one. The fix most teams converge on is to skip the daemon entirely: Kaniko or Buildah build OCI images from a Dockerfile without ever needing a privileged container or a Docker socket, and are the safer default for anything running on shared infrastructure.
If you inherited a pipeline with docker:dind and privileged = true in a shared runner's config.toml, that's worth treating as a finding, not a given — it's the single most common security misconfiguration in real-world GitLab CI setups, precisely because it's what every "how to build a Docker image in GitLab CI" tutorial shows first.
The other traps worth knowing before you hit them
Tag mismatch: a job stuck at "pending" forever, no error message, is almost always tags: [...] naming something no registered runner offers — check Settings → CI/CD → Runners before you assume the pipeline is broken. Shallow clones: GitLab sets GIT_DEPTH: 50 by default for speed, which silently breaks anything needing full history — git describe, changelog generators, some monorepo tooling — until you override GIT_DEPTH per job. Duplicate pipelines: without a workflow:rules block like the one above, a push to a branch with an open merge request spawns both a branch pipeline and an MR pipeline for the same commit — burning double the compute for zero extra signal. Masked variables that silently aren't: a CI/CD variable marked "masked" only actually gets redacted from job logs if its value satisfies GitLab's masking requirements (no whitespace, a minimum length, no characters outside a safe set); fail that check and the value ships to the log in plaintext with no error telling you it happened. Compute quota exhaustion: GitLab.com's shared runners are metered against a monthly compute-unit allowance per plan, and a runaway pipeline schedule or a retry loop can burn through a month's quota before anyone notices — self-managed runners sidestep the quota but move the capacity-planning problem onto you instead.
GitLab CI/CD vs. the assemble-it-yourself model
☺ Like you're 10: Other tools make you go shopping for the rest of the kitchen yourself — sometimes that's more work, sometimes it's exactly the flexibility you want.
| Tool | Model | SCM & registry | Security scanning | Best when |
|---|---|---|---|---|
| GitLab CI/CD | Single application — CI is one feature of a platform that also holds your repo, MRs, and registries | Native, built in | SAST, DAST, dependency, container, secret, and IaC scanning as maintained CI templates, results in the MR | You want fewer seams and are comfortable with GitLab as the platform of record, not just the pipeline |
| Jenkins | Self-hosted automation server; every capability is a plugin | None natively — points at whatever SCM you configure | None natively — SonarQube, Snyk, Checkmarx, etc. bolted on and operated separately | Maximum plugin-ecosystem flexibility and full control over the server, and someone is staffed to run and patch it |
| GitHub Actions | Marketplace-extended, native only to repos already hosted on GitHub | Native to GitHub, GHCR built in | CodeQL, Dependabot, and secret scanning are first-party (GitHub Advanced Security); DAST and IaC scanning are third-party Marketplace actions | Your code already lives on GitHub and you're comfortable composing Marketplace actions |
| CircleCI | Dedicated CI/CD SaaS, bring-your-own SCM | None — connects to GitHub or GitLab as the source | None natively — orbs integrate third-party scanners | You want a CI specialist's pipeline UX and performance features without adopting a platform for everything else |
The honest version of this comparison is not "GitLab wins" — it's that Jenkins asks you to assemble a platform from a job scheduler plus a plugin ecosystem, GitHub Actions gives you a strong native core narrowed to GitHub-hosted repos with growing but still partial built-in security coverage, and GitLab gives you the broadest single-vendor bundle at the cost of buying into GitLab as your platform, not just your pipeline runner. Teams already committed to GitHub rarely gain enough from switching SCMs to justify it; teams standing up delivery from scratch, or already running GitLab for source control, get the most out of the bundle. See the DevOps toolchain for this same tradeoff mapped across the rest of the stack, and GitLab Certifications if you want to validate this specifically.
On a throwaway GitLab.com project, commit a .gitlab-ci.yml with a build stage and a test stage using needs to skip the wait for an unrelated job. Push it, watch the pipeline graph render as a DAG instead of a straight line, then add include: - template: Jobs/Secret-Detection.gitlab-ci.yml, commit a fake-looking API key on purpose in a throwaway file, and push again. Watch it get caught in the merge request widget before you merge — then remove the fake key and force-push, and notice the old commit still has it in history until you deal with that separately. Finish by intentionally mistagging a job (tags: [does-not-exist]) and watching it sit at "pending" — the fastest way to recognize that failure mode next time it isn't on purpose.
Foxy: Why not just run Jenkins? We already know it.
Benny the Beaver: You can. But then you're also standing up a registry, a SAST tool, a DAST tool, and keeping all four patched and talking to each other. GitLab ships them as one include: line each.
Gizmo: Or skip the whole DAST step. Staging's basically the same as prod, right? Ship it. 🤑
Timmy the Turtle: DAST attacks a running app the way an actual attacker would — that's exactly the class of bug the other four scanners structurally can't see. It stays.
Benny the Beaver: And while we're being honest — whoever set up our image build with docker:dind and privileged mode, we're switching that to Kaniko this sprint. No more privileged containers on shared runners.
Foxy: So the pitch isn't "GitLab is smarter." It's "GitLab is fewer places for something to quietly go wrong."
Benny the Beaver: That's exactly it.
1. What is the single-application pitch, and which pieces does it bundle that Jenkins and GitHub Actions leave you to assemble yourself? 2. Explain the difference between GitLab and GitLab Runner — who parses the pipeline, and who executes a job? 3. What determines which runner picks up a given job? 4. Distinguish cache from artifacts, and explain what needs does to pipeline execution order. 5. Name three of the six built-in security scanning categories and what each one actually checks. 6. Why is docker:dind with privileged mode a common but risky default, and what are the safer alternatives? 7. Your pipeline sits at "pending" with no error. What's the first thing to check?
Check your answers
- The pitch is that source control, CI/CD, a container/package registry, and security scanning live in one application with one merge-request UI, instead of being wired together from separate products. Jenkins bundles none of it natively — every piece is a plugin or a separate tool (Artifactory, SonarQube, Snyk). GitHub Actions natively covers SCM, CodeQL/Dependabot/secret scanning, and GHCR, but DAST, IaC scanning, and license compliance are still third-party Marketplace additions.
- GitLab (the application) parses
.gitlab-ci.ymland creates the pipeline and its jobs, but never executes anything itself. GitLab Runner is a separate binary, installed wherever you want compute, that polls GitLab for jobs and actually runs them inside an executor. - A job's
tags:must match tags the runner was registered with, and the runner must be in scope for that project — shared (instance-wide), group, or project-specific. A tag mismatch is the most common reason a job sits pending indefinitely. cacheis a best-effort speed-up (e.g. dependency directories) shared opportunistically across pipelines, allowed to miss silently.artifactsare guaranteed, versioned files a job promises to produce, downloadable and the mechanismneedsuses to pass files between jobs.needsturns the pipeline into a DAG: a job starts as soon as the specific jobs it needs finish, rather than waiting for its entire declared stage to complete.- Any three of: SAST (static analysis of your own source), Secret Detection (committed credentials, and optionally blocking them at push time), Dependency Scanning (known CVEs in your lockfile's packages), Container Scanning (known CVEs in a built image's layers), DAST (attacks a genuinely running application), IaC Scanning (SAST-style rules applied to Terraform/CloudFormation/Kubernetes manifests/Dockerfiles).
- Running a Docker daemon inside a job requires elevated kernel access, so the classic setup runs the job in privileged mode — which can, in principle, let that job break out to the host. It's risky by default on shared runner infrastructure. Kaniko and Buildah build OCI images from a Dockerfile without needing a Docker daemon or privileged mode at all.
- Whether a registered runner actually offers every tag the job lists under
tags:, and whether that runner is in scope (shared/group/project) for the project the pipeline belongs to.