
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.ymlcombining 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:
- Getting started: pipeline, stage, job and runner
- First pipeline:
write a
.gitlab-ci.ymlthat runs - Validate your pipeline: check the syntax before pushing
- Debug logs: read a failing job
- Runners: who runs the jobs, and with which image
- Artifacts and cache: pass files along and speed up installs
- Variables and secrets: scope, precedence, masking and protection
- Rules: decide when a job runs
- Triggers: push, merge request, tag and schedule
- Environments: staging, production and the stop action
- Quality reports: JUnit and coverage in the interface
- Debug pending: a job no runner ever picks up
- Debug skip: a pipeline that is absent or skipped
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.
# ── 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: productionBreaking down each decision
Why this stage order?
stages: [lint, test, build, deploy]| Order | Stage | Reason |
|---|---|---|
| 1 | lint | Fastest feedback, style errors in a few seconds |
| 2 | test | Validates the business logic, slower than lint |
| 3 | build | Builds the artifact, pointless if the tests fail |
| 4 | deploy | Deploys, 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.jsonThe 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_BRANCHrules 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.xmlThese 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:stagingGitLab 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: falseresource_group: productionA 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.
| Mechanism | Protection |
|---|---|
when: manual | A human must click to deploy, no accidental deployment |
allow_failure: false | The pipeline stays waiting until someone triggers the job (manual status, no false green) |
resource_group | Only one production deployment at a time, avoids race conditions |
| Regex on the tag | Only 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 instages: - No job without a
script(ortrigger) - Job names are descriptive (
test:unit, notjob1)
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: alwayson 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(noonly/except) - Deployment jobs do not run on merge requests
- The production deployment requires
when: manual - No overly permissive rule (
if: $CI_COMMIT_BRANCHwithout 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.
-
No job at all?
Two symptoms, two causes. If the Pipelines tab stays empty, with no message, no rule of
workflow:rulesmatches: GitLab created no pipeline (it is neither a "skipped" pipeline nor a grey one). If a pipeline appears asfailedwith zero jobs, the configuration was refused as a whole, for example because of a stage missing fromstages:: only CI Lint names the cause.→ Debug: pipeline skipped and Validate a .gitlab-ci.yml file
-
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.
-
Job failed with "file not found"?
The artifact from a previous job was not passed on. Check the producing job's
artifacts:pathsand that the stages are in the right order. -
Empty variable in the script?
Check the precedence: a job variable overrides a global variable, a protected variable is only available on protected branches.
-
Rule that does not match?
Add a temporary debug job that prints the CI variables:
debug:rules:stage: lintscript:- echo "SOURCE=$CI_PIPELINE_SOURCE"- echo "BRANCH=$CI_COMMIT_BRANCH"- echo "TAG=$CI_COMMIT_TAG"- echo "MR=$CI_MERGE_REQUEST_IID"rules:- when: always
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.
| # | Question | Lesson |
|---|---|---|
| 1 | I can write a .gitlab-ci.yml without copy-pasting an example | Getting started, Writing a .gitlab-ci.yml |
| 2 | I can validate my YAML syntax before pushing | Validate a .gitlab-ci.yml file |
| 3 | I can read a job's logs to find the error | Debug: reading logs |
| 4 | I understand the difference between a shared runner and a project runner | Runners |
| 5 | I know when to use cache and when to use artifacts | Artifacts and cache |
| 6 | I can protect a secret and understand variable precedence | Variables and secrets |
| 7 | I can write rules with if, changes and exists | Execution conditions, rules |
| 8 | I can configure a pipeline for MRs, tags and schedules | Triggers |
| 9 | I can create an environment with a stop action | Environments |
| 10 | I can integrate JUnit and coverage reports | Quality 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
rulesreplaceonly/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
manualstatus, notsuccess
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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab registries: build and publish images and packages
A new full quiz with random questions
Work again only on the questions you missed