Table of Contents#
- Where GitLab Fits — a Different Philosophy Than GitHub
- GitLab Editions and Pricing at a Glance
- Anatomy of
.gitlab-ci.yml - Stages and the Default Sequential Model
- A Minimal Pipeline, Built Up Step by Step
- DAG Pipelines — Breaking Free of Stage Ordering with
needs - Rules, Workflow Rules, and When Jobs Run
- Matrix Builds —
parallel:matrix - Artifacts and Caching
includeand CI/CD Components — GitLab's Reusability Model- Parent-Child and Multi-Project Pipelines
- Protected Branches, Protected Environments, and Approval Rules
- Merge Trains
- OIDC — ID Tokens for Cloud Authentication
- A Full Worked OIDC Example: Deploying to AWS
- Secrets and CI/CD Variables
- Built-In Security Scanning — SAST, DAST, Dependency Scanning
- GitLab Runners — Shared, Group, Project, and Self-Managed
- A Full Realistic Multi-Stage Pipeline
- GitHub Actions vs. GitLab CI/CD — A Direct Comparison
- Auto DevOps — Zero-Configuration Pipelines
- Compliance Pipelines and Policy-as-Code
- Common Mistakes
- Worked Practice Problems
- Summary and What's Next
Where GitLab Fits — a Different Philosophy Than GitHub#
Parts 4-5 covered GitHub, where CI/CD (Actions) is one product bolted tightly onto the git host, with many small independent workflow files, each declaring its own triggers. GitLab takes a meaningfully different approach worth understanding on its own terms rather than as "GitHub with different YAML":
Diagram
GitLab markets itself explicitly as "the DevOps platform" — a single application spanning planning (issues, epics), source control, CI/CD, container registry, and security scanning (SAST/DAST/dependency scanning), rather than a git host with CI/CD as one feature among many. This shows up concretely in how deeply security scanning is integrated into the default pipeline story (covered later in this chapter) — where GitHub treats security scanning as a separate, opt-in ecosystem (Part 5's GHAS, third-party Actions), GitLab ships SAST/dependency scanning as a nearly one-line include away from day one.
This chapter follows the same build-first structure as Parts 4-5, but — reflecting GitLab's more integrated security story — folds security-relevant material (protected environments, OIDC, built-in scanning) into this single chapter rather than splitting it into a separate governance chapter, since GitLab's own security tooling is less an "additional hardening pass" and more a checkbox in the same pipeline file.
A note on how to read this chapter if you've already read Parts 4-5: rather than re-deriving every CI/CD concept from scratch, this chapter leans heavily on direct comparison back to GitHub's equivalent mechanism wherever one exists — matrix builds, artifacts, OIDC, and environments all map fairly cleanly between the two platforms, just under different names and slightly different defaults. The genuinely new ground this chapter covers — where GitLab's model diverges rather than just renames — is the stage-sequential-by-default DAG model, CI/CD Components as a more structured reusability primitive, merge trains, and the depth of built-in security/compliance tooling.
GitLab Editions and Pricing at a Glance#
| Tier | Key CI/CD-relevant features | Typical fit |
|---|---|---|
| Free | Unlimited public repos, 400 compute minutes/month (SaaS), basic pipelines | Individuals, small open-source projects |
| Premium | Merge trains, protected environments with approval rules, multiple approval rules per MR, higher compute minutes | Growing teams needing real release governance |
| Ultimate | Full SAST/DAST/fuzz testing suite, compliance pipelines, advanced vulnerability management, higher minutes | Regulated or security-mature organizations |
Two structural facts worth knowing before writing a single pipeline:
- GitLab can be run three ways — GitLab.com (SaaS, GitLab-hosted), GitLab Self-Managed (a team runs the entire application on its own infrastructure), and GitLab Dedicated (a single-tenant managed instance). This is a meaningfully different set of options than GitHub's SaaS-plus-Enterprise-Server split, and self-managed GitLab is genuinely common in regulated or air-gapped environments specifically because the entire platform, not just the runner, can live entirely inside a private network.
- Compute minutes are the SaaS billing unit, similar in spirit to GitHub Actions minutes — with the same "some machine types cost more per wall-clock minute than others" multiplier for larger, GPU, or macOS runners.
Anatomy of .gitlab-ci.yml#
Where GitHub Actions structures a workflow as Workflow → Jobs → Steps, GitLab structures a pipeline as Pipeline → Stages → Jobs:
Diagram
- Pipeline — the entire run, triggered by a Git event, a schedule, or a manual/API trigger.
- Stage — a named phase (
build,test,deployare conventional but arbitrary names); by default, every job in one stage must finish before any job in the next stage starts. - Job — the actual unit of work — a
script:(shell commands) plus metadata (stage:,image:,rules:,needs:, etc.). Unlike GitHub, there is no intermediate "step" concept — a job'sscript:is just a list of shell commands run in sequence inside one container/shell session.
stages: - build - test - deploy build-job: stage: build script: - echo "Building..." - make build test-job: stage: test script: - make test deploy-job: stage: deploy script: - make deploy
The absence of a "step" layer is a real, not cosmetic, difference from GitHub Actions. A GitHub job composes many discrete steps (each independently reportable, each potentially a reusable Action); a GitLab job's script: is one flat shell script — reusability at that granularity comes from extends: (YAML-level inheritance between job definitions) or breaking logic into separate shell scripts checked into the repo, not from a "step" primitive.
Stages and the Default Sequential Model#
By default, GitLab pipelines are strictly stage-sequential — every job in build must succeed before any job in test starts, regardless of whether a specific test job actually depends on a specific build job's output. This is simpler to reason about than GitHub's default all-parallel-unless-needs: model, but can waste real time in a large pipeline, which is exactly the problem DAG pipelines (needs:, covered next) solve.
Diagram
Within a single stage, jobs run in parallel by default (identical to GitHub) — the sequencing constraint only applies between stages.
A Minimal Pipeline, Built Up Step by Step#
Step 1 — bare minimum:
test: script: - echo "hello"
Step 2 — a real Node project, checked out automatically (GitLab clones the repo before every job automatically — there's no equivalent of actions/checkout to remember):
image: node:20 stages: - test test: stage: test script: - npm ci - npm test
Step 3 — splitting lint and test into parallel jobs in the same stage:
image: node:20 stages: - test lint: stage: test script: - npm ci - npm run lint unit-test: stage: test script: - npm ci - npm test
Step 4 — adding caching, and scoping which branches trigger the pipeline at all (rules:, covered in depth shortly):
image: node:20 stages: - test cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ lint: stage: test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" script: - npm ci - npm run lint unit-test: stage: test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" script: - npm ci - npm test
Notice image: node:20 is set once, at the top level, and inherited by every job — a genuinely convenient default GitHub Actions has no direct equivalent for (each GitHub job independently sets up its own toolchain via setup-node-style Actions); GitLab jobs instead run inside a container image specified once (or overridden per-job), reflecting GitLab's assumption that most CI work runs in Docker-based runners.
DAG Pipelines — Breaking Free of Stage Ordering with needs#
needs: lets a specific job declare a dependency on specific other jobs, regardless of stage — the job starts the moment its named dependencies finish, not when the entire preceding stage finishes. This converts a strictly linear stage pipeline into a Directed Acyclic Graph (DAG), GitLab's version of the same fine-grained dependency graph GitHub Actions gets from its default parallel-unless-needs: job model.
stages: - build - test - deploy build-frontend: stage: build script: [make build-frontend] build-backend: stage: build script: [make build-backend] test-frontend: stage: test needs: [build-frontend] # starts as soon as build-frontend finishes — doesn't wait for build-backend script: [make test-frontend] test-backend: stage: test needs: [build-backend] script: [make test-backend] deploy: stage: deploy needs: [test-frontend, test-backend] script: [make deploy]
Diagram
Without needs:, test-frontend would wait for both build-frontend AND build-backend (the entire build stage) to finish, even though it has no actual dependency on the backend build — pure wasted wall-clock time in a large pipeline. This is the exact same "don't wait on work you don't actually depend on" efficiency principle GitHub Actions gets for free by default, that GitLab requires opting into via needs: because its default model is stage-sequential rather than dependency-graph-based.
Rules, Workflow Rules, and When Jobs Run#
rules: is GitLab's mechanism for controlling whether a specific job runs at all — conceptually similar to GitHub's if:, but structured as an ordered list of conditions evaluated top to bottom, the first match wins:
deploy-production: stage: deploy rules: - if: $CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push" when: manual # requires a human to click "run" in the GitLab UI - when: never # otherwise, never run this job at all script: - ./deploy.sh --env production
workflow:rules: (top-level, applies to the whole pipeline) is distinct from a job's own rules:, and conflating the two is a common early mistake: workflow:rules: decides whether the entire pipeline is created at all for a given event; a job's own rules: then decides whether that specific job runs within a pipeline that was already created.
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" - when: never # no pipeline at all for anything else (e.g. a random feature branch push)
This two-level structure is genuinely important at scale — without a workflow:rules: guard, GitLab's default behavior can create a pipeline for every push to every branch, including ones nobody wants CI running against, wasting compute minutes on pipelines nobody will ever look at.
Matrix Builds — parallel:matrix#
GitLab's equivalent of GitHub's strategy.matrix:
test: stage: test parallel: matrix: - NODE_VERSION: ['18', '20', '22'] OS: [ubuntu, alpine] image: node:${NODE_VERSION}-${OS} script: - npm ci - npm test
This expands into 6 parallel job instances (3 Node versions × 2 base images), each shown individually in the pipeline UI, functionally identical to GitHub's matrix expansion covered in Part 4 — the same "test across every real-world configuration combination without hand-writing each one" problem, GitLab-flavored syntax.
Artifacts and Caching#
The same conceptual split as GitHub Actions (Part 4) — cache for speed, artifacts for passing data between jobs and stages — with GitLab's own syntax:
build: stage: build script: - npm ci - npm run build cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ artifacts: paths: - dist/ expire_in: 1 week deploy: stage: deploy needs: [build] # artifacts from build are automatically available here script: - ./deploy.sh dist/
One genuine GitLab-specific convenience: artifacts declared in one job are automatically downloaded into every job that depends on it via needs: or stage order — no separate explicit "download artifact" step the way GitHub Actions requires actions/download-artifact. This is a direct consequence of GitLab tracking the dependency relationship (needs:, or implicit stage order) as first-class pipeline metadata, rather than treating each job as a fully isolated unit that must explicitly opt into receiving prior output.
include and CI/CD Components — GitLab's Reusability Model#
GitLab's reusability story has evolved through two overlapping mechanisms, worth understanding both since real-world .gitlab-ci.yml files use both:
include: — the older, still-widely-used mechanism, pulling in YAML from another file, another project, or a public template, merged into the current pipeline's configuration:
include: - local: '/ci/build.yml' - project: 'my-group/shared-ci-templates' ref: main file: '/templates/deploy.yml' - template: 'Security/SAST.gitlab-ci.yml' # GitLab's own built-in template
CI/CD Components — the newer (GA since GitLab 17.0), more structured evolution of include, designed specifically to be versioned, discoverable, and shareable via the CI/CD Catalog (GitLab's equivalent of a curated marketplace), addressing include's biggest weakness: a plain include: of a shared template file has no clean concept of semantic versioning or a discoverable catalog the way a proper package does.
# Using a published component, pinned to a specific version — the modern recommended pattern include: - component: gitlab.com/my-group/ci-components/deploy@2.1.0 inputs: environment: production
A component is defined with a template.yml inside its own repository, declaring typed spec:inputs: — genuinely similar in spirit to a GitHub reusable workflow's workflow_call: inputs::
# template.yml — defines a reusable component spec: inputs: environment: type: string default: staging --- deploy: stage: deploy script: - ./deploy.sh --env $[[ inputs.environment ]]
include: (plain template) | CI/CD Component | |
|---|---|---|
| Versioning | Manual (a Git ref, if you remember to pin it) | Native semantic versioning (@2.1.0) |
| Discoverability | None — you have to already know it exists | Listed in the CI/CD Catalog, searchable |
| Typed inputs | No — just YAML merge | Yes — spec:inputs: with types and defaults |
| GitLab's own direction | Being phased toward Components over time | The recommended modern pattern |
The direct GitHub Actions analogy, worth stating explicitly since it clarifies both platforms: include: with a plain template is closest to a GitHub composite action's "just merge in some steps" simplicity; a versioned CI/CD Component with typed inputs is GitLab's closest equivalent to a GitHub reusable workflow — both exist specifically to let an organization centralize a golden-path deploy pipeline that many projects consume rather than copy-paste.
Parent-Child and Multi-Project Pipelines#
Beyond include (merging configuration into one pipeline), GitLab supports parent-child pipelines — a top-level pipeline that triggers one or more genuinely separate, independently-visible sub-pipelines, each with its own job graph:
trigger-backend-pipeline: stage: build trigger: include: backend/.gitlab-ci.yml strategy: depend # parent pipeline waits for and reflects the child's pass/fail trigger-frontend-pipeline: stage: build trigger: include: frontend/.gitlab-ci.yml strategy: depend
This is GitLab's answer to a large monorepo's CI complexity — rather than one enormous flat .gitlab-ci.yml with dozens of rules: conditions gating which jobs apply to which subproject, each subproject gets its own genuinely independent child pipeline, visible and debuggable on its own, triggered conditionally by the parent based on which paths actually changed (rules: changes:).
Multi-project pipelines extend the same idea across repository boundaries — a pipeline in one project can trigger a pipeline in a completely different project, useful for a microservices architecture where deploying service A should also kick off an integration-test pipeline living in a separate, shared test-suite repository.
Protected Branches, Protected Environments, and Approval Rules#
GitLab's version of GitHub's branch protection + environments (Parts 4-5), split across two related but distinct mechanisms:
Protected branches restrict who can push/merge to a branch (typically main), and — critically for CI/CD — control which CI/CD variables (secrets) a pipeline running on that branch can even access, covered further in the Secrets section below.
Protected environments apply the same idea to deployment targets rather than branches — requiring specific users or groups to approve a deployment to a named environment before it proceeds:
deploy-production: stage: deploy environment: name: production url: https://app.example.com rules: - if: $CI_COMMIT_BRANCH == "main" when: manual script: - ./deploy.sh --env production
With the production environment configured as protected (Settings → CI/CD → Protected Environments) and a required-approval rule attached, clicking the manual job's "run" button doesn't execute it immediately — it enters a pending-approval state, requiring a specified number of eligible approvers (from a designated group) before the deploy job actually starts. This is functionally the direct equivalent of a GitHub Environment's required reviewers from Part 4 — the same manual-approval-gate concept from Part 1's pipeline-anatomy diagram, GitLab-flavored.
Merge Trains#
A merge train solves a specific, high-traffic-repo problem: with many merge requests targeting main in quick succession, testing each MR individually against main's current state doesn't guarantee it will still pass once several other MRs have already merged ahead of it — by the time MR #5 actually merges, three other MRs might already be in main, subtly invalidating MR #5's own, now-stale test run.
Diagram
Each merge request in the train is tested against a simulated merged state that includes every MR ahead of it in the queue — guaranteeing main never receives a change that wasn't actually validated against exactly what it's merging into. If any MR in the train fails, it's automatically removed and the train re-validates the next one without it, rather than blocking the entire queue. This is the concrete mechanism behind GitLab's own claim of supporting "50+ merges per day to a single branch while guaranteeing it never breaks" — a genuinely different scale problem than a small team's occasional MR, and a feature GitHub Actions has no direct first-party equivalent for (GitHub's closest analogue is a third-party merge-queue feature with similar but not identical semantics).
OIDC — ID Tokens for Cloud Authentication#
GitLab's OIDC mechanism — ID tokens — is conceptually identical to GitHub's OIDC from Part 5: a short-lived, signed JWT minted fresh per job, exchanged with a cloud provider for temporary credentials, eliminating any long-lived cloud secret stored in GitLab.
deploy: id_tokens: AWS_ID_TOKEN: aud: https://gitlab.com # or your self-managed instance URL script: - > export $(aws sts assume-role-with-web-identity --role-arn arn:aws:iam::123456789012:role/gitlab-deploy --role-session-name gitlab-pipeline --web-identity-token $AWS_ID_TOKEN --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}') - aws s3 sync ./dist s3://my-production-bucket
The id_tokens: block is the GitLab-specific declaration that requests a token at all — directly analogous to GitHub's permissions: { id-token: write } from Part 5; without it, no OIDC token is available to the job's script, regardless of how the AWS side is configured. The token's payload carries claims GitLab controls — including project_path, ref, and (notably) environment_protected, which lets the receiving cloud provider's trust policy distinguish "this job is deploying to a genuinely protected, approval-gated environment" from an arbitrary unprotected job, mirroring the environment:production claim restriction from GitHub's own OIDC trust-policy example in Part 5.
A Full Worked OIDC Example: Deploying to AWS#
Step 1 — one-time AWS setup, structurally identical to the GitHub version from Part 5, trusting GitLab's OIDC issuer instead:
{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "gitlab.com:sub": "project_path:my-group/my-project:ref_type:branch:ref:main", "gitlab.com:aud": "https://gitlab.com" } } }
Step 2 — the pipeline job, this time using the community-maintained aws-actions-equivalent approach (GitLab doesn't ship an official first-party AWS credentials helper the way GitHub's aws-actions/configure-aws-credentials does, so the raw aws sts call shown in the previous section, or a small wrapper script, is the typical pattern):
deploy-production: stage: deploy environment: name: production id_tokens: AWS_ID_TOKEN: aud: https://gitlab.com rules: - if: $CI_COMMIT_BRANCH == "main" when: manual script: - ./scripts/assume-aws-role.sh # wraps the sts assume-role-with-web-identity call shown above - aws s3 sync ./dist s3://my-production-bucket
The trust policy's sub condition scopes exactly which project and branch may assume the role — the same defense-in-depth idea as GitHub's environment-scoped trust policy from Part 5, expressed via GitLab's own claim structure. The environment_protected claim can be added to the trust condition for an even tighter guarantee — restricting the role to only be assumable when the job is running against a GitLab-protected environment, meaning even a job on main targeting an unprotected environment by mistake is rejected before ever obtaining AWS credentials.
Secrets and CI/CD Variables#
GitLab calls secrets CI/CD variables, configurable at the project, group, or instance level, with a Protect variable flag that's the direct analogue of GitHub's Environment-scoped secrets:
| Scope | Visible to | Notes |
|---|---|---|
| Instance (self-managed only) | Every project on the instance | Rare — broad shared credentials |
| Group | Every project in the group (and subgroups) | Shared credentials across related projects |
| Project | Just this project | The common case |
| Protected (a flag, any scope) | Only pipelines running on a protected branch/tag | The critical security boundary |
Diagram
The Protected flag is the single most important secrets setting to get right, and it's easy to forget on a new secret. An unprotected variable is visible to a pipeline running on any branch, including an attacker-controlled fork's merge request pipeline (if MR pipelines run with variables enabled) or simply an accidental typo'd deploy script on a feature branch — the same "scope a secret to only where it's genuinely needed" least-privilege principle from GitHub's Environment secrets in Part 5, expressed as a single checkbox rather than a separate named scope.
Like GitHub, GitLab automatically masks any exact-match secret value in job logs — with the same "naive string match, not semantic" caveat from Part 5 applying identically: a transformed or encoded secret value bypasses masking entirely.
GitLab also supports external secrets integration (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) as a first-party option, letting a job fetch a secret at run time from an external store rather than storing it as a GitLab CI/CD variable at all — the same "don't store what you don't have to" principle behind OIDC, applied to secrets that genuinely can't be replaced by a short-lived token (a third-party API key, for instance, rather than cloud infrastructure credentials).
Built-In Security Scanning — SAST, DAST, Dependency Scanning#
This is where GitLab's "one integrated platform" philosophy is most visible in practice — enabling a genuinely comprehensive security scanning suite is close to a one-line change, rather than the separate ecosystem of third-party Actions and GHAS configuration GitHub requires (Part 5):
include: - template: Security/SAST.gitlab-ci.yml - template: Security/Dependency-Scanning.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml - template: Security/DAST.gitlab-ci.yml # dynamic scanning against a running app
| Scan type | What it finds | Rough GitHub equivalent |
|---|---|---|
| SAST | Source-code vulnerability patterns (static analysis) | CodeQL |
| Dependency Scanning | Known-vulnerable dependencies | Dependency review / Dependabot alerts |
| Secret Detection | Committed credentials | Secret scanning |
| DAST | Vulnerabilities found by attacking a running application (SQLi, XSS, against the real deployed app) | No direct first-party GitHub equivalent — commonly a third-party tool |
| Container Scanning | Vulnerabilities in a built container image | Third-party Action (e.g. Trivy) on GitHub |
Findings from every included scanner surface directly in the Merge Request Security widget and the project's Vulnerability Report, without any additional dashboard or third-party integration to wire up — a genuinely different experience from assembling an equivalent picture from GitHub's Security tab plus whatever third-party scanners a team has separately adopted. This built-in breadth, especially DAST (dynamic scanning against a genuinely running instance of the application, which requires real deploy infrastructure to even attempt), is one of the most commonly cited reasons a security-mature organization chooses GitLab Ultimate specifically.
Every scan runs as an ordinary CI job under the hood — meaning it's subject to the exact same rules:, needs:, and stage-ordering mechanics covered earlier in this chapter, and a team can just as easily scope SAST to run only on main and merge request pipelines (skipping it on every throwaway feature-branch push) as they can scope any other job, keeping the "cheap checks first, expensive checks only where they matter" principle from Part 1 intact even with this much scanning enabled by default.
GitLab Runners — Shared, Group, Project, and Self-Managed#
GitLab's execution model has one more layer of scoping than GitHub's hosted-vs-self-hosted split:
| Runner type | Scope | Rough GitHub equivalent |
|---|---|---|
| Shared / Instance runners | Available to every project on the GitLab instance | GitHub-hosted runners |
| Group runners | Available to every project within one group | No direct GitHub equivalent (GitHub self-hosted runners can be org-scoped, which is close) |
| Project runners | Registered to, and usable by, one specific project only | A self-hosted runner scoped to a single repo |
test: tags: [docker, linux] # matches against a runner's configured tags — same concept as GitHub's runner labels script: - npm test
The tags: mechanism for selecting a specific runner is functionally identical to GitHub's runner labels (runs-on: [self-hosted, linux, gpu] from Part 4) — a job requests a runner carrying all the listed tags, and GitLab dispatches it to any available runner matching. Self-managed GitLab Runner instances carry the same security tradeoffs already covered for GitHub self-hosted runners in Part 5: real hardware/network access at the cost of real security responsibility, and the same caution around running untrusted merge-request pipelines (from external forks, on a public project) against infrastructure-backed runners.
GitLab Runner also supports several execution "executors" beyond a plain shell — most notably the Kubernetes executor, which spins up a fresh pod per job (directly analogous to the ephemeral-runner hardening pattern recommended for GitHub self-hosted runners in Part 5) and the Docker executor, which runs each job inside a fresh container from the job's image:. A team running self-managed GitLab Runner at real scale almost always reaches for the Kubernetes executor specifically because it gets the same autoscaling and per-job isolation properties this course's Kubernetes deep-dive already covers for workload scheduling generally, applied here to CI job scheduling.
A Full Realistic Multi-Stage Pipeline#
The same build → test → scan → deploy-staging → approval → deploy-production shape from Part 4's GitHub example, now in GitLab's stage + DAG + protected-environment model:
stages: - build - test - deploy include: - template: Security/SAST.gitlab-ci.yml build: stage: build script: - npm ci - npm run build artifacts: paths: [dist/] unit-test: stage: test needs: [build] script: - npm test sast: stage: test # provided automatically by the included SAST template — no script needed here deploy-staging: stage: deploy needs: [unit-test, sast] environment: name: staging url: https://staging.example.com rules: - if: $CI_COMMIT_BRANCH == "main" script: - ./deploy.sh --env staging smoke-test: stage: deploy needs: [deploy-staging] script: - curl -f https://staging.example.com/healthz deploy-production: stage: deploy needs: [smoke-test] environment: name: production # protected + approval rule configured here = the manual gate rules: - if: $CI_COMMIT_BRANCH == "main" when: manual script: - ./deploy.sh --env production
Diagram
GitHub Actions vs. GitLab CI/CD — A Direct Comparison#
| GitHub Actions | GitLab CI/CD | |
|---|---|---|
| Pipeline file(s) | Many independent files in .github/workflows/ | Typically one .gitlab-ci.yml, often split via include |
| Default job scheduling | Parallel unless needs: | Sequential by stage unless needs: (DAG) |
| Reusable steps | Composite Actions | extends: / shared scripts |
| Reusable jobs/pipelines | Reusable Workflows (workflow_call) | CI/CD Components, include |
| Cross-repo/project reuse | Reusable workflow in another repo | CI/CD Catalog components, or include: project: |
| Manual approval gate | Environment required reviewers | Protected environment + when: manual |
| Merge-time correctness at high volume | Third-party merge-queue tools | Native merge trains |
| OIDC to cloud | permissions: id-token: write | id_tokens: |
| Built-in SAST/DAST/dependency scanning | Opt-in via GHAS + third-party Actions | Near one-line include, deeply integrated by default |
| Artifact passing between jobs | Explicit upload/download steps | Automatic via needs:/stage order |
| Platform philosophy | Git host + first-party CI/CD product | Single integrated "DevOps platform" (planning → security) |
Neither platform is unconditionally "better" — the right choice genuinely depends on organizational context. A team already deep in the GitHub ecosystem (open-source-heavy, Marketplace Actions for everything) gains from GitHub Actions' enormous third-party ecosystem and per-minute-cheap public-repo CI. A team wanting security scanning, planning, and CI/CD as one coherent product with less integration glue to maintain — especially one needing DAST or a fully self-managed, air-gapped platform — gains more from GitLab.
Neither answer is permanent, either — an organization's needs at 20 engineers (favoring GitHub's ecosystem breadth and lower ceremony) commonly look different from the same organization at 500 engineers under a compliance mandate (favoring GitLab's centrally-enforceable governance), and a genuinely mature platform team revisits this choice periodically rather than treating it as decided once at founding.
Auto DevOps — Zero-Configuration Pipelines#
Worth a brief, dedicated mention as a genuinely distinctive GitLab feature with no direct GitHub Actions equivalent: Auto DevOps auto-detects a project's language/framework and generates a complete pipeline — build, test, SAST/dependency/container scanning, and deploy to Kubernetes — with zero .gitlab-ci.yml required at all.
Diagram
Each stage is individually overridable — a team can start with the fully automatic pipeline and progressively replace specific stages with custom .gitlab-ci.yml jobs as their needs diverge from the generic default, rather than an all-or-nothing choice. The realistic use case is narrower than the pitch suggests, worth stating plainly rather than oversold: Auto DevOps genuinely shines for a straightforward, conventionally-structured application (a standard web service with a standard test command) deployed to Kubernetes, and becomes progressively less useful the more a project's build/deploy process diverges from that common shape — a legacy monolith with a bespoke, multi-step release process gets comparatively little value from an auto-generated pipeline built for the common case. It's most valuable as a fast on-ramp (a new project gets a genuinely complete, security-scanning-included pipeline on day one with no YAML authored at all) rather than a permanent solution for every project in an organization.
Compliance Pipelines and Policy-as-Code#
An Ultimate-tier feature worth knowing about even briefly, because it's a direct, concrete answer to a question this course's Incident Management and DevSecOps series both raise abstractly: how does an organization guarantee a specific control (e.g. "SAST must run on every pipeline for any project handling customer data") is actually enforced, everywhere, rather than trusted to each project team to remember?
Compliance framework labels attach a named framework (e.g. "SOC 2", "HIPAA", or a custom label) to a project, and a compliance pipeline configuration — defined centrally, outside any individual project's own .gitlab-ci.yml — is automatically injected into every pipeline for every project carrying that label, regardless of what that project's own pipeline file says:
# A compliance pipeline configuration, defined at the group/compliance-framework level, # NOT inside any individual project's own .gitlab-ci.yml include: - project: 'compliance/mandatory-scans' file: '/required-sast.yml' required-sast: stage: test script: - echo "This job is injected centrally and cannot be removed by an individual project"
The key property that makes this a genuine governance mechanism rather than a convention: an individual project's own pipeline maintainer cannot delete or bypass the compliance-injected job, even with full admin rights over their own .gitlab-ci.yml — the injection happens at the group/framework level, outside their control entirely. This directly closes the gap the Part 5 GitHub chapter's compliance section flagged as a common audit finding: a control that exists only because individual teams remember to include it is not the same guarantee as one enforced centrally and unconditionally, and GitLab's compliance pipelines make that distinction concrete and enforceable rather than a matter of policy documentation and hoping every team follows it.
Common Mistakes#
| Mistake | Why it's a problem | Fix |
|---|---|---|
No workflow:rules: guard | GitLab creates a pipeline for every push to every branch by default, wasting compute minutes | Add a top-level workflow:rules: scoping which events actually create a pipeline |
Forgetting needs: in a large pipeline | Every stage waits for the ENTIRE previous stage, even jobs with no real dependency | Use needs: to build a genuine DAG wherever stage-sequential ordering isn't actually required |
Treating include: templates as unversioned | A plain include: of a mutable file can change underneath you with no review, similar to GitHub's unpinned-tag risk | Pin include: project: / ref: to a specific tag, or migrate to versioned CI/CD Components |
A secret/CI-CD variable created without the Protected flag | Visible to pipelines on any branch, not just trusted ones | Flag every genuinely sensitive variable as Protected, tied to protected branches/environments |
Confusing job-level rules: with top-level workflow:rules: | A job never runs because the whole pipeline was never created, and the job's own rules: looked correct | Check workflow:rules: first when a pipeline doesn't appear at all; check job rules: when the pipeline exists but a specific job is missing |
Relying on include: merge for something that's really a full reusable job with inputs | Plain YAML merge has no typed inputs or versioning, drifts easily | Use a proper CI/CD Component with spec:inputs: for anything meant to be reused across many projects |
Assuming a manual job on an unprotected environment gives real deploy governance | Anyone with pipeline access can click "run" — there's no approval gate, just a pause button | Combine when: manual with an actual Protected Environment carrying an approval rule |
One enormous flat .gitlab-ci.yml for a large monorepo | Every rules: changes: condition adds complexity; the whole pipeline is one hard-to-debug unit | Split into parent-child pipelines per subproject, triggered conditionally by what actually changed |
| Assuming Auto DevOps fits every project just because it's zero-config | It's tuned for conventionally-structured apps deploying to Kubernetes; a bespoke release process gets little value from it | Use Auto DevOps as a fast on-ramp for new, conventional projects; override or replace stages as needs diverge |
Expecting job-level rules: to override a workflow:rules: when: never | A job with its own passing rules: still never runs if the pipeline itself was never created | Fix the pipeline-level gate first — job-level rules: can only select among jobs in a pipeline that already exists |
Worked Practice Problems#
Problem 1: A pipeline has build, test, and deploy stages. The test stage takes 8 minutes because a test-frontend job (which only needs build-frontend's output) waits for a much slower, unrelated build-backend job to finish first, purely because they're both in the same build stage. What's the fix?
Answer: Add needs: [build-frontend] to the test-frontend job. This converts the pipeline from strictly stage-sequential to a DAG for that specific dependency — test-frontend now starts the instant build-frontend finishes, without waiting for build-backend at all, since it has no actual dependency on it. This is purely a wall-clock optimization; it changes nothing about correctness, since needs: only ever narrows what a job waits for down to its genuine dependencies, never removes a real one.
Problem 2: A security review finds a CI/CD variable named PROD_DEPLOY_KEY with no Protected flag set, in a project where feature-branch pipelines run automatically on every push. What's the exploitable gap, and what's the minimal fix?
Answer: Any pipeline running on any branch — including a feature branch, and depending on project settings, potentially even a merge request pipeline from a fork — can read PROD_DEPLOY_KEY's value, meaning a compromised dependency pulled in by a feature-branch build (the same supply-chain risk pattern from Part 5's GitHub chapter) could exfiltrate a production deploy credential without ever touching main. The minimal fix: flag the variable as Protected, restricting its visibility to pipelines running on protected branches/tags only — combined with ensuring main (or wherever PROD_DEPLOY_KEY is actually needed) is itself a protected branch, so the variable becomes invisible to every untrusted, unprotected branch pipeline in one settings change.
Problem 3: A platform team wants to guarantee main never receives a merge that breaks the build, even with 40+ merge requests landing per day from a large team — and wants this to work even when two MRs, individually passing, would conflict when combined. Which GitLab feature solves this specifically, and why doesn't ordinary "require passing CI before merge" alone solve it?
Answer: Merge trains. Ordinary "require passing CI before merge" validates each MR against main's state at the time that MR's pipeline ran — but at high merge volume, several other MRs can land in between an MR's last passing pipeline and its actual merge, meaning the tested state and the merged state have silently diverged. A merge train fixes this by testing every queued MR against a simulated state that already includes every MR ahead of it in the train, not just main's current HEAD — guaranteeing the exact state actually being validated is the exact state that will exist the moment this MR merges, at any merge volume.
Problem 4: An organization is deciding between GitHub Actions and GitLab CI/CD for a new, security-sensitive project that will need SAST, DAST, and dependency scanning, plus a guarantee that every project under a specific compliance label actually runs those scans regardless of individual project configuration. Which platform's default posture more directly addresses this requirement, and what would the equivalent GitHub setup require?
Answer: GitLab's default posture is more directly aligned, for two compounding reasons specific to this requirement. First, SAST/DAST/dependency/secret scanning are native, near-one-line include: templates rather than an assembled set of third-party GitHub Actions and separately-configured GHAS features — less integration surface to get right. Second, and more decisively for the "guarantee... regardless of individual project configuration" clause specifically: GitLab's compliance pipelines can inject mandatory scanning jobs at the group/framework level that an individual project cannot remove, which is a materially stronger guarantee than anything achievable on GitHub purely through required status checks — a GitHub branch protection rule can require a named check to pass, but nothing prevents a project maintainer from deleting the workflow file that defines that check in the first place, short of also locking down .github/workflows/ itself via CODEOWNERS and branch protection (Part 5) on every single project individually, which is exactly the "trust each team to remember" gap GitLab's centrally-injected compliance pipeline avoids by construction.
Problem 5: A new project enables Auto DevOps for a fast start, and three months later the team wants to add a custom integration-test stage that needs a real Postgres instance, without giving up the automatically-generated build/scan/deploy stages. Is this an all-or-nothing choice, and if not, how would you approach it?
Answer: Not all-or-nothing. Auto DevOps stages are individually overridable — the team can add a .gitlab-ci.yml to the project that defines only the new integration-test job (with its own services:-equivalent Postgres dependency, structurally the same pattern as GitHub's service containers from Part 4, GitLab calls this services: too), placed in the appropriate stage, while leaving every other Auto DevOps-generated stage (build, SAST, dependency scan, deploy) running exactly as before. This progressive-override model is precisely why Auto DevOps is positioned as a fast on-ramp rather than a permanent, inflexible choice — a team is never locked into either the fully automatic pipeline or a fully custom one; they can occupy any point in between as their actual needs grow past the generic default.
Summary and What's Next#
GitLab structures CI/CD around a single .gitlab-ci.yml (Pipeline → Stages → Jobs, sequential-by-default unless needs: builds a DAG), reflecting its broader identity as one integrated DevOps platform rather than a git host with a bolted-on CI product. rules: and workflow:rules: gate what runs at the job and pipeline level respectively; CI/CD Components (the modern evolution of include:) provide versioned reusability roughly analogous to GitHub's reusable workflows; protected branches and protected environments implement the same access-control and manual-approval-gate concepts as GitHub's branch protection and Environments; ID tokens provide the same OIDC-based, credential-free cloud authentication as GitHub's OIDC from Part 5; and built-in SAST/DAST/dependency/secret scanning — enabled with a near one-line include — is where GitLab's integrated-platform philosophy shows up most concretely, in contrast to GitHub's more ecosystem-driven, opt-in security tooling. Merge trains solve a high-merge-volume correctness problem GitHub Actions has no direct first-party equivalent for.
Part 7 covers Bitbucket and Bitbucket Pipelines — Atlassian's entry in this space, notable for its deep native integration with Jira and its Pipes-based reusability model, continuing the same comparative approach against everything covered for GitHub and GitLab.
Auto DevOps and compliance pipelines are worth carrying forward as a specific lens for that comparison: ask, for each new platform, not just "how do I write a pipeline" but "how much does the platform itself do on my behalf, and how much of the pipeline's own governance can I make impossible for an individual project to quietly opt out of." Those two questions turn out to be where GitHub, GitLab, Bitbucket, and Azure DevOps diverge most, more than any difference in raw YAML syntax.
That framing carries forward directly into Part 7's look at Bitbucket, a platform built by a company (Atlassian) whose core product identity is project/issue tracking rather than either "git host" or "DevOps platform" — worth watching for how that different starting point shapes its own CI/CD answers to the same questions.
Keep the direct-comparison table from this chapter open while reading Part 7 — it grows by one column, not by starting over.