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 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:

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' pipelines
api-gateway ──┐
api-users ────┼── parent pipeline → child pipelines per service
api-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.

DecisionTechniqueLesson
Factor out common jobsTemplates + ComponentsTemplates, Components & Catalog
Start lint and test without waitingDAG (needs: [])DAG and parallelism
Test on 3 OS / 3 versionsMatricesMatrices (parallel:matrix)
Build each service separatelyChild pipelinesParent-child pipelines
Trigger when shared-lib changesMulti-projectMulti-project
Run only on MR + tagsWorkflowsWorkflows
Absorb transient errorsReliabilityReliability

Step 1: the reusable component

Start by creating a CI/CD Component for the pattern common to all services: lint → test → Docker build → push.

templates/service-pipeline/template.yml
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_failure

The 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.

.gitlab-ci.yml (monorepo root)
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: manual

Each 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:

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:

services/api-users/.gitlab-ci.yml
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 tests
test: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_runners

The 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:

Step 4: the multi-project trigger

When shared-lib is modified, it must trigger the pipeline of the services that use it:

shared-lib/.gitlab-ci.yml
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:

CheckApplied?
Targeted retry:when (not always), see the Reliability lessonYes
Timeout adapted to each job typeYes
interruptible: true by default, false on deployYes
resource_group on deploymentsYes
Test framework retry (pytest --reruns) for testsYes
Idempotent jobs (names based on $CI_COMMIT_SHA)Yes
allow_failure on non-blocking scansTo 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 list
Hint

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) → passed
test:matrix 2/3 (Python 3.12) → passed
Hint

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_success

Case 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:

CriterionScoreLesson
Templates factor out at least 60% of the YAML/2Templates
DAG enabled, duration reduced vs linear stages/2DAG and parallelism
At least 1 published CI/CD Component/1Components & Catalog
Test matrix on ≥ 2 versions/1Matrices (parallel:matrix)
CI services with healthcheck/1Services and advanced cache
Child pipeline per service (monorepo)/2Parent-child pipelines
Multi-project trigger for shared libs/1Multi-project
Workflow:rules with MR + tags/2Workflows
Targeted retry + timeouts + interruptible/2Reliability
Idempotent deploy jobs + resource_group/1Reliability

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.

  1. Which directive replaces include: for reusable components? (Components & Catalog)
  2. How do you run a job as soon as its dependencies are done, without waiting for the stage? (DAG and parallelism)
  3. Which directive prevents two deployments from running in parallel? (Reliability)
  4. How do you trigger another project's pipeline when shared-lib changes? (Multi-project)
  5. Which section of .gitlab-ci.yml prevents duplicate pipelines (branch + MR)? (Workflows)
  6. Which keyword lets you build only the services whose code changed? (Parent-child pipelines)
  7. How do you run PostgreSQL and Redis as services in a test job? (Services and advanced cache)
  8. Which type of error should never be in retry:when for 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:) takes optional: 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:rules is 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

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