Learn to structure a .gitlab-ci.yml file: define stages, create jobs, write scripts. A step-by-step guide to building your first GitLab CI/CD pipelines.
The issue opens pre-filled with this page title and URL.
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
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:
What are the big steps of my workflow? → These will be your stages
What tasks must I complete at each step? → These will be your jobs
What commands must each task run? → This will be the script
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
Example of reasoning for a web project:
Question
Answer
YAML element
Big steps?
Build, test, deploy
stages: [build, test, deploy]
Test tasks?
Unit tests, linting
2 jobs in the test stage
Lint commands?
npm run lint
script: [npm run lint]
Swipe to see more
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:
Rule
Explanation
Sequential order
build finishes before test starts
Blocking failure
If build fails, test and deploy do not run
Default stages
Without a declaration, GitLab uses: .pre, build, test, deploy, .post
Swipe to see more
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.
Property
Required
Description
script
Yes
List of commands to run
stage
No
Stage it belongs to (default: test)
image
No
Docker image for the environment
variables
No
Environment variables of the job
artifacts
No
Files to keep after the job
cache
No
Files to reuse between pipelines
rules
No
Execution conditions
needs
No
Explicit dependencies (ignores stage order)
Swipe to see more
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.
Rule
Example
Each line = one command
- npm install
Sequential execution
Line 1, then line 2, then line 3...
Failure = stop
If a command returns a code ≠ 0, the job fails
Default shell
/bin/sh (or the one in the Docker image)
Swipe to see more
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.
Language
Light image
Full image
Node.js
node:24-alpine
node:24
Python
python:3.13-slim
python:3.13
Go
golang:1.26-alpine
golang:1.26
Java
eclipse-temurin:21-alpine
eclipse-temurin:21
Generic
alpine:3.24
ubuntu:24.04
Swipe to see more
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:
Variable
Content
$CI_COMMIT_BRANCH
Branch name
$CI_COMMIT_SHORT_SHA
Short commit hash
$CI_PROJECT_NAME
Project name
$CI_PIPELINE_SOURCE
Trigger (push, merge_request, etc.)
$CI_JOB_NAME
Name of the current job
Swipe to see more
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:
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.
In GitLab: Build > Pipeline editor > Validate tab
Locally with glab, the official GitLab CLI:
Terminal window
glabcilint.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 provisionprepares the virtual machine (allow a few minutes the
first time, then it is reused), and dsoxlab run opens the lab:
Terminal window
dsoxlabprovision
dsoxlabrungl-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
Level
Element
Role
1
stages
Defines the order of the big steps
2
Jobs
Tasks to accomplish (freely named)
3
script
Shell commands to run
Swipe to see more
Logical construction:
List your steps → stages
For each step, list the tasks → jobs
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
⏱️--:--
0 / 10
Review
(0/0)
--:--
0%
0Correct
0To review
0:00Time
-Level
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.