Part 6 of 832 min read · 8 diagramsAI-assisted

GitLab & GitLab CI/CD

Table of Contents#

  1. Where GitLab Fits — a Different Philosophy Than GitHub
  2. GitLab Editions and Pricing at a Glance
  3. Anatomy of .gitlab-ci.yml
  4. Stages and the Default Sequential Model
  5. A Minimal Pipeline, Built Up Step by Step
  6. DAG Pipelines — Breaking Free of Stage Ordering with needs
  7. Rules, Workflow Rules, and When Jobs Run
  8. Matrix Builds — parallel:matrix
  9. Artifacts and Caching
  10. include and CI/CD Components — GitLab's Reusability Model
  11. Parent-Child and Multi-Project Pipelines
  12. Protected Branches, Protected Environments, and Approval Rules
  13. Merge Trains
  14. OIDC — ID Tokens for Cloud Authentication
  15. A Full Worked OIDC Example: Deploying to AWS
  16. Secrets and CI/CD Variables
  17. Built-In Security Scanning — SAST, DAST, Dependency Scanning
  18. GitLab Runners — Shared, Group, Project, and Self-Managed
  19. A Full Realistic Multi-Stage Pipeline
  20. GitHub Actions vs. GitLab CI/CD — A Direct Comparison
  21. Auto DevOps — Zero-Configuration Pipelines
  22. Compliance Pipelines and Policy-as-Code
  23. Common Mistakes
  24. Worked Practice Problems
  25. 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#

TierKey CI/CD-relevant featuresTypical fit
FreeUnlimited public repos, 400 compute minutes/month (SaaS), basic pipelinesIndividuals, small open-source projects
PremiumMerge trains, protected environments with approval rules, multiple approval rules per MR, higher compute minutesGrowing teams needing real release governance
UltimateFull SAST/DAST/fuzz testing suite, compliance pipelines, advanced vulnerability management, higher minutesRegulated or security-mature organizations

Two structural facts worth knowing before writing a single pipeline:

  1. 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.
  2. 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, deploy are 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's script: 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
VersioningManual (a Git ref, if you remember to pin it)Native semantic versioning (@2.1.0)
DiscoverabilityNone — you have to already know it existsListed in the CI/CD Catalog, searchable
Typed inputsNo — just YAML mergeYes — spec:inputs: with types and defaults
GitLab's own directionBeing phased toward Components over timeThe 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:

ScopeVisible toNotes
Instance (self-managed only)Every project on the instanceRare — broad shared credentials
GroupEvery project in the group (and subgroups)Shared credentials across related projects
ProjectJust this projectThe common case
Protected (a flag, any scope)Only pipelines running on a protected branch/tagThe 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 typeWhat it findsRough GitHub equivalent
SASTSource-code vulnerability patterns (static analysis)CodeQL
Dependency ScanningKnown-vulnerable dependenciesDependency review / Dependabot alerts
Secret DetectionCommitted credentialsSecret scanning
DASTVulnerabilities 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 ScanningVulnerabilities in a built container imageThird-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 typeScopeRough GitHub equivalent
Shared / Instance runnersAvailable to every project on the GitLab instanceGitHub-hosted runners
Group runnersAvailable to every project within one groupNo direct GitHub equivalent (GitHub self-hosted runners can be org-scoped, which is close)
Project runnersRegistered to, and usable by, one specific project onlyA 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 ActionsGitLab CI/CD
Pipeline file(s)Many independent files in .github/workflows/Typically one .gitlab-ci.yml, often split via include
Default job schedulingParallel unless needs:Sequential by stage unless needs: (DAG)
Reusable stepsComposite Actionsextends: / shared scripts
Reusable jobs/pipelinesReusable Workflows (workflow_call)CI/CD Components, include
Cross-repo/project reuseReusable workflow in another repoCI/CD Catalog components, or include: project:
Manual approval gateEnvironment required reviewersProtected environment + when: manual
Merge-time correctness at high volumeThird-party merge-queue toolsNative merge trains
OIDC to cloudpermissions: id-token: writeid_tokens:
Built-in SAST/DAST/dependency scanningOpt-in via GHAS + third-party ActionsNear one-line include, deeply integrated by default
Artifact passing between jobsExplicit upload/download stepsAutomatic via needs:/stage order
Platform philosophyGit host + first-party CI/CD productSingle 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#

MistakeWhy it's a problemFix
No workflow:rules: guardGitLab creates a pipeline for every push to every branch by default, wasting compute minutesAdd a top-level workflow:rules: scoping which events actually create a pipeline
Forgetting needs: in a large pipelineEvery stage waits for the ENTIRE previous stage, even jobs with no real dependencyUse needs: to build a genuine DAG wherever stage-sequential ordering isn't actually required
Treating include: templates as unversionedA plain include: of a mutable file can change underneath you with no review, similar to GitHub's unpinned-tag riskPin include: project: / ref: to a specific tag, or migrate to versioned CI/CD Components
A secret/CI-CD variable created without the Protected flagVisible to pipelines on any branch, not just trusted onesFlag 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 correctCheck 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 inputsPlain YAML merge has no typed inputs or versioning, drifts easilyUse 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 governanceAnyone with pipeline access can click "run" — there's no approval gate, just a pause buttonCombine when: manual with an actual Protected Environment carrying an approval rule
One enormous flat .gitlab-ci.yml for a large monorepoEvery rules: changes: condition adds complexity; the whole pipeline is one hard-to-debug unitSplit into parent-child pipelines per subproject, triggered conditionally by what actually changed
Assuming Auto DevOps fits every project just because it's zero-configIt's tuned for conventionally-structured apps deploying to Kubernetes; a bespoke release process gets little value from itUse 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: neverA job with its own passing rules: still never runs if the pipeline itself was never createdFix 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.