GitLab CI/CD Pipeline Structure: How Pipelines, Stages, Jobs, Artifacts and Cache Fit Together

By Vishvesh Patel · DevOps EngineerUpdated September 12, 2026

The architecture of a GitLab pipeline. How pipelines, stages and jobs relate, what runners do, and where artifacts and cache belong.

GitLab CI/CD Pipeline Structure

This page is about the shape of a GitLab pipeline. What the moving parts are, how they relate, and where each concept belongs. It is the map. The linked guides are the territory.

If you want the full syntax for defining stages and writing jobs, go straight to GitLab CI Stages and Jobs.

Four layers

Everything in GitLab CI/CD sits in one of four layers, and most confusion comes from mixing them up.

LayerWhat it isWhere it is defined
PipelineOne end-to-end run, triggered by an eventCreated by GitLab from .gitlab-ci.yml
StageA named group of jobs that run togetherstages: list
JobOne unit of work with a scriptA top-level key in .gitlab-ci.yml
RunnerThe machine or container that executes a jobRegistered separately, outside the file

Read downward: a push creates a pipeline, the pipeline contains stages in order, each stage contains jobs that run in parallel, and each job is handed to a runner that actually runs the script.

The layer people forget is the runner. GitLab itself never executes anything. It decides what should run and hands it off. That is why a pipeline with no available runner sits pending forever with no error: nothing is wrong with your configuration, there is simply nobody to do the work.

The default flow

stages:
  - build
  - test
  - deploy

build-app:
  stage: build
  script: npm run build

unit-tests:
  stage: test
  script: npm test

lint:
  stage: test
  script: npm run lint

deploy-staging:
  stage: deploy
  script: ./deploy.sh staging

That pipeline runs in three waves:

  1. build-app runs alone.
  2. unit-tests and lint run at the same time, because they share a stage.
  3. deploy-staging runs.

Two rules produce that behaviour, and they are worth stating explicitly because everything else builds on them:

  • Jobs in the same stage run in parallel.
  • A stage does not start until every job in the previous stage has succeeded.

A job with no stage: key goes into test by default, which regularly puts jobs somewhere their author did not intend.

Breaking the stage lockstep

Strict stage ordering is simple but wasteful. If lint takes 20 seconds and unit-tests takes 8 minutes, the deploy waits 8 minutes even though it only really depends on the build.

needs: replaces stage ordering with explicit dependencies, turning the pipeline into a directed acyclic graph:

deploy-staging:
  stage: deploy
  needs: [build-app]        # start as soon as the build is done
  script: ./deploy.sh staging

The job now starts the moment build-app finishes, regardless of whether the test stage is still running. On pipelines with uneven job durations this is often the single largest speed-up available.

The trade-off is legibility, a DAG is harder to reason about than a column of stages, so it is worth applying where the saving is real rather than everywhere.

Where artifacts and cache fit

Every job runs in a fresh, isolated environment. Nothing carries over between jobs by default: not files, not installed packages, not environment variables set with export. That isolation is what makes pipelines reproducible, and it is also why two separate mechanisms exist for moving data between jobs.

ArtifactsCache
PurposePreserve job outputReuse job input
Guaranteed presentYesNo
Visible in the UIYes, downloadableNo
ScopeWithin a pipeline, and afterwardsAcross pipelines
Typical contentsBuild output, test reports, deploy bundlesnode_modules, pip cache, Maven repository

The distinction that actually matters is correctness versus speed.

Artifacts are a guarantee. A job that declares an artifact will have it available to the jobs that need it, and you can download it weeks later from the pipeline page.

Cache is an optimisation with no guarantee. It may be missing on a new branch, stale from an older run, or absent because the job landed on a different runner. A pipeline must still produce a correct result with every cache empty, if it does not, something that should be an artifact is being treated as cache.

The practical test: would the pipeline still be correct if this data vanished? If yes, cache. If no, artifact.

Full detail in Artifacts and Cache.

What triggers a pipeline

Not all pipelines are the same kind, and several CI variables behave differently depending on which one is running. The most common source of rules that mysteriously never match.

TypeTriggered byNote
Branch pipelineA push to a branch$CI_COMMIT_BRANCH is set
Merge request pipelineOpening or updating a merge request$CI_COMMIT_BRANCH is not set
Scheduled pipelineA cron schedule in the UIGood for nightly integration suites
Tag pipelinePushing a tagThe usual release trigger
Child pipelineA trigger: job in the same projectSplits large configurations
Multi-project pipelineA trigger: job naming another projectCoordinates across repositories

A rule written as $CI_COMMIT_BRANCH == "main" will never match inside a merge request pipeline, because that variable is not populated there. This catches almost everyone once.

Controlling the pipeline as a whole

Individual jobs are filtered with rules:. The pipeline itself is governed by a top-level workflow: block:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH

That block is worth understanding because it solves the most common annoyance in GitLab CI: duplicate pipelines. Without it, a push to a branch with an open merge request creates both a branch pipeline and a merge request pipeline, doubling your runner minutes and cluttering the UI. The third rule suppresses the branch pipeline when a merge request already exists.

Defining this once at the top is much better than repeating the same conditions on every job.

Putting the map together

  push / MR / tag / schedule
            │
            ▼
       ┌─────────┐
       │ workflow│  should this pipeline exist at all?
       └────┬────┘
            ▼
   stage: build ──────────► job: build-app ──► runner
            │                     │
            │                     └─ artifacts: dist/
            ▼
   stage: test  ──────────► job: unit-tests ─► runner   ┐ parallel
                └─────────► job: lint ───────► runner   ┘
            │                     │
            │                     └─ artifacts: reports: junit
            ▼
   stage: deploy ─────────► job: deploy-staging ► runner
                                  │
                                  └─ environment: staging

Cache sits underneath all of it, restored at the start of each job and persisted across pipelines.

Where to go next

TopicWhat you will learn
Stages and JobsFull syntax, job keywords, rules, parallel jobs and needs
ArtifactsPassing files between jobs, report types, expiry
CacheCache keys, policies, fallbacks and invalidation
Pipeline RulesControlling exactly when jobs and pipelines run
RunnersExecutors, registration, tags and concurrency

Final Takeaway

A GitLab pipeline is four layers: an event creates a pipeline, which contains ordered stages, which contain parallel jobs, each executed by a runner. Jobs are isolated from one another, so artifacts move guaranteed output forward and cache speeds up repeated inputs without any guarantee. workflow: decides whether a pipeline runs at all; rules: decides which jobs are in it; needs: lets you escape strict stage ordering when it costs too much time.

Get those relationships straight and the syntax in the detailed guides is mostly bookkeeping.


Frequently Asked Questions

What is the difference between a pipeline, a stage and a job?

A job is a single unit of work with its own script, running in its own isolated environment on a runner. A stage is a named group of jobs that execute in parallel with each other. A pipeline is one complete run of all the stages, triggered by an event such as a push, a merge request, a tag or a schedule. Stages run in sequence and, by default, each waits for every job in the previous stage to succeed before starting.

Do jobs in the same stage run at the same time?

Yes, provided runners are available to take them. That is the defining property of a stage. Putting two jobs in test means they run concurrently, and the stage is complete only when both finish. If jobs in a stage appear to run one after another, you have fewer free runners than jobs, or the runner's concurrent setting limits how many it will execute simultaneously.

What is the difference between artifacts and cache?

Artifacts preserve a job's output and are guaranteed to reach the jobs that need them, as well as being downloadable from the GitLab UI. Cache stores reusable inputs such as node_modules to make later runs faster, with no guarantee it will be there. It can be missing on a new branch, stale, or absent because the job ran on a different runner. The test is whether the pipeline would still be correct if the data vanished: if yes, cache; if no, artifacts.

Why are two pipelines created every time I push?

Because both branch pipelines and merge request pipelines are running for the same commit. When a branch has an open merge request, GitLab creates a pipeline for the branch and another for the merge request unless you tell it otherwise. The standard fix is a top-level workflow: rules: block that runs the merge request pipeline when one exists, suppresses the branch pipeline in that case, and always runs on the default branch. Defining it once at the top applies it to the whole pipeline.

Why doesn't my rule match on merge requests?

Because $CI_COMMIT_BRANCH is not set in a merge request pipeline. It only exists in branch pipelines, so a condition written against it silently never matches when the pipeline was triggered by a merge request event. Use $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME for the branch name in that context, or branch on $CI_PIPELINE_SOURCE == "merge_request_event" to distinguish the pipeline type explicitly.

Can a job start before the previous stage finishes?

Yes, with needs:. Listing the specific jobs a job depends on lets it begin as soon as those have completed, ignoring stage boundaries and turning the pipeline into a directed acyclic graph. This can cut total runtime substantially when job durations are uneven. A deploy that only needs the build no longer waits for a slow test suite. The cost is that the pipeline becomes harder to read, so it is worth using where the time saving is genuine rather than applying it everywhere.

Continue Learning

Explore Related Topics

Try the Tool

When it breaks

Related Resources