
You worked through the 11 course lessons of level C3 (Industrialise) separately: templates, DAG, components, services, matrices, child pipelines, multi-project, workflows, reliability. Now, assemble them. This capstone drops you into a realistic scenario: industrializing the pipeline of a team that manages three microservices, a SPA frontend and a shared library.
The goal is not to learn new directives, but to check that all the advanced techniques fit together. Each decision points back to the relevant lesson.
What you will learn
- Combine templates, DAG, components and matrices in an industrial pipeline
- Orchestrate multi-service builds with child pipelines and multi-project pipelines
- Apply a suitable workflow (MR + tags) with built-in reliability
- Diagnose the problems of a complex pipeline
- Assess industrial maturity with a grid of criteria
Prerequisites
This capstone assumes that you have followed the whole of level C3: the
11 industrialisation course lessons, from extends to reliability, which
the design-principles table calls on one by one. It does not re-explain a
component, a DAG, a matrix or a retry:when: it combines them. It
also assumes the basics of level C2, in particular rules, environments
and publishing an image to a registry, since the pipeline builds and pushes
a Docker image.
On the permissions side, you need the Maintainer or Owner role on a
GitLab group: creating projects in it and publishing a component to the
Catalog are group management actions, out of reach of a Developer role.
On the version side, only strategy: mirror sets a floor, GitLab 18.2 or
later. The example assumes a complete environment that you must have at hand
before reproducing anything: that group where you create several projects
(a monorepo for the services and the frontend, a shared-lib project, one
project per API triggered in multi-project, and a ci-components project that
publishes the component to the Catalog), a runner able to run jobs in
container images and to run docker build with the docker:27-dind service
(whose setup is not detailed here), and the project's container registry,
since the image is pushed to $CI_REGISTRY_IMAGE. The names myorg and
deploy.sh and the URLs are fictional and yours to replace, and you will
supply your own credentials for the twine upload. Reading the lesson requires
none of this; rebuilding it does: the lab in the "Hands-on lab" section below
replays it on a prepared forge. Upstream lessons:
- Extends and YAML anchors: the hidden templates the component provides and jobs extend
- Include templates:
factor with
include, before moving to the component - Components & Catalog:
spec:inputsand publishing - DAG and parallelism:
needs, andneeds: []to start lint and test without waiting - Matrices (parallel:matrix): test several Python versions
- Services and advanced cache: PostgreSQL and Redis next to the job
- Parent-child pipelines:
trigger:includeandstrategy: mirror - Dynamic pipelines: the level lesson on generated pipelines: the example below does not use them, but the final lab requires one, as well as test reports (see Quality reports)
- Multi-project:
trigger:projectand variable propagation - CI/CD workflows:
workflow:rulesandrules:changes - Reliability:
targeted retry, timeouts,
interruptibleandresource_group - GitLab registries: push an image to the project registry
In which context?
You are a DevOps engineer in a team that manages a product made of:
- api-gateway: Go, entry point, routing
- api-users: Python, user management
- api-orders: Node.js, order management
- frontend: React SPA consuming the 3 APIs
- shared-lib: internal validation library, used by the 3 APIs
The current pipeline? A 400-line .gitlab-ci.yml with no templates, everything duplicated, 25 minutes of execution, no retry. Your mission: industrialize.
Target architecture
The target pipeline uses several levels of orchestration:
shared-lib (multi-project) ↓ triggers the consumers' pipelinesapi-gateway ──┐api-users ────┼── parent pipeline → child pipelines per serviceapi-orders ───┘frontend ──────── dedicated child pipeline (triggered by its own changes)Design principles
Each row of the table matches a team need to the GitLab technique that answers it and to the level C3 lesson that teaches it. Read it as a decision table: if you hesitate over a choice in the steps below, it tells you where to go back.
| Decision | Technique | Lesson |
|---|---|---|
| Factor out common jobs | Templates + Components | Templates, Components & Catalog |
| Start lint and test without waiting | DAG (needs: []) | DAG and parallelism |
| Test on 3 OS / 3 versions | Matrices | Matrices (parallel:matrix) |
| Build each service separately | Child pipelines | Parent-child pipelines |
| Trigger when shared-lib changes | Multi-project | Multi-project |
| Run only on MR + tags | Workflows | Workflows |
| Absorb transient errors | Reliability | Reliability |
Step 1: the reusable component
Start by creating a CI/CD Component for the pattern common to all services: lint → test → Docker build → push.
spec: inputs: service_name: type: string image: type: string # FULL, pinned reference: image:tag@sha256:... # A tag can be republished with different content; the @sha256 # digest designates the exact image (supply chain). test_command: type: string default: "make test" docker_context: type: string default: "."
---
.lint-$[[ inputs.service_name ]]: stage: lint image: $[[ inputs.image ]] script: - make lint needs: []
.test-$[[ inputs.service_name ]]: stage: test image: $[[ inputs.image ]] services: - postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 variables: POSTGRES_DB: test POSTGRES_USER: test POSTGRES_PASSWORD: test script: - $[[ inputs.test_command ]] needs: [] retry: max: 1 when: - runner_system_failure - stuck_pending_with_matching_runners
.build-$[[ inputs.service_name ]]: stage: build image: docker:27@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce services: - docker:27-dind@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce script: - docker build -t $CI_REGISTRY_IMAGE/$[[ inputs.service_name ]]:$CI_COMMIT_SHA -f $[[ inputs.docker_context ]]/Dockerfile $[[ inputs.docker_context ]] - docker push $CI_REGISTRY_IMAGE/$[[ inputs.service_name ]]:$CI_COMMIT_SHA # No needs: here. The .test-... job is hidden (see the paragraph # below): a needs pointing to it would target a job absent from the # pipeline. Without needs, the build waits for the test stage to finish. retry: max: 2 when: - runner_system_failure - stuck_pending_with_matching_runners - unknown_failureThe image input receives a full reference of the form image:tag@sha256:…. The tag states the wanted version, the digest (the sha256 fingerprint of the content) freezes the exact image: without it, a republished tag would silently change the image of your jobs. Each service passes its own at call time (step 3), and the postgres and docker images of the component are pinned the same way.
The three jobs of the component start with a dot: they are hidden jobs. The GitLab documentation states that "Hidden jobs are not processed by GitLab CI/CD, but they can be used as templates for reusable configuration" (for example with extends). The component therefore offers templates, and no job runs until a pipeline extends them: this is what step 3 does. Since a hidden job is not processed, it is not added to the pipeline, so the build job has no needs: pointing to .test-…: the documentation describes a needs: on a job that is absent as a failure to start the pipeline ('job1' job needs 'job2' job, but it was not added to the pipeline). Without needs:, the build simply waits for the end of the test stage.
Link: Components & Catalog for the spec:inputs syntax and publishing.
Step 2: the parent pipeline
The parent pipeline orchestrates: it launches one child pipeline per service, in parallel.
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG =~ /^v\d+/
default: interruptible: true retry: max: 1 when: - runner_system_failure - stuck_pending_with_matching_runners
stages: - trigger - deploy
# ── Child pipelines ────────────────────────────────────────────trigger:api-gateway: stage: trigger trigger: include: services/api-gateway/.gitlab-ci.yml strategy: mirror rules: - changes: - services/api-gateway/**/* - shared-lib/**/*
trigger:api-users: stage: trigger trigger: include: services/api-users/.gitlab-ci.yml strategy: mirror rules: - changes: - services/api-users/**/* - shared-lib/**/*
trigger:api-orders: stage: trigger trigger: include: services/api-orders/.gitlab-ci.yml strategy: mirror rules: - changes: - services/api-orders/**/* - shared-lib/**/*
trigger:frontend: stage: trigger trigger: include: frontend/.gitlab-ci.yml strategy: mirror rules: - changes: - frontend/**/*
# ── Global deployment (after the children succeed) ─────────────deploy:staging: stage: deploy interruptible: false script: ./deploy.sh staging resource_group: staging environment: name: staging url: https://staging.example.com needs: - job: trigger:api-gateway optional: true - job: trigger:api-users optional: true - job: trigger:api-orders optional: true - job: trigger:frontend optional: true rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy:production: stage: deploy interruptible: false script: ./deploy.sh production --atomic resource_group: production environment: name: production url: https://app.example.com needs: - job: trigger:api-gateway optional: true - job: trigger:api-users optional: true - job: trigger:api-orders optional: true - job: trigger:frontend optional: true rules: - if: $CI_COMMIT_TAG =~ /^v\d+/ when: manualEach trigger:* job carries a rules: changes:: when no file of the service changed, the job is not added to the pipeline. A plain needs: on that job would make the pipeline fail to start, because the GitLab documentation says: "If a needed job has optional: false, but it was not added to the pipeline, the pipeline fails to start". The deployments therefore declare their dependencies with optional: true, which the documentation describes as: "To need a job that sometimes does not exist in the pipeline, add optional: true to the needs configuration". The deployment then only waits for the children that are actually present.
There is a flip side: if no trigger is present, no needs: holds the deployment back any more. This follows from the definition of optional but has not been replayed on an instance.
Links:
trigger: include:withstrategy: mirror→ Parent-child pipelinesrules: changes:→ Workflowsresource_group+interruptible: false→ Reliability
Step 3: the child pipeline of a service
Each service has its own .gitlab-ci.yml that uses the component and passes it its pinned image:
include: - component: $CI_SERVER_FQDN/myorg/ci-components/service-pipeline@1.0.0 inputs: service_name: api-users image: python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de test_command: "pytest --reruns 2" docker_context: "."
stages: - lint - test - build
# The component jobs are hidden: without extends, they do not run.# The stage, image, script and retry come from the template.lint: extends: .lint-api-users
build: extends: .build-api-users
# Multi-version test matrix. The tag changes in each cell of the# matrix: it is the only unpinned reference on this page. In# production, pin each version by its digest.test:matrix: extends: .test-api-users parallel: matrix: - PYTHON_VERSION: ["3.11", "3.12", "3.13"] image: python:$PYTHON_VERSION timeout: 15 minutes
# CI services for integration teststest:integration: stage: test image: python:3.12@sha256:cd7c412d000912f29075a1b8803e43cb2f38bb67f104019df526df5ceaf30569 services: - name: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685 alias: db - name: redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf alias: cache variables: DATABASE_URL: "postgresql://test:test@db:5432/test" REDIS_URL: "redis://cache:6379" script: - pytest tests/integration/ --reruns 1 needs: [] retry: max: 1 when: - runner_system_failure - stuck_pending_with_matching_runnersThe child pipeline extends the three hidden templates of the component: lint and build only add their name, test:matrix overrides the image and adds the matrix. build has no needs:, so it starts once the jobs of the test stage (test:matrix and test:integration) have succeeded.
Links:
- Matrices → Matrices (parallel:matrix)
- CI services → Services and advanced cache
Step 4: the multi-project trigger
When shared-lib is modified, it must trigger the pipeline of the services that use it:
stages: - test - publish - notify
test: stage: test script: make test
publish: stage: publish script: - make build - twine upload dist/* rules: - if: $CI_COMMIT_TAG =~ /^v\d+/
# ── Multi-project trigger ──────────────────────────────────────notify:api-gateway: stage: notify trigger: project: myorg/api-gateway branch: main strategy: mirror variables: SHARED_LIB_VERSION: $CI_COMMIT_TAG rules: - if: $CI_COMMIT_TAG =~ /^v\d+/
notify:api-users: stage: notify trigger: project: myorg/api-users branch: main strategy: mirror variables: SHARED_LIB_VERSION: $CI_COMMIT_TAG rules: - if: $CI_COMMIT_TAG =~ /^v\d+/
notify:api-orders: stage: notify trigger: project: myorg/api-orders branch: main strategy: mirror variables: SHARED_LIB_VERSION: $CI_COMMIT_TAG rules: - if: $CI_COMMIT_TAG =~ /^v\d+/Link: Multi-project for trigger:project and variable propagation.
Step 5: reliability configuration
Review each job against the reliability grid:
| Check | Applied? |
|---|---|
Targeted retry:when (not always), see the Reliability lesson | Yes |
| Timeout adapted to each job type | Yes |
| interruptible: true by default, false on deploy | Yes |
| resource_group on deployments | Yes |
| Test framework retry (pytest --reruns) for tests | Yes |
| Idempotent jobs (names based on $CI_COMMIT_SHA) | Yes |
| allow_failure on non-blocking scans | To add |
Exercise: diagnose a broken pipeline
Your capstone pipeline fails. Here are the symptoms: find the cause and the solution for each one.
Case 1: the child pipeline is not triggered
trigger:api-users → missing from the pipeline's job listHint
Check the rules: changes: section. Does the path of the modified file match the pattern?
Solution
The modified file is in services/api-users/src/ but the changes: rule points to services/api-users/**/*. Check that the glob really covers subfolders. If your commit only touches shared-lib/, the api-users trigger only runs if shared-lib/**/* is in its changes:.
Case 2: the test matrix fails on a single version
test:matrix 3/3 (Python 3.13) → failed (script_failure)test:matrix 1/3 (Python 3.11) → passedtest:matrix 2/3 (Python 3.12) → passedHint
This is not a CI problem. It is a compatibility problem.
Solution
Check the dependencies in requirements.txt: a library may not support Python 3.13 yet. Temporarily use allow_failure on that version during the migration:
test:matrix: parallel: matrix: - PYTHON_VERSION: ["3.11", "3.12"] - PYTHON_VERSION: "3.13" ALLOW_FAIL: "true" allow_failure: exit_codes: [1] rules: - if: $ALLOW_FAIL == "true" allow_failure: true - when: on_successCase 3: the staging deploy starts before the children are done
deploy:staging → running (while trigger:api-orders is still in progress)Hint
Check the needs: directive of the deployment job.
Solution
Without strategy: on the triggers, the parent job finishes as soon as the child pipeline is triggered, not when it is finished. Add strategy: mirror to each trigger (GitLab 18.2 or later), and make sure the deploy has needs: on all the triggers, with optional: true since each trigger may be absent from the pipeline.
strategy: depend has the same waiting effect, but the GitLab documentation marks it "Not recommended": it is still accepted, and should be kept for instances older than 18.2. It counts as successful a failed child pipeline whose failing job carries allow_failure: true, which mirror does not hide.
Industrial maturity grid
Assess your pipeline with this grid. Each criterion matches a level C3 lesson:
| Criterion | Score | Lesson |
|---|---|---|
| Templates factor out at least 60% of the YAML | /2 | Templates |
| DAG enabled, duration reduced vs linear stages | /2 | DAG and parallelism |
| At least 1 published CI/CD Component | /1 | Components & Catalog |
| Test matrix on ≥ 2 versions | /1 | Matrices (parallel:matrix) |
| CI services with healthcheck | /1 | Services and advanced cache |
| Child pipeline per service (monorepo) | /2 | Parent-child pipelines |
| Multi-project trigger for shared libs | /1 | Multi-project |
| Workflow:rules with MR + tags | /2 | Workflows |
| Targeted retry + timeouts + interruptible | /2 | Reliability |
| Idempotent deploy jobs + resource_group | /1 | Reliability |
Interpretation:
- 12–15: solid industrial pipeline, production-ready
- 8–11: good base, a few optimizations missing
- < 8: revisit the lessons where you scored 0
Self-assessment
Answer without looking at the lessons. If you get stuck, the link points you to the right guide.
- Which directive replaces
include:for reusable components? (Components & Catalog) - How do you run a job as soon as its dependencies are done, without waiting for the stage? (DAG and parallelism)
- Which directive prevents two deployments from running in parallel? (Reliability)
- How do you trigger another project's pipeline when shared-lib changes? (Multi-project)
- Which section of
.gitlab-ci.ymlprevents duplicate pipelines (branch + MR)? (Workflows) - Which keyword lets you build only the services whose code changed? (Parent-child pipelines)
- How do you run PostgreSQL and Redis as services in a test job? (Services and advanced cache)
- Which type of error should never be in
retry:whenfor a test job? (Reliability)
Hands-on lab
The lab "Rebuild the whole pipeline" puts this guide into practice on a GitLab CE forge of your own, set up on a virtual machine with its runner and its registry: no group of five projects to set up yourself as in the scenario above, no minute quota, no identity verification, and a project you can break as often as you need. It covers a single application rather than the scenario's five repositories, but it calls on the same techniques.
The repository has no pipeline at all: not a single CI file. You rebuild one that lints, tests on a matrix with reported results, triggers a generated child pipeline, publishes the image to the registry and deploys reliably, in five stages and several included files. No step-by-step guide: a specification, and the state the forge has to show at the end.
The lab starts with dsoxlab run gl-19-reconstruire-tout-le-pipeline, after
dsoxlab provision prepares the virtual machine, its runner and its
registry. Installing DSOXLab and its prerequisites are described in
the DSOXLab documentation.
Grading reads the latest pipeline of the default branch main: that is
where you push. The five stages must then all carry jobs, deploy
included.
One constraint specific to this environment: the forge registry answers in
HTTP, not HTTPS, and the Docker client of the build job refuses that by
default. The REGISTRE-DU-LAB.txt memo, placed next to your working copy,
gives the exact line to write.
Key takeaways
- An industrial pipeline is not one big file, it is an orchestrated set of factored pipelines
- Components > templates > copy-paste: factor in that order of preference
- A job whose name starts with a dot is hidden: it only runs if it is extended, and no
needs:should point to it - A
needs:on a conditional job (rules:) takesoptional: true, otherwise the pipeline does not start - Child pipelines isolate each service: local failure, not global
- Multi-project connects dependencies: when the lib changes, the consumers retest
- Reliability is configured everywhere: targeted retry, adapted timeout, selective interruptible
- Pin images by digest: a republished tag changes your jobs' image without warning,
image:tag@sha256:…freezes the exact content - Workflows prevent duplicates:
workflow:rulesis your global guard - Measure maturity: the criteria grid gives an objective score
- The end goal: a pipeline that fails fast, recovers on its own, and deploys with confidence
Test your knowledge
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: C4: GitLab CI/CD pipeline security
A new full quiz with random questions
Work again only on the questions you missed