Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

GitLab logo

You worked through the 13 previous lessons separately: stages, variables, rules, cache, environments, reports. Now, put them together. This lesson is a mini-capstone: you build a pipeline end to end for a real application, applying all the building blocks you learned.

The goal is not to learn something new, but to check that everything fits together. If you get stuck, each section points back to the relevant lesson. This summary closes the part about the pipeline itself (levels C1 and C2, grouped in the Fundamentals); the next two lessons, Registries and Security scanners, cover what it produces and what it lets through.

What you will learn

  • Assemble a complete .gitlab-ci.yml combining all the building blocks of levels C1 and C2
  • Structure the stages in a logical order (lint → test → build → deploy)
  • Apply good practices: protected variables, targeted rules, optimized cache
  • Diagnose a broken pipeline quickly with a systematic method
  • Assess whether your pipeline is ready for industrialization (level C3)

Prerequisites

This lesson assumes that you have followed the 13 course lessons of levels C1 and C2 that it recaps: it no longer explains what a stage, a cache or a rule is, it assembles them. You must know how to write a .gitlab-ci.yml, validate it, read the logs of a job, tell cache from artifacts, protect a variable, write rules, declare an environment and wire up test and coverage reports. Being able to read Node.js commands (npm ci, npm test, npm run build) helps with the example, but nothing runs on your machine: the jobs run in container images.

As for permissions and tools, you need a GitLab project where you hold at least the Maintainer role: saving a variable in Settings > CI/CD > Variables requires it, and the Developer role does not grant access (with a Developer role, the variables endpoint answers 403 Forbidden). That is where you push branches and tags, and open a merge request. You also need a runner that runs jobs in container images; no particular GitLab or runner version is required. The staging and production addresses are fictional: the deploy jobs only print a message and deploy nothing. Upstream lessons:

In which context?

You have finished the 13 previous lessons. You know how to create a job, configure a runner, use the cache and artifacts, write rules, set up environments, generate reports. But when you open a production .gitlab-ci.yml, you see 200 lines and you do not know where to start.

This lesson puts you in that situation:

  • You join a project with an incomplete pipeline
  • You must build a complete pipeline for a Node.js app
  • The pipeline must build, test, analyze quality and deploy to staging then production
  • You must handle these cases: push on a branch, merge request, release tag

The target pipeline

Here is the complete pipeline you are going to build, brick by brick. Each section is annotated, in parentheses, with the title of the lesson or lessons that explain it in detail, as it appears in the course outline. The self-assessment table at the end of the page matches each skill to its lesson.

Do not try to understand everything at once. Read it in the order of the comment numbers: sections 1 to 3 set up the skeleton (stages, variables, cache) and contain no job. Sections 4 to 9 declare the jobs themselves, from the quickest to the most committing. Each decision that is not obvious when reading is taken up and justified right after.

.gitlab-ci.yml
# ── 1. Stages: the execution order (Basic concepts) ─────────────────────────
stages:
- lint
- test
- build
- deploy
# ── 2. Global variables (Variables and secrets) ─────────────────────────────
variables:
NODE_VERSION: "20"
APP_NAME: "mon-app"
DEPLOY_PATH: "/srv/$APP_NAME"
# Automatic CI_ variables: CI_COMMIT_REF_NAME, CI_PIPELINE_SOURCE…
# ── 3. Global cache (Artifacts and cache) ───────────────────────────────────
default:
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull
# ── 4. Lint (Basic concepts, Execution conditions) ──────────────────────────
lint:
stage: lint
image: node:${NODE_VERSION}
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
policy: pull-push
script:
- npm ci --prefer-offline
- npm run lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 5. Unit tests + coverage (Artifacts and cache, Quality reports) ─────────
test:unit:
stage: test
image: node:${NODE_VERSION}
script:
- npm ci --prefer-offline
- npm test -- --coverage --reporters=default --reporters=jest-junit
artifacts:
when: always
paths:
- coverage/
reports:
junit: junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
expire_in: 7 days
coverage: '/All files\s*\|\s*(\d+\.?\d*)\s*\|/'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
# ── 6. Build (Artifacts and cache, Variables and secrets) ───────────────────
build:
stage: build
image: node:${NODE_VERSION}
script:
- npm ci --prefer-offline
- npm run build
- echo "BUILD_VERSION=$(node -p 'require(\"./package.json\").version')" >> build.env
artifacts:
paths:
- dist/
reports:
dotenv: build.env
expire_in: 30 days
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
# ── 7. Staging deployment (Environments, Execution conditions) ──────────────
deploy:staging:
stage: deploy
image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
environment:
name: staging
url: https://staging.example.com
on_stop: stop:staging
script:
- echo "Deploying $APP_NAME v$BUILD_VERSION to staging..."
- echo "Target → $DEPLOY_PATH on staging server"
# Replace with your deployment method (rsync, scp, kubectl…)
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 8. Stop staging (Environments) ──────────────────────────────────────────
stop:staging:
stage: deploy
image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
environment:
name: staging
action: stop
script:
- echo "Stopping staging environment..."
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
allow_failure: true
# ── 9. Production deployment (Environments, Triggers, Variables and secrets) ───
deploy:production:
stage: deploy
image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
environment:
name: production
url: https://www.example.com
variables:
DEPLOY_STRATEGY: "rolling"
script:
- echo "Deploying $APP_NAME v$BUILD_VERSION to production..."
- echo "Strategy → $DEPLOY_STRATEGY"
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
allow_failure: false
resource_group: production

Breaking down each decision

Why this stage order?

stages: [lint, test, build, deploy]
OrderStageReason
1lintFastest feedback, style errors in a few seconds
2testValidates the business logic, slower than lint
3buildBuilds the artifact, pointless if the tests fail
4deployDeploys, only if everything is green

Principle: the quickest and cheapest jobs first. If the lint fails in 10 seconds, there is no point starting 5 minutes of tests.

→ Reference lesson: Basic concepts

Why package-lock.json as the cache key?

cache:
key:
files:
- package-lock.json

The cache is invalidated only when the dependencies change. As long as package-lock.json does not move, the node_modules/ are reused. It is more reliable than $CI_COMMIT_REF_SLUG, which creates one cache per branch.

The pull-push policy on the lint job (the first to run) feeds the cache. The following jobs use pull (read-only) to avoid concurrent writes.

→ Reference lesson: Artifacts and cache

Why rules and not only/except?

rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

rules is the recommended mechanism since GitLab 12.3. It replaces only/except, which cannot handle complex conditions. Each if is evaluated in order: the first rule that matches determines whether the job runs and with which when.

Beware of the classic trap: if no rule matches, the job is excluded from the pipeline. No rules at all = the job always runs.

→ Reference lesson: Execution conditions, rules

Why JUnit and Cobertura reports?

artifacts:
reports:
junit: junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml

These reports plug directly into the GitLab interface:

  • JUnit: test results appear in the pipeline's "Tests" tab and in the merge request
  • Cobertura: the MR diff shows which lines are covered or not; the overall percentage, read by the job's coverage: expression, feeds the project badge

Without these reports, you have to dig through the logs to find which tests failed.

→ Reference lesson: Quality reports

Why environment and on_stop?

environment:
name: staging
url: https://staging.example.com
on_stop: stop:staging

GitLab environments give you:

  • A deployment history visible in the interface
  • A direct link to the application URL
  • A Stop button for temporary environments (review apps)

The on_stop is essential for cleanup. GitLab triggers the stop:staging job in three situations: when you click Stop in the interface, when the associated branch is deleted, or when an auto_stop_in delay expires if you declare one. Without on_stop, a test environment created per branch stays listed indefinitely and its resources are never released.

The stop job is itself manual. Under rules, a manual job blocks the pipeline by default (allow_failure is then false): that is why stop:staging declares allow_failure: true, otherwise every push to the default branch would leave the pipeline waiting for a click on a cleanup job.

→ Reference lesson: Environments

Why when: manual and resource_group in production?

rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
when: manual
allow_failure: false
resource_group: production

A production deployment must withstand two scenarios: the accidental trigger (a commit on the wrong branch) and the concurrent deployment (two pipelines writing at the same time on the same server). No single keyword covers both, hence the stack of four complementary safeguards listed below.

The one most often forgotten is resource_group: without it, two tags pushed one minute apart launch two deployment jobs in parallel, and the order of arrival on the target server is not guaranteed.

MechanismProtection
when: manualA human must click to deploy, no accidental deployment
allow_failure: falseThe pipeline stays waiting until someone triggers the job (manual status, no false green)
resource_groupOnly one production deployment at a time, avoids race conditions
Regex on the tagOnly tags in the vX.Y.Z format trigger the job

This manual status is a terminal state: the pipeline no longer moves on its own and waits for a human decision, it never becomes success by itself. So do not measure the health of a pipeline by its overall status, but by its jobs: none failed, none canceled.

→ Reference lessons: Triggers and Variables

Clean pipeline checklist

Before pushing your pipeline, check each point. The six families below match the six places where a pipeline degrades over time: structure, variables, cache, conditions, environments and validation.

Structure

Structure is what you read first when a pipeline fails six months after it was written. The job name is the only information shown in the pipeline view and in the failure notification: test:unit tells you immediately what to rerun, job1 forces you to reopen the YAML. As for the stage order, it determines the feedback time: badly ordered, a pipeline can take eight minutes to report a style error detectable in ten seconds. One last trap: a job placed in a stage that stages: does not declare makes GitLab refuse the whole configuration (see the Validation section).

  • Stages are ordered from the fastest to the slowest
  • Each job has an explicit stage, declared in stages:
  • No job without a script (or trigger)
  • Job names are descriptive (test:unit, not job1)

Variables

The critical point is scope. A variable declared at the root applies to all jobs, a job variable overrides the global value, and a variable defined in the project interface wins over both. This precedence explains most of the "my variable is empty" cases: the value exists, but another level replaced it.

On the secrets side, two attributes add up and do not do the same thing. Masked replaces the value with [MASKED] in the job logs. Protected forbids injecting the variable outside protected branches and tags. A production secret must carry both.

  • No secret in the .gitlab-ci.yml, everything in Settings > CI/CD > Variables
  • Sensitive variables are masked and protected
  • Global variables are in variables: at the root level
  • Specific variables are in the job concerned

Cache and artifacts

The two mechanisms look alike and serve opposite purposes. The cache is an optimization: if it is lost, the pipeline stays correct, only slower. An artifact is a deliverable: if it is lost, the next job fails. A dist/ cached instead of published as an artifact produces exactly this symptom, a deployment that works every other time depending on which runner picked up the job.

The when: always on test artifacts deserves particular attention: by default, GitLab only keeps artifacts if the job succeeds. But it is precisely when the tests fail that you want to read the JUnit report.

  • The cache uses a key based on a lock file (package-lock.json, Gemfile.lock…)
  • Artifacts have a reasonable expire_in (no infinite retention)
  • Reports (JUnit, Cobertura) are declared in artifacts:reports
  • when: always on test artifacts (to see the failures)

Rules

A rule that is too permissive never causes a visible error: the job runs, it simply runs too often. The cost is paid in compute minutes and, for a deployment job, in incidents. A deploy triggered from a merge request pushes the code of an unreviewed branch onto a real server.

Reread each rules block asking yourself a single question: what are all the situations in which this condition becomes true? Merge request pipelines, tag pipelines and scheduled pipelines are among them, and they are the ones people forget.

  • Each job has explicit rules (no only/except)
  • Deployment jobs do not run on merge requests
  • The production deployment requires when: manual
  • No overly permissive rule (if: $CI_COMMIT_BRANCH without a filter)

Environments

Declaring an environment changes nothing about how the script runs: it is a declaration for GitLab, which builds from it the deployment history, the link to the application and the stop button. A deployment job without environment works, but you lose track of who deployed what and when, exactly the information asked for on the day of an incident.

  • Each deployment job declares an environment
  • Temporary environments have an on_stop
  • Production uses resource_group

Validation

A YAML syntax error does not break a job, it prevents the entire pipeline from starting. The feedback then arrives as a generic message in the interface, several minutes after the push. Validating before pushing brings that delay down to a few seconds and avoids polluting the branch history with successive "fix ci" commits.

The most puzzling case is a job placed in a stage that stages: does not declare: GitLab refuses the whole configuration, the pipeline is born failed with zero jobs, and only CI Lint names the cause, the interface does not give it.

Local validation, however, only covers syntax. Check the behavior separately: a pipeline that is correct in push mode may trigger nothing in merge request mode if the rules do not provide for that case.

  • The YAML is validated before the push (CI Lint or VS Code extension)
  • The pipeline runs correctly in MR mode and in push mode
  • Reports are displayed in the GitLab interface

Quick diagnosis: the 5 most frequent errors

When a complete pipeline does not work, the temptation is to reread the YAML from top to bottom. It is rarely worthwhile: on a pipeline assembled from these building blocks, five symptoms each point to a precise cause, before you open the file.

So follow the order below, from the most global to the most local: first, does the pipeline exist; then, does the job start; finally, does the script do what is expected.

  1. No job at all?

    Two symptoms, two causes. If the Pipelines tab stays empty, with no message, no rule of workflow:rules matches: GitLab created no pipeline (it is neither a "skipped" pipeline nor a grey one). If a pipeline appears as failed with zero jobs, the configuration was refused as a whole, for example because of a stage missing from stages:: only CI Lint names the cause.

    → Debug: pipeline skipped and Validate a .gitlab-ci.yml file

  2. Job pending for 10 minutes?

    The runner is not available. Check the job's tags against the runner's, and that the runner is active.

    → Debug: job pending

  3. Job failed with "file not found"?

    The artifact from a previous job was not passed on. Check the producing job's artifacts:paths and that the stages are in the right order.

    → Artifacts and cache

  4. Empty variable in the script?

    Check the precedence: a job variable overrides a global variable, a protected variable is only available on protected branches.

    → Variables and secrets

  5. Rule that does not match?

    Add a temporary debug job that prints the CI variables:

    debug:rules:
    stage: lint
    script:
    - echo "SOURCE=$CI_PIPELINE_SOURCE"
    - echo "BRANCH=$CI_COMMIT_BRANCH"
    - echo "TAG=$CI_COMMIT_TAG"
    - echo "MR=$CI_MERGE_REQUEST_IID"
    rules:
    - when: always

    → Execution conditions, rules

Self-assessment: are you ready for level C3?

Answer honestly; the questions cover the 13 previous lessons. If you answer yes to at least 8 out of 10, you are ready for industrialization (level C3). The two lessons that follow, Registries and Security scanners, finish level C2 before you enter it.

#QuestionLesson
1I can write a .gitlab-ci.yml without copy-pasting an exampleGetting started, Writing a .gitlab-ci.yml
2I can validate my YAML syntax before pushingValidate a .gitlab-ci.yml file
3I can read a job's logs to find the errorDebug: reading logs
4I understand the difference between a shared runner and a project runnerRunners
5I know when to use cache and when to use artifactsArtifacts and cache
6I can protect a secret and understand variable precedenceVariables and secrets
7I can write rules with if, changes and existsExecution conditions, rules
8I can configure a pipeline for MRs, tags and schedulesTriggers
9I can create an environment with a stop actionEnvironments
10I can integrate JUnit and coverage reportsQuality reports

Key takeaways

  • A production pipeline combines all the building blocks: stages, variables, cache, rules, artifacts, environments, reports
  • The stage order follows the fast feedback principle: lint → test → build → deploy
  • The cache uses a lock file as its key, not the branch name
  • rules replace only/except, each job must have explicit conditions
  • Secrets go in Settings > CI/CD > Variables, never in the YAML
  • Production requires when: manual + resource_group + a tag regex
  • JUnit and Cobertura reports plug into the GitLab interface, use them
  • Validate before the push (CI Lint), and read a pipeline's health from its jobs: a waiting manual job gives the manual status, not success

Test your knowledge

This quiz only covers the building blocks assembled in this lesson. A hesitant answer on a question points to the lesson to reread before moving on to the registries, then industrialization.

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn