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 now know the core concepts: pipeline, stages, jobs, runners. Time to get practical! The .gitlab-ci.yml file describes what GitLab must automate. This guide teaches you to structure it methodically: how to split your workflow into stages, define jobs, and write the scripts that will be run.

What you will learn

By the end of this module, you will be able to:

  • Structure a .gitlab-ci.yml file: the stages → jobs → script hierarchy
  • Define stages: organize the big steps of your pipeline
  • Create jobs: configure the tasks with the right properties
  • Write scripts: the commands run by each job
  • Specify a Docker image: choose the execution environment
  • Use variables: configure your jobs dynamically
  • Validate your file: avoid syntax errors
  • Get a first green pipeline: write it and push it to a lab GitLab forge, in the last section

Prerequisites

Before continuing, make sure you have mastered:

What is the building logic?

A .gitlab-ci.yml file is built on 3 nested levels: stages contain jobs, which each run a script. Before writing YAML, ask yourself one question per level. The "Level 1", "Level 2" and "Level 3" sections detail them in that order:

  1. What are the big steps of my workflow? → These will be your stages
  2. What tasks must I complete at each step? → These will be your jobs
  3. What commands must each task run? → This will be the script
Structure of a .gitlab-ci.yml: the build stage contains the build job (npm ci, npm run build, dist/ artifact), the test stage the test-unit and lint jobs run in parallel, the deploy stage the deploy job (./deploy.sh); the stages chain in order..gitlab-ci.ymlStage: buildStage: test (jobs in parallel)Stage: deployJob: buildscript: npm ci, npm run buildartifacts: dist/Job: test-unitnpm testJob: lintnpm lintJob: deploy./deploy.sh
A stage runs after the previous one; the jobs of a same stage run in parallel; each job runs the commands of its script. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Example of reasoning for a web project:

QuestionAnswerYAML element
Big steps?Build, test, deploystages: [build, test, deploy]
Test tasks?Unit tests, linting2 jobs in the test stage
Lint commands?npm run lintscript: [npm run lint]

What is the minimal structure of the file?

A GitLab pipeline fits in very few lines. The only element that is really indispensable is a job with a script: the stages block is optional, GitLab provides a default one. Declare it anyway, because it is what makes the execution order readable for the person who takes over the file after you.

A valid .gitlab-ci.yml file contains at minimum:

# 1. Declare the stages (optional but recommended)
stages:
- build
- test
# 2. Define at least one job with a script
my-job:
stage: build
script:
- echo "Hello GitLab CI!"

The file must be:

  • Named exactly .gitlab-ci.yml (with the leading dot)
  • Placed at the root of the repository
  • Encoded in UTF-8

Level 1: Defining the stages

Stages represent the big steps of your pipeline. They run in the declared order.

stages:
- build # Step 1: compile the code
- test # Step 2: run the tests
- deploy # Step 3: deploy the application

Stage rules:

RuleExplanation
Sequential orderbuild finishes before test starts
Blocking failureIf build fails, test and deploy do not run
Default stagesWithout a declaration, GitLab uses: .pre, build, test, deploy, .post

When should you create a new stage?

Create a stage when there is a logical dependency:

  • Yes: test depends on build, so 2 separate stages
  • Yes: deploy-staging before deploy-prod, so 2 separate stages
  • No: unit tests and integration tests, same stage because there is no dependency between them

Level 2: Defining the jobs

A job is a task that belongs to a stage. This is where you describe what must be done.

# Job syntax
job-name: # Free name, visible in the interface
stage: test # Which stage this job belongs to
script: # Commands to run (REQUIRED)
- command1
- command2

What are the properties of a job?

Only one line of this table is mandatory: script. All the others have a default value, and two of those defaults regularly surprise people. A job without stage lands in test, even if you thought you were placing it at the start of the pipeline. A job without image inherits the image configured on the runner, not an image tied to your language. The last three lines (cache, rules, needs) belong to more advanced usage; leave them aside for a first pipeline.

PropertyRequiredDescription
scriptYesList of commands to run
stageNoStage it belongs to (default: test)
imageNoDocker image for the environment
variablesNoEnvironment variables of the job
artifactsNoFiles to keep after the job
cacheNoFiles to reuse between pipelines
rulesNoExecution conditions
needsNoExplicit dependencies (ignores stage order)

How do jobs run in parallel?

The jobs of a same stage run in parallel:

stages:
- test
unit-tests:
stage: test
script:
- npm run test:unit
integration-tests:
stage: test
script:
- npm run test:integration
lint:
stage: test
script:
- npm run lint

These 3 jobs start simultaneously because they are all in the test stage.

Level 3: Writing the scripts

The script holds the shell commands run by the job, in the order of the list. Each item is a separate command, run at the root of the repository freshly cloned by the runner. The echo lines in the example are not decoration: they show up in the job logs and serve as landmarks when you look for the step where a build stopped.

build:
stage: build
script:
- echo "Starting the build"
- npm ci
- npm run build
- echo "Build finished"

What are the script rules?

The line to remember is the third one: GitLab does not judge the output of your commands, it only looks at their exit code. A command that prints "ERROR" but exits with 0 leaves the job green, and a command that exits with 1 stops the job even if everything seemed to work. The last line explains why so many jobs fail on an Alpine image: the shell there is /bin/sh, not Bash, so the [[ ... ]] syntax and Bash arrays cause a syntax error.

RuleExample
Each line = one command- npm install
Sequential executionLine 1, then line 2, then line 3...
Failure = stopIf a command returns a code ≠ 0, the job fails
Default shell/bin/sh (or the one in the Docker image)

How do you write multi-line commands?

The YAML literal block | lets you write several commands that share the same shell. That is what makes an if condition or a loop possible: split into separate items, each line would run in its own call and the structure would be broken. Beware, though: this block remains a single script entry for GitLab. The interface displays it all at once, and a failure in the middle of a block is harder to locate in the logs.

For long commands or control structures, use the literal block |:

script:
- |
echo "First line"
echo "Second line"
if [ -f "config.json" ]; then
echo "Config found"
fi

What are before_script and after_script for?

These two sections avoid repeating the same preparation in every job. The difference that matters is their behavior on failure: before_script is concatenated with script, so if it fails, the job fails immediately and the script is never started. By contrast, after_script runs in a separate shell, even when the job failed or was canceled, which makes it the right place for cleanup or for collecting a diagnostic log. A corollary to know: variables defined in the script are not visible from after_script.

Declare them under default to apply them to all jobs:

# Runs BEFORE the script of each job
default:
before_script:
- echo "Preparing..."
- npm ci
test:
script:
- npm test
after_script:
- echo "Cleaning up..." # Runs even if the job fails

How do you specify the environment with image?

A job without image does not fail: it inherits the image configured on the runner. On the runners hosted by GitLab.com, this default value is ruby:3.1; on a runner you administer, it is the image key of the [runners.docker] section of the config.toml file. In other words, the same pipeline can work on your side and fail on a colleague's with command not found. Explicitly declaring the image is therefore what makes a pipeline reproducible.

build:
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd # Node.js 24 LTS on Alpine Linux
script:
- node --version
- npm ci
- npm run build

The table below gives starting points, not obligations. The light image column relies on Alpine or on a slim variant: it downloads much faster, which matters on a pipeline run fifty times a day, but it uses the musl C library instead of glibc. A native module compiled for glibc may therefore refuse to load. When in doubt on the first try, take the right-hand column.

LanguageLight imageFull image
Node.jsnode:24-alpinenode:24
Pythonpython:3.13-slimpython:3.13
Gogolang:1.26-alpinegolang:1.26
Javaeclipse-temurin:21-alpineeclipse-temurin:21
Genericalpine:3.24ubuntu:24.04

How do you define variables?

Variables let you configure your jobs without changing the script.

Global variables

A variables: block written at the root of the file applies to all the jobs of the pipeline. These values are exported as environment variables in the job container, and are used with the shell syntax $NAME. Reserve this place for what is truly common, such as a tool version or an application name; a value that concerns only one job belongs to the job. And keep in mind that this file is versioned in Git: no secret belongs here, tokens and passwords are declared in the project's CI/CD variables, masked and protected.

variables:
NODE_ENV: production
DEPLOY_TARGET: staging
build:
script:
- echo "Environment: $NODE_ENV"
- echo "Target: $DEPLOY_TARGET"

Per-job variables

A variables: block placed inside a job only applies to that job, and it overrides the global value with the same name. This is the mechanism that lets a same pipeline build in development mode then deploy in production mode without duplicating the script. The pitfall to know: the override works key by key. A job variable replaces the global value of the same name and nothing else, so the other global variables stay available. In the example below, the global NODE_ENV: production has no effect at all on the build job, which runs with development, while deploy overrides nothing and inherits production.

variables:
NODE_ENV: production # global value
build:
variables:
NODE_ENV: development # replaces the global value, for this job only
script:
- npm run build
deploy:
script:
- npm run deploy # no override: NODE_ENV is "production"

Which predefined GitLab variables exist?

GitLab automatically injects useful variables:

VariableContent
$CI_COMMIT_BRANCHBranch name
$CI_COMMIT_SHORT_SHAShort commit hash
$CI_PROJECT_NAMEProject name
$CI_PIPELINE_SOURCETrigger (push, merge_request, etc.)
$CI_JOB_NAMEName of the current job
build:
script:
- echo "Building $CI_PROJECT_NAME on branch $CI_COMMIT_BRANCH"
- echo "Commit: $CI_COMMIT_SHORT_SHA"

What does a complete commented example look like?

This pipeline gathers everything above on a Node.js project. Three details deserve your attention while reading. The build job publishes dist/ as an artifact, which is the reliable way to pass files from one stage to the next: the working directory starts from zero on every job. The test-unit and test-lint jobs each redo an npm ci, because they start in separate containers and share no node_modules. Finally, the deploy job combines rules and when: manual: it only appears on the main branch, and it waits for a click in the interface before starting.

Here is a realistic pipeline for a Node.js project:

# Declaration of the stages in execution order
stages:
- build
- test
- deploy
# Global variables available in all jobs
variables:
NODE_ENV: production
DEPLOY_TARGET: staging
# --- BUILD STAGE ---
build:
stage: build
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
script:
- echo "Installing dependencies..."
- npm ci
- echo "Compiling..."
- npm run build
artifacts:
paths:
- dist/ # Keeps the dist/ folder for the following jobs
expire_in: 1 hour # Deleted after 1 hour
# --- TEST STAGE ---
# These 2 jobs run IN PARALLEL
test-unit:
stage: test
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
script:
- npm ci
- npm run test:unit
test-lint:
stage: test
image: node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
script:
- npm ci
- npm run lint
# --- DEPLOY STAGE ---
deploy:
stage: deploy
image: alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
script:
- echo "Deploying to $DEPLOY_TARGET..."
- echo "Files to deploy:" && ls dist/
rules:
- if: $CI_COMMIT_BRANCH == "main" # Only on main
when: manual # Click required to start

How do you validate before committing?

A syntax error only shows up after the push, and it costs a fix commit in the history. The two methods below check the file before it leaves. Both rely on GitLab's own validator, not on a generic YAML parser: they therefore also detect an unknown stage or a misspelled property, which a simple yamllint would let through.

  1. In GitLab: Build > Pipeline editor > Validate tab
  2. Locally with glab, the official GitLab CLI:
    Terminal window
    glab ci lint .gitlab-ci.yml

Validation deserves a treatment of its own, notably to automate it in a pre-commit hook: the guide Validating a .gitlab-ci.yml file details the CI Lint API and its limits.

Hands-on lab

The exercise is played in the lab "Your first pipeline, green, on your own GitLab". It gives you a GitLab CE forge of your own (a GitLab Community Edition server that you host), set up on a virtual machine with its runner and its registry: no minute quota, no identity verification, and a project you can break as often as you need. Installing DSOXLab and its prerequisites are described in the DSOXLab documentation.

dsoxlab provision prepares the virtual machine (allow a few minutes the first time, then it is reused), and dsoxlab run opens the lab:

Terminal window
dsoxlab provision
dsoxlab run gl-01-premier-pipeline

The starting point is already in place: the FastAPI project is pushed to the forge, on its main branch, at http://gitlab-1.lab/root/pipeline-craft, and a copy of the repository waits for you in /opt/dsoxlab/gl-01/pipeline-craft. It contains the complete Python code (the API, the tests, the Dockerfile) but no .gitlab-ci.yml file. In the forge, Build > Pipelines is therefore empty: without that file, GitLab does not know what to do with your code.

You write the pipeline that lints the code (ruff check), tests it (pytest) and builds it as an image (docker build), then you push. The project is in Python, not Node.js: the complete example of this page transposes by changing the image (python:3.13-slim) and the commands of the script. One point is not covered here: docker build needs a Docker daemon inside the job, which the docker:dind service provides. It is presented in Registries, and the third hint of the lab recalls it too. Grading is taken on the pipeline the server really ran, read from the API, never on the file you wrote: a perfect .gitlab-ci.yml whose jobs never ran is worth nothing.

Key takeaways

LevelElementRole
1stagesDefines the order of the big steps
2JobsTasks to accomplish (freely named)
3scriptShell commands to run

Logical construction:

  1. List your steps → stages
  2. For each step, list the tasks → jobs
  3. For each task, list the commands → script

Knowledge check

Test your knowledge of the structure of .gitlab-ci.yml files.

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