2. Pipeline Configuration Fundamentals
Every GitLab pipeline is a YAML document that declares jobs, groups them into stages and shares settings through defaults. This chapter covers each of those building blocks: the YAML syntax GitLab parses, how jobs are defined and run, how stages order them, and how global defaults reach individual jobs. Most configuration bugs come from a misunderstanding at this level, so these rules are worth knowing exactly.
<a id="21-yaml-essentials-for-gitlab-ci"></a>
2.1 YAML Essentials for GitLab CI
GitLab reads .gitlab-ci.yml with Ruby's YAML parser, which follows YAML 1.1, so YAML 1.1's syntax rules and quirks are also pipeline rules. This subchapter covers the part of YAML that CI configuration relies on, along with the traps it contains.
Scalars, Lists and Maps
Why it matters. GitLab processes your configuration in two passes. First, a YAML parser turns the text into data: strings, numbers, lists and dictionaries. Then GitLab checks that data against its CI schema. A configuration can be valid YAML and still be wrong for GitLab, for example when a script that should be a list of strings is parsed as a dictionary. If you know the three YAML building blocks, you can read any keyword's documentation ("script: an array of strings", "variables: a hash") and see exactly what shape to write.
What it is. YAML has three kinds of node:
| Node | Also called | Syntax | Example |
|---|---|---|---|
| Scalar | value | A single value on one line (or a block, see the next topic) | stage: build |
| Sequence | list, array | Items prefixed by - , or inline [a, b] | tags: [docker, linux] |
| Mapping | map, hash, dictionary | key: value pairs, or inline {a: 1} | artifacts: {paths: [dist/]} |
Think of YAML as JSON with less punctuation: indentation takes the place of braces, and - takes the place of brackets. Scalars are typed automatically from how they look:
| Written as | Parsed as | Notes |
|---|---|---|
build, "build", 'build' | String | Quotes are optional for simple text |
3, -1 | Integer | GitLab passes numeric variable values to jobs as strings |
1.5 | Float | 3.10 becomes 3.1, see Common YAML Pitfalls |
true, false, yes, no, on, off | Boolean | YAML 1.1 accepts all of these spellings |
~, null, empty value | Null | script: with nothing after it is null |
The difference between single and double quotes:
| Quote style | Escapes such as \n | Use it for |
|---|---|---|
'single' | Not processed. Write '' for a literal ' | Shell commands containing ", $ or \ |
"double" | Processed (\n, \t, \") | Strings that need escapes |
The example below uses all three kinds of node:
variables: # mapping: variable name -> value
APP_ENV: "staging" # string scalar (quoted)
REPLICAS: 3 # integer scalar; the job sees the string "3"
build: # mapping whose value is another mapping (the job)
stage: build # string scalar (unquoted)
tags: [docker, linux] # flow-style (inline) sequence
script: # block-style sequence of strings
- make build
- make package
artifacts: # nested mapping
paths:
- dist/
expire_in: 1 day
rules: # sequence of mappings
- if: $CI_COMMIT_BRANCH == "main" # item 1: a mapping with two keys...
when: always # ...aligned with "if", so it belongs to item 1
- when: never # item 2: a mapping with one key
Here is the same build job as JSON, the structure GitLab actually checks:
{
"build": {
"stage": "build",
"tags": ["docker", "linux"],
"script": ["make build", "make package"],
"artifacts": { "paths": ["dist/"], "expire_in": "1 day" },
"rules": [
{ "if": "$CI_COMMIT_BRANCH == \"main\"", "when": "always" },
{ "when": "never" }
]
}
}
Indentation rules. Use spaces only, because tabs are illegal in YAML indentation. Indent children consistently (two spaces is the convention), and indent all keys of a mapping to exactly the same column. If when: always above were indented one column less than if, it would no longer belong to the first rule, and the configuration would fail to parse or mean something else.
Variables are strings. CI/CD variables reach the job's shell as environment variables, which are always strings. GitLab accepts string and numeric values under variables and rejects boolean values such as ENABLED: true with a validation error. Quoting every variable value is the simplest habit to adopt.
Key takeaways
- YAML has three building blocks: scalars, sequences (
- itemor[a, b]) and mappings (key: valueor{k: v}). - GitLab first parses the YAML, then validates the resulting structure. A configuration can be valid YAML and still be invalid CI configuration.
- Scalars get their type from how they look. Quote anything that must stay a string.
- Indent with spaces, and align sibling keys to exactly the same column.
Practice
- (Beginner) Convert this JSON into idiomatic block-style YAML:
{"test": {"image": "node:22", "script": ["npm ci", "npm test"], "artifacts": {"paths": ["coverage/"]}}}.- (Beginner) For each value, give its parsed type:
stage: test,retry: 2,allow_failure: yes,timeout: 1h 30m,VERSION: 2.0,coverage: '/Total: (\d+)%/'.- Interview question: "GitLab reports
script config should be a string or a nested array of strings up to 10 levels deep, but the file looks fine. What has gone wrong?" Hint: the YAML parsed without error, so look for a list item that the parser turned into something other than a string.
Multi-line Strings and Block Scalars
Why it matters. Real scripts outgrow one-liners: if statements, heredocs, docker build commands with ten flags. Packing them into a single line makes them hard to read and to review. Block scalars let you write multi-line text in YAML with no escaping at all.
What it is. A block scalar starts with an indicator on its own and continues on the following, more-indented lines. YAML has two styles:
| Indicator | Name | Newlines | Typical CI use |
|---|---|---|---|
| | Literal | Kept exactly as written | Shell blocks: if/for, heredocs, several statements |
> | Folded | Single newlines become spaces | One long command split across lines for readability |
A chomping indicator after the style controls the trailing newline:
| Suffix | Effect | Example |
|---|---|---|
| (none) | Clip: keep one trailing newline | |, > |
- | Strip: remove the trailing newline | |-, >- |
+ | Keep: keep all trailing blank lines | |+, >+ |
For script entries the trailing newline makes no difference to the shell. It does matter for variable values, which are often compared or concatenated.
deploy:
image: alpine:3.20
script:
# Literal block: newlines are kept, so the shell sees a normal multi-line if-statement
- |
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
echo "Deploying to production"
./deploy.sh production
else
echo "Skipping deploy for branch $CI_COMMIT_BRANCH"
fi
# Literal block with a heredoc: writes a config file line by line
- |
cat > app.env <<EOF
VERSION=$CI_COMMIT_SHORT_SHA
BUILT_AT=$CI_JOB_STARTED_AT
EOF
build-image:
image: docker:27
script:
# Folded block: the lines are joined with spaces into ONE command
- >
docker build
--pull
--tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
--label "org.opencontainers.image.revision=$CI_COMMIT_SHA"
.
The folded entry reaches the shell as a single line:
docker build --pull --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" .
Rules to remember.
- The content's indentation is set by its first line. Every following line must be indented at least that far, and the block ends at the first line indented less.
- In a folded (
>) block, lines indented more than the first line keep their newlines. One accidentally deeper-indented line breaks a folded command in two. - Never write an
iforforblock as>. Folding joins the statements into one line without the;separators, which produces a shell syntax error. - A literal (
|) block is the YAML equivalent of the shell's backslash continuation, without the backslashes. You can still use\inside a literal block if you prefer. - The job log echoes only the first line of a multi-line command. Add
echolines inside long blocks when you need to follow their progress.
Key takeaways
|keeps newlines and suits shell logic.>folds lines into one and suits long single commands.-strips the trailing newline. Use|-or>-for variable values that are compared or concatenated.- In folded blocks, lines indented more deeply keep their newlines, which is a common source of broken commands.
- Move scripts longer than about ten lines into a file in the repository (
./ci/deploy.sh), where they can be linted and run locally.
Practice
- (Beginner) Rewrite
curl --fail --silent --show-error --header "PRIVATE-TOKEN: $TOKEN" --output report.json "$CI_API_V4_URL/projects/$CI_PROJECT_ID/issues"as a folded block scalar in ascriptlist.- (Intermediate) Write a job whose script uses a literal block and a heredoc to generate
config.jsoncontaining the commit SHA and branch name, then prints the file. Check in the job log that the JSON is valid.- Interview question: "When do you use
|and when>in a job script, and what breaks if you use>for a shellifblock?" Hint: think about what separates shell statements, and what folding does to newlines.
YAML Anchors and Aliases
Why it matters. Pipelines repeat themselves: the same setup commands, the same image, the same rules in a dozen jobs. Copy-paste duplication means one fix has to be applied in twelve places. Anchors let you define a piece of YAML once and reuse it by name, like a variable for configuration.
What it is. YAML provides anchors and aliases for reuse:
&nameattaches an anchor to a node: a scalar, a sequence or a whole mapping.*nameis an alias. It inserts a copy of the anchored node at that position.
The YAML parser resolves aliases before GitLab sees the configuration. GitLab never sees an alias, only the expanded data, so anchors work in every keyword. The Full configuration tab of the pipeline editor shows the expanded result.
# Anchors are usually placed in hidden jobs (leading dot) so they never run on their own
.python-image: &python-image python:3.12-slim # anchored scalar
.setup-venv: &setup-venv # anchored sequence of commands
- python -m venv .venv
- source .venv/bin/activate
- pip install -r requirements.txt
.main-only: &main-only # anchored sequence of rules
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
unit-tests:
image: *python-image
script:
- *setup-venv # alias inside a list -> nested list, GitLab flattens it
- pytest tests/unit
integration-tests:
image: *python-image
script:
- *setup-venv
- pytest tests/integration
rules: *main-only # alias used as the whole value
After resolution and flattening, unit-tests is equivalent to:
unit-tests:
image: python:3.12-slim
script:
- python -m venv .venv
- source .venv/bin/activate
- pip install -r requirements.txt
- pytest tests/unit
GitLab flattens nested arrays in script, before_script and after_script up to 10 levels deep. That is why an alias to a list can sit among plain commands.
Limitations.
| Limitation | Consequence | Alternative |
|---|---|---|
| Anchors are scoped to a single file | An anchor defined in an included file cannot be used in .gitlab-ci.yml | extends or !reference (chapter 9) |
| The anchor must be defined before its aliases | Using *name above &name is a parse error | Put templates at the top of the file |
| The alias is an exact copy | You cannot change part of an aliased list | Merge keys for mappings (next topic), !reference for lists |
Key takeaways
&namedefines an anchor, and*nameinserts a copy of it. Resolution happens in the YAML parser, before GitLab validates anything.- Anchors can wrap scalars, lists or mappings. Aliases to command lists can be mixed with plain commands in
script. - Anchors work only within a single file. For reuse across included files, use
extendsor!reference. - Keep anchored snippets in hidden jobs so they never run as jobs.
Practice
- (Beginner) Three jobs each start with
apt-get update && apt-get install -y curl jq. Refactor them to use one anchored command list.- (Intermediate) Define an anchored
ruleslist that runs a job only for merge request pipelines and for the default branch, and apply it to three jobs. Check the expanded result in the Full configuration tab.- Interview question: "Your team moved shared snippets into
ci/common.ymland included it, and now every alias fails with an 'unknown alias' error. Why, and how do you fix it?" Hint: consider when YAML aliases are resolved compared with whenincludefiles are merged.
Merge Keys
Why it matters. An alias copies a node exactly, but jobs usually need "the template plus a few changes". The merge key << copies the keys of an anchored mapping into the current mapping. You can then add keys or override some of them, a lightweight form of inheritance in plain YAML.
What it is. <<: *anchor merges every key of the anchored mapping into the mapping where it appears. Keys written explicitly in that mapping take precedence over the merged ones. To merge several templates, use a list: <<: [*a, *b]. When the same key appears in several of them, the first one in the list wins.
.node-job: &node-job
image: node:22
tags: [docker]
before_script:
- npm ci
retry: 1
lint:
<<: *node-job # copy image, tags, before_script and retry
script:
- npm run lint
test:
<<: *node-job
retry: 2 # explicit key overrides the merged value
script:
- npm test
test resolves to:
test:
image: node:22
tags: [docker]
before_script:
- npm ci
retry: 2
script:
- npm test
The merge is shallow. Only top-level keys are merged. If you override a key whose value is a mapping, such as variables, the whole mapping is replaced:
.defaults: &defaults
variables:
NODE_ENV: test
LOG_LEVEL: info
job-a:
<<: *defaults
variables:
LOG_LEVEL: debug # replaces the whole map: NODE_ENV is lost
script:
- printenv NODE_ENV LOG_LEVEL # prints only "debug"; NODE_ENV is unset
To override a single nested key, anchor the nested mapping as well and merge it explicitly:
.default-vars: &default-vars # anchor the nested map on its own
NODE_ENV: test
LOG_LEVEL: info
.defaults: &defaults
variables: *default-vars
job-b:
<<: *defaults
variables:
<<: *default-vars # merge the nested map explicitly...
LOG_LEVEL: debug # ...then override one key: NODE_ENV is kept
script:
- printenv NODE_ENV LOG_LEVEL # prints "test" and "debug"
Merge keys compared with extends. GitLab's own extends keyword (chapter 9) solves the same problem at the GitLab level:
| Aspect | <<: *anchor | extends: .template |
|---|---|---|
| Resolved by | The YAML parser | GitLab |
Works across included files | No | Yes |
Nested mappings (variables, cache, ...) | Replaced (shallow) | Merged recursively (deep) |
Lists (script, rules, ...) | Replaced | Replaced |
| Multiple parents | <<: [*a, *b], the first wins | extends: [.a, .b], the last wins |
Key takeaways
<<: *anchormerges the keys of an anchored mapping, and explicitly written keys override them.- The merge is shallow. Overriding a nested mapping replaces it entirely unless you merge that mapping too.
- With several anchors,
<<: [*a, *b]gives precedence to the first, whileextends: [.a, .b]gives it to the last. - Use
extendswhen templates live in other files or need deep merging. Use merge keys for quick reuse within one file.
Practice
- (Beginner) Refactor two jobs that share
image,tagsandbefore_scriptinto one anchored template applied with<<. Verify the result in the Full configuration tab.- (Intermediate) Given
.a: &a {image: alpine, retry: 1}and.b: &b {image: ubuntu, timeout: 10m}, predict the resolved job for<<: [*a, *b]and forextends: [.a, .b]. Check both in the pipeline editor.- Interview question: "A job uses
<<: *templateand defines its ownvariables. Some variables from the template are missing at runtime. Explain why, and give two ways to fix it." Hint: shallow merge compared with deep merge.
Common YAML Pitfalls
Why it matters. YAML's convenience, with optional quotes and automatic types, also means that small typos change meaning without any error. The most painful CI bugs are not syntax errors. They are configurations that parse cleanly and do something slightly different from what you intended.
The usual suspects.
| Pitfall | You write | YAML parses it as | Fix |
|---|---|---|---|
| Colon and space in a command | - echo Status: OK | Mapping {"echo Status": "OK"} | Quote the whole item: - 'echo Status: OK' |
| Partial quoting | - echo "key: value" | Still a mapping, because the item starts with echo, not a quote | - 'echo "key: value"' or a | block |
Space and # inside a value | - echo Build #42 | echo Build (the rest is a comment) | - 'echo "Build #42"' (the shell also treats # as a comment) |
| Version numbers | PY_VERSION: 3.10 | Float 3.1 | PY_VERSION: "3.10" |
| YAML 1.1 booleans | FEATURE: on, DEBUG: yes | Booleans, rejected as variable values | Quote them: "on", "yes" |
| Leading zeros | MODE: 010 | Octal integer 8 | MODE: "010" |
Leading * | - *.log under artifacts:paths | An alias named .log (parse error) | - "*.log" |
Missing space after - | -echo hi | Plain string -echo hi, not a list | - echo hi |
| Tabs | A tab used for indentation | Syntax error | Spaces only; configure your editor |
| Duplicate keys | The same job name, or script, written twice | The last one silently wins | Keep keys unique; review diffs carefully |
| Empty value | script: with nothing after it | null | Provide a value, or remove the key |
Two of these produce no error at all. -echo hi creates a valid script that is a single string, and the job fails at runtime with -echo: command not found. A duplicated job name silently drops the first definition.
A broken configuration and its fixed version:
# Broken
variables:
PY_VERSION: 3.10 # becomes 3.1
VERBOSE: on # boolean: rejected
test:
script:
- echo Running build #$CI_PIPELINE_IID # everything after " #" is a comment
- curl -H "Accept: application/json" "$API" # parsed as a mapping
artifacts:
paths:
- *.xml # parsed as an alias
# Fixed
variables:
PY_VERSION: "3.10"
VERBOSE: "on"
test:
script:
- 'echo "Running build #$CI_PIPELINE_IID"' # YAML quotes outside, shell quotes inside
- 'curl -H "Accept: application/json" "$API"'
artifacts:
paths:
- "*.xml"
Seeing what the parser sees. When in doubt, print the parsed structure locally. PyYAML follows YAML 1.1 rules like GitLab's parser, so it reproduces most of these quirks:
# Print .gitlab-ci.yml as JSON to see exactly how each value was typed
python3 -c 'import json, sys, yaml; print(json.dumps(yaml.safe_load(open(sys.argv[1])), indent=2, default=str))' .gitlab-ci.yml
safe_load rejects GitLab's custom !reference tag. For files that use it, rely on CI Lint or the pipeline editor (chapter 1.3), which also add GitLab's schema checks.
Key takeaways
:and#inside unquoted values change their meaning. Quote the entire list item, not only part of it.- YAML 1.1 types values automatically:
3.10becomes3.1,onandyesbecome booleans, and010becomes8. Quote values that must stay strings. - Values starting with
*,&,!,[,{,@or`need quotes. - Some mistakes (
-echo, duplicate keys) cause no validation error. Read the Full configuration tab, or parse the file locally, when behavior looks wrong.
Practice
- (Beginner) Rewrite this job in block style, then find and fix every YAML pitfall in it:
build: {script: [echo Version: $VERSION, -make], variables: {VERSION: 1.20, STRICT: yes}, artifacts: {paths: [*.tar.gz]}}.- (Intermediate) Write a small script that runs the PyYAML one-liner above on
.gitlab-ci.ymland fails if any value under anyvariableskey is not a string. Run it as a job in the.prestage.- Interview question: "Your pipeline installs Node.js
18.1even though the variable saysNODE_VERSION: 18.10. What happened?" Hint: consider how YAML types a scalar that looks like a number.
<a id="22-jobs"></a>
2.2 Jobs
Jobs are the smallest unit of work in a pipeline. Each one runs a script on a runner and reports a single status. This subchapter covers how jobs are declared and named, what runs inside them, and how they choose their execution environment.
Job Definition and Naming
Why it matters. A pipeline is only as understandable as its jobs. Clear job boundaries and names decide what you can retry on its own, what runs in parallel and how quickly someone can see what failed in the pipeline graph.
What it is. A job is any top-level key in .gitlab-ci.yml that is not a global keyword and does not start with a dot. Its value is a mapping of job keywords, and it must define either script (commands to run) or trigger (a downstream pipeline to start), directly or through extends.
A job works like a function called on a remote machine. It has a name, an environment (image, variables), a body (script) and a return value (its status). Jobs are isolated. With container-based executors, each job starts in a fresh container with a fresh checkout of the commit. Nothing on the file system carries over from one job to the next unless you pass it on explicitly with artifacts or cache (chapter 8).
Lifecycle of a single job
created ──> pending ──> running ─────────────────────────────────────────────> success / failed
(stage (waiting │
not yet for a ├─ prepare executor (pull image, start services)
started) runner) ├─ get sources (clone/fetch the exact commit)
├─ restore cache
├─ download artifacts from earlier jobs
├─ before_script + script (one shell session)
├─ after_script (new shell session)
├─ save cache
└─ upload artifacts
A minimal job and a fuller one:
hello: # job name
script: echo "Hello from $CI_JOB_NAME" # the only required keyword (a single string is allowed)
"unit tests: api": # names may contain spaces and colons, but quote them
stage: test
image: golang:1.23
script:
- go test ./api/...
Naming rules.
| Rule | Detail |
|---|---|
| Length | At most 255 characters |
| Uniqueness | Job names are YAML keys. If one is repeated in the same file, the last definition silently wins. When an included file and the main file use the same name, their definitions are merged and the main file's values take precedence |
| Reserved words | Global keywords such as stages, variables and default cannot be used as names (see Reserved Keywords) |
| Characters | Letters, digits, spaces, -, _, :, / and more are allowed. Quote names containing : or characters YAML treats as special |
| Grouping | Jobs whose names differ only by a trailing index, such as test 1/3 and test 2/3, are collapsed into one group in the pipeline graph |
Naming conventions that scale. Use lowercase kebab-case following a <verb>-<object>[-<qualifier>] pattern: build-image, test-unit, test-e2e-chrome, deploy-staging. Names are visible in the UI, in notifications and in needs references. The name should tell a reader what failed without opening the log.
Key takeaways
- Any top-level key that is not a global keyword and has no leading dot is a job. It needs
scriptortrigger. - Jobs are isolated: each one gets a fresh environment and checkout, and data moves between jobs only through artifacts or cache.
- A job name repeated in the same file silently replaces the earlier definition.
- Choose descriptive, consistent names. They appear everywhere a failure is reported.
Practice
- (Beginner) Write a job named
print-contextthat printsCI_COMMIT_BRANCH,CI_COMMIT_SHORT_SHAandCI_PIPELINE_SOURCE. Run it and find the output in the log.- (Intermediate) Create a job named
buildwhose script writes a fileversion.txt, and a jobshow-versionin a later stage that runscat version.txt. Explain the failure, then fix it withartifacts:paths.- Interview question: "How do you decide where one job ends and the next begins? When is a single big job better than five small ones?" Hint: weigh retry granularity, parallelism and failure visibility against per-job overhead (image pull, clone, cache restore).
script Keyword
Why it matters. script is the payload of the job: the commands that actually build, test or deploy. Everything else in a job prepares for these commands or reacts to them. How the runner executes them decides when a job fails, which is the most important behavior to understand.
What it is. script is a string or a list of strings (nested lists up to 10 levels deep are flattened). The runner runs them with the shell configured for it. In Linux containers it uses bash if the image has it and falls back to sh otherwise. Windows runners typically use PowerShell (pwsh).
Execution semantics:
- The commands of
before_scriptandscriptare combined into one shell session.cd,exportand shell functions persist from one line to the next. - Each command is echoed to the log as
$ <command>before it runs. - The job stops at the first command that exits with a non-zero code, and the job is marked
failed. - The job starts in
$CI_PROJECT_DIR, the checkout of the repository (for example/builds/my-group/my-project).
build:
image: golang:1.23
variables:
CGO_ENABLED: "0" # YAML-level variable, passed in as an environment variable
script:
- go version # echoed as "$ go version", then run
- export GOFLAGS="-trimpath" # persists for the following lines (same shell session)
- cd cmd/server # so does the working directory
- go build -o "$CI_PROJECT_DIR/bin/server" .
- cd "$CI_PROJECT_DIR"
- set -o pipefail # without this, the pipe below hides test failures
- go test ./... 2>&1 | tee test.log # exit code = go test's, not tee's
artifacts:
paths:
- bin/
- test.log
Failure semantics in practice.
| Situation | Outcome | Remedy |
|---|---|---|
make test exits with code 2 | Job fails at that line, later lines do not run | Intended behavior |
pytest | tee out.log and pytest fails | Pipe exits with tee's code 0, so the job passes | set -o pipefail before the pipe |
grep TODO src/ || true | Never fails, even when grep finds nothing | Use it only when failure is truly irrelevant |
A complex if/for block | Hard to read and test in YAML | Move it to ./ci/script.sh and call that |
| A command that may fail with a known exit code | Job fails | allow_failure:exit_codes (chapter 4) |
Keep logic in files. When a script grows past a few lines, move it into the repository (ci/build.sh) and call it from script. You can then run it locally, lint it with shellcheck and reuse it, and the YAML stays a readable outline of the pipeline.
Key takeaways
scriptis required for normal jobs. It is a string or a (nested) list of strings run by the runner's shell.before_scriptandscriptshare one shell session, so working directory and exported variables carry forward.- The job fails at the first non-zero exit code. Pipes hide failures unless you
set -o pipefail. - Move complex logic into versioned script files and keep the YAML declarative.
Practice
- (Beginner) Write a job with three commands where the second one is
false. Confirm that the third never runs, and find the exit code in the log.- (Intermediate) Create a job that runs
ls does-not-exist | wc -l. Observe that it passes, then make it fail correctly. Explain why the fix works.- Interview question: "The pipeline is green, but the test report shows failures. How can that happen?" Hint: look at how the test command is invoked. Pipes,
|| true, wrapper scripts that swallow exit codes andallow_failureare all candidates.
before_script and after_script
Why it matters. Most jobs have setup (install dependencies, log in to a registry) and teardown (print diagnostics, release a lock, clean up test resources). Keeping these apart from the main script makes intent clear, and the setup or teardown can then be shared through default.
What it is.
| Aspect | before_script | after_script |
|---|---|---|
| When it runs | Before script | After script, even if script failed. Since GitLab 17.0 it also runs when the job is canceled during before_script or script |
| Shell session | Same session as script | A new shell session |
Sees exported variables or cd from script | Yes (state flows into script) | No. The working directory is reset to the project directory and exports are gone |
Sees files created by script | Yes | Yes (same file system) |
| If it fails | The job fails and script does not run | Job status is unaffected and the exit code is ignored |
| Time limit | Counts toward the job timeout | A separate 5-minute limit, configurable with RUNNER_AFTER_SCRIPT_TIMEOUT |
| Knows the job result | No | Yes, through CI_JOB_STATUS (success, failed, canceled) |
┌──────────────── shell session 1 ────────────────┐ ┌──── shell session 2 ────┐
│ before_script ──(ok)──> script ──(ok or fail)───┼──>│ after_script │
│ │ │ │ (CI_JOB_STATUS is set) │
│ └──(fail)──> script skipped ───────────────┼──>│ │
└─────────────────────────────────────────────────┘ └─────────────────────────┘
Job status is decided by session 1; session 2 cannot change it.
integration-tests:
image: python:3.12-slim
before_script:
- pip install -r requirements.txt
- export API_URL="http://localhost:8080" # visible in script (same session)
script:
- ./start-test-server.sh > server.log 2>&1 & # start the server in the background
- ./wait-for-it.sh localhost:8080 --timeout=30
- pytest tests/integration
after_script:
- echo "Job finished with status $CI_JOB_STATUS"
- echo "API_URL='$API_URL'" # prints API_URL='' because this is a new shell session
- if [ "$CI_JOB_STATUS" != "success" ]; then tail -n 100 server.log; fi # files are still there
artifacts:
when: always
paths:
- server.log
Using them with defaults. Both keywords are often set under default (subchapter 2.4). A job that defines its own before_script replaces the default one, and the two are not combined. Write before_script: [] to switch off the default for one job.
Key takeaways
before_scriptruns in the same session asscript, and a failure in it fails the job beforescriptruns.after_scriptalways runs in a new session afterscript(also on cancellation since 17.0). It cannot change the job's status and does not see exported variables.- Use
CI_JOB_STATUSinsideafter_scriptto react differently to success, failure and cancellation. - A job-level
before_scriptorafter_scriptreplaces the default one completely.
Practice
- (Beginner) Write a job where
before_scriptexportsGREETING=hi,scriptprints it, andafter_scriptprints it too. Explain the difference in the output.- (Intermediate) Write a job whose
after_scriptuploads a debug bundle only whenCI_JOB_STATUSisfailed, and make the job fail on purpose to test it. Then makeafter_scriptitself fail and observe the job status.- Interview question: "Why shouldn't you put critical cleanup, such as deleting a cloud resource created by the job, only in
after_script?" Hint: think about its separate timeout, ignored exit codes and situations where the runner itself disappears.
image Keyword
Why it matters. "It works on my machine" usually means the build machine had different tools. image declares the exact toolchain a job needs as a container image, so every run of that job, on any runner, starts from the same environment. It is like handing each job a sealed, pre-packed toolbox instead of whatever happens to be on the bench.
What it is. image names the container image the job runs in. It applies to container-based executors (Docker, Docker Autoscaler, Kubernetes). The shell executor ignores it, because jobs run directly on the host there. If neither the job nor default sets an image, the runner uses the default image from its own configuration.
Image references follow standard container naming:
| Form | Example | Notes |
|---|---|---|
| Name only | alpine | Docker Hub, tag latest |
| Name and tag | python:3.12-slim | The most common form |
| Registry path | registry.example.com/platform/ci-tools:2.4 | Private registries need credentials (chapter 7) |
| GitLab registry | $CI_REGISTRY_IMAGE/builder:1.0 | Variables are expanded |
| Digest | python@sha256:4f0c... | Immutable and fully reproducible |
The short form and the extended form:
lint:
image: python:3.12-slim # short form
script:
- ruff check .
plan:
image:
name: hashicorp/terraform:1.9
entrypoint: [""] # the image's ENTRYPOINT is the terraform CLI; clear it so the runner can start a shell
pull_policy: if-not-present # always | if-not-present | never (the runner must allow the chosen policy)
docker:
platform: linux/arm64 # pull a specific architecture
user: "1000" # run the job as this user inside the container
script:
- terraform init
- terraform plan
Choosing a tag.
| Strategy | Example | Reproducible | Gets updates | Risk |
|---|---|---|---|---|
latest or no tag | node | No | Automatically | Builds break when upstream changes |
| Minor version | node:22 | Mostly | Patch and minor updates | Occasional surprises |
| Exact version | node:22.9.0-alpine3.20 | Yes | Only when you bump it | Must be kept up to date (automate with a dependency bot) |
| Digest | node@sha256:... | Fully | Only when you bump it | Least readable. Best for high-assurance pipelines |
Image requirements. The image must contain a shell (sh or bash), because the runner runs the script through one. Distroless and "scratch" images cannot be job images. The image does not need git: with container executors, the runner's helper image does the clone and handles artifacts and cache.
Key takeaways
imagemakes the job environment explicit and reproducible. It requires a container-based executor and is ignored by the shell executor.- Use the extended form for
entrypoint,pull_policyand Docker options such asplatformanduser. - Pin tags deliberately. Unpinned
latesttrades reproducibility for convenience. - Images whose
ENTRYPOINTis a CLI tool needentrypoint: [""], and every job image needs a shell.
Practice
- (Beginner) Run the same job (
cat /etc/os-release; python3 --version || true) withalpine:3.20,python:3.12-slimandubuntu:24.04. Compare the outputs.- (Intermediate) Use
hashicorp/terraform:1.9as a job image withoutentrypointand read the error. Then fix it with the extended syntax.- Interview question: "What are the trade-offs between pinning job images by tag and by digest, and how do you keep pinned images up to date?" Hint: mutable tags, supply-chain security, automated dependency update tools and the review effort of each bump.
tags Keyword
Why it matters. Not every runner can run every job. Some are Windows machines, some have GPUs, some run on ARM, and some sit inside a network segment with access to production. tags let a job state its requirements so that only capable runners pick it up, much as a parcel's shipping label decides which trucks may carry it.
What it is. tags is a list of strings. A runner can pick up a job only if the runner has all of the job's tags. The runner may have extra tags. Jobs without tags can be picked up only by runners that have the "Run untagged jobs" option enabled.
Runners
R1 tags: [docker, linux] run untagged: yes
R2 tags: [docker, linux, gpu] run untagged: no
R3 tags: [windows] run untagged: no
Job tags Eligible runners Why
------------------- ----------------- ---------------------------------------------
(none) R1 only R1 accepts untagged jobs
[docker] R1, R2 both have "docker"
[docker, gpu] R2 only R2 has both tags
[linux, windows] none -> stuck no runner has both tags; the job stays pending
default:
tags: [docker, linux] # baseline for all jobs
train-model:
tags: [docker, linux, gpu] # replaces the default list (lists are not merged)
script:
- python train.py
build-windows:
tags: [windows]
script:
- dotnet build -c Release
build-arch:
tags:
- docker
- $TARGET_ARCH # variables are expanded, e.g. "arm64"
variables:
TARGET_ARCH: arm64
script:
- make build
compile-on-gitlab-com:
tags: [saas-linux-medium-amd64] # selects a GitLab-hosted runner size on GitLab.com
script:
- make -j4
Tags as a security boundary. Deployment runners often hold credentials or network access to production. Give them a dedicated tag (for example prod-deploy), turn off "Run untagged jobs", and mark them protected so they run jobs only from protected branches and tags. Then a job on a feature branch cannot reach them, even if it uses the right tag.
Key takeaways
- A runner is eligible only if it has every tag the job lists. Extra runner tags do not matter.
- Untagged jobs need a runner with "Run untagged jobs" enabled.
- A job-level
tagslist replacesdefault:tags, and variables can be used in tags. - A job stuck in
pendingwith a "no runners" warning usually has a tag combination that no online runner matches.
Practice
- (Beginner) Find the tags of the runners available to one of your projects (Settings > CI/CD > Runners). Write one job each that can and cannot be picked up, and observe the stuck job.
- (Intermediate) Design a tagging scheme for a fleet with Linux amd64, Linux arm64 and macOS runners plus one protected production deployment runner. Show the
tagsof four representative jobs.- Interview question: "Why route deployment jobs to dedicated runners through tags even when every runner could technically run them?" Hint: consider credentials stored on the runner, network segmentation, protected runners and blast radius.
Hidden Jobs
Why it matters. Not everything in a pipeline file is meant to run. You need places to keep reusable templates and anchors, and a quick way to switch a job off temporarily without deleting it.
What it is. Any top-level key that starts with a dot (.) is a hidden job. GitLab parses it but never creates a job from it, and its content does not need to be a complete job. Hidden jobs have four common uses:
| Use | How |
|---|---|
Template for extends | .node-base: holds shared keywords, and real jobs write extends: .node-base |
Source for !reference | Pick out one keyword: !reference [.aws-login, script] |
| Holder for YAML anchors | .setup: &setup [...] (anchors cannot sit at the top level without a key) |
| Temporarily disabling a job | Rename e2e-tests: to .e2e-tests:. The diff is one character and the YAML stays validated |
.node-base: # template: never runs on its own
image: node:22
tags: [docker]
before_script:
- npm ci
.aws-login: # snippet source for !reference
script:
- aws sts get-caller-identity
lint:
extends: .node-base # inherits image, tags and before_script
script:
- npm run lint
deploy-assets:
extends: .node-base
script:
- !reference [.aws-login, script] # inserts the commands from .aws-login
- npm run build
- aws s3 sync dist/ "s3://$ASSETS_BUCKET/"
.e2e-tests: # disabled: re-enable by removing the dot
extends: .node-base
script:
- npm run test:e2e
Commenting a job out with # has the same effect, but a hidden job is still parsed and its structure stays visible to tools such as the pipeline editor. Re-enabling it is a one-character change.
Key takeaways
- A leading dot makes a job hidden: it is parsed but never runs.
- Hidden jobs hold templates (
extends), snippets (!reference) and YAML anchors. - Prefixing a dot is the cleanest way to disable a job temporarily.
- Hidden jobs do not need to be complete. They can contain a single keyword, or even just a list.
Practice
- (Beginner) Disable a job by adding a dot to its name, push, and confirm it disappears from the pipeline. Re-enable it.
- (Intermediate) Create a
.docker-loginhidden job with abefore_scriptthat logs in to the GitLab container registry, and reuse it in two jobs with!reference. One job should also run its own extra setup command.- Interview question: "What are the differences between reusing configuration through a hidden job with
extendsand through a YAML anchor?" Hint: compare where the template can live (same file or included files), deep and shallow merging, and readability in the Full configuration view.
Reserved Keywords
Why it matters. GitLab has to tell apart top-level keys that configure the pipeline from keys that name jobs. Inside a job, the set of allowed keywords is fixed. Knowing both lists prevents two classes of bug: a job that silently becomes pipeline configuration, and a typo rejected as an unknown key.
What it is.
Global keywords configure the whole pipeline and cannot be used as job names:
| Keyword | Purpose |
|---|---|
default | Default values for job keywords (subchapter 2.4) |
include | Import configuration from other files (chapter 9) |
stages | Names and order of stages (subchapter 2.3) |
variables | Global CI/CD variables (chapter 5) |
workflow | Control whether and how a pipeline is created (chapter 4) |
spec | Inputs for the file, in a header section separated by --- (chapter 9) |
Deprecated global keywords. image, services, cache, before_script and after_script at the top level still work, but they should move under default. They are also reserved, together with true, false and nil, so none of these can be job names either.
Job keywords form a closed set. The most common ones are grouped by purpose below, and GitLab rejects any key inside a job that is not a job keyword:
| Group | Keywords |
|---|---|
| Execution | script, before_script, after_script, image, services, tags, timeout, retry, interruptible |
| Ordering and control | stage, rules, needs, when, allow_failure, dependencies, parallel, resource_group, trigger, manual_confirmation |
| Data | variables, artifacts, cache, coverage, secrets, id_tokens |
| Deployment | environment, release, pages |
| Reuse and inheritance | extends, inherit, hooks |
# Pitfall 1: a "job" named with a reserved word is not a job at all
cache: # this is the (deprecated) global cache, applied to every job
paths:
- vendor/
# Pitfall 2: a typo in a job keyword is rejected
unit-tests:
scirpt: # error: jobs:unit-tests config contains unknown keys: scirpt
- make test
A third, subtler case: the job name pages has special meaning for GitLab Pages, and its public/ artifact is published as a static website. Avoid the name unless you mean to use Pages.
Key takeaways
- Global keywords (
default,include,stages,variables,workflow) and the deprecated globals cannot be job names. - A job named after a reserved word becomes global configuration, which may not raise any error.
- Job keywords are a closed set, so typos are caught as "unknown keys".
- Prefer
default:over the deprecated top-levelimage,services,cache,before_scriptandafter_script.
Practice
- (Beginner) Add a job named
variableswith ascriptand run CI Lint. Explain the error message in terms of how GitLab interprets the key.- (Intermediate) Take a configuration that uses top-level
image,before_scriptandcache, and migrate it todefault:. Use the Full configuration tab to show that the jobs resolve the same way.- Interview question: "Why does GitLab reject unknown keys in a job instead of ignoring them, and what would go wrong if it ignored them?" Hint: consider a typo in
rulesorwhenon a deployment job.
<a id="23-stages"></a>
2.3 Stages
Stages give a pipeline its coarse order. Every job belongs to exactly one stage, and stages run one after another. This subchapter covers the default stage list, defining your own stages, how GitLab sequences them, and the special first and last stages.
Default Stages
Why it matters. A pipeline needs some ordering out of the box: building before testing, testing before deploying. GitLab provides a sensible default so that small projects work with no stage configuration at all.
What it is. If .gitlab-ci.yml has no stages keyword, GitLab uses this list:
.pre ──> build ──> test ──> deploy ──> .post
A job with no stage keyword goes into test. A job that names a stage not in the list makes the whole configuration invalid.
# No "stages" keyword: the default list applies
compile:
stage: build
script:
- make
unit-tests: # no stage -> "test"
script:
- make test
publish:
stage: deploy
script:
- make publish
package:
stage: package # error: chosen stage package does not exist;
script: # available stages are .pre, build, test, deploy, .post
- make dist
Without the package job, the resulting pipeline is:
build test deploy
┌─────────┐ ┌────────────┐ ┌─────────┐
│ compile │──>│ unit-tests │──>│ publish │
└─────────┘ └────────────┘ └─────────┘
(.pre and .post have no jobs, so they are not shown)
Key takeaways
- The default stages are
.pre,build,test,deployand.post. - Jobs without a
stagego intotest. - Referring to a stage that is not defined is a validation error, not a silent fallback.
- Stages without jobs do not appear in the pipeline.
Practice
- (Beginner) Write a pipeline with no
stageskeyword and four jobs, one of them without astage. Predict the pipeline graph, then check it in the Visualize tab.- (Beginner) Add a job with
stage: lintand read the error. Fix it in two different ways.- Interview question: "Is relying on the default stages a good idea in a long-lived project?" Hint: consider readability for newcomers and what happens when someone needs a stage that is not in the default list.
Custom Stage Definitions
Why it matters. Real pipelines have phases the default list does not name, such as security scanning, packaging or deploying to several environments in sequence. Declaring stages explicitly documents the pipeline's shape at the top of the file.
What it is. The global stages keyword is an ordered list of stage names. It replaces build, test and deploy entirely, while .pre and .post always remain at the ends. Stage names are free-form strings, and each job's stage must be one of them.
stages:
- build
- test
- security
- package
- deploy-staging
- deploy-production
compile:
stage: build
script: [make build]
unit-tests:
stage: test
script: [make test]
dependency-scan:
stage: security
script: [make audit]
container-image:
stage: package
script: [make image]
deploy-staging:
stage: deploy-staging
script: [./deploy.sh staging]
deploy-production:
stage: deploy-production
when: manual # someone clicks "Run" to promote the release
script: [./deploy.sh production]
Stages and include. stages is a list, and lists are replaced, not merged, when included files are combined. If a template and your .gitlab-ci.yml both declare stages, the main file's list wins and the template's list is ignored. Shared templates therefore either use stage names that consumers must declare (and document them), or put their jobs in .pre or .post, which always exist.
How many stages?
| Too few stages | Too many stages |
|---|---|
| Unrelated work is grouped together, and the graph says little | Each stage boundary adds waiting time, because the next stage waits for the slowest job |
| A failure in one stage blocks everything after it, even unrelated work | Stages with one job each turn the pipeline into a slow serial chain |
| Harder to see where a pipeline failed | More configuration to maintain |
Aim for stages that match decisions in your delivery process ("is it built?", "is it correct?", "is it safe?", "is it deployed?"). Use needs (chapter 3) for finer-grained ordering.
Key takeaways
stagesdefines the order of stages and replaces the defaultbuild,testanddeploy..preand.postalways exist, whatever the list contains.- When files are included, the main file's
stagesreplaces anystagesin included files. - Choose stages that reflect the phases of your process, and keep their number small.
Practice
- (Beginner) Define stages
build,test,packageandrelease, and place one job in each. Confirm the order in the pipeline graph.- (Intermediate) Include a local template file that declares its own
stages: [scan]and a job inscan. Observe the error, then make the template work without the consumer changing theirstages.- Interview question: "You maintain a shared CI template used by 200 projects. How do you handle stages so the template works in projects with different stage lists?" Hint: think about
.preand.post, documented stage contracts, andneeds-based ordering.
Stage Execution Order
Why it matters. Stage ordering decides when each job can start and what happens after a failure. It also sets the length of the pipeline's critical path. Knowing the exact rules explains both "why did this job not run?" and "why is the pipeline so slow?".
What it is. Without needs, GitLab applies these rules:
- Stages run sequentially, in the order of the
stageslist. - Jobs in the same stage run in parallel, limited only by available runners and their concurrency settings.
- A stage starts only when every job in the previous stage has finished successfully. Jobs with
allow_failure: truecount as successful even if they fail. - If a job fails, later stages do not start. Their jobs are marked
skipped, except those withwhen: on_failureorwhen: always. Jobs already running in the failing stage finish normally. - A manual job (
when: manualwith norules) is optional by default and does not block later stages. A manual job withallow_failure: falseblocks the pipeline until someone runs it (chapter 4).
The stage boundary acts as a barrier: the slowest job in a stage holds up everything after it.
time ──────────────────────────────────────────────────────────────────>
build [compile-api ████]
[compile-web ████████████]
^ barrier: test waits for compile-web
test [unit ███]
[lint ██]
[e2e ██████████████]
^ barrier
deploy [deploy ███]
unit and lint finished early, but deploy still waits for e2e.
A failure, and a job that reacts to it:
stages: [build, test, deploy]
compile:
stage: build
script: [make build]
unit-tests:
stage: test
script: [make test] # suppose this fails
flaky-lint:
stage: test
allow_failure: true # a failure here would NOT block deploy
script: [make lint]
deploy:
stage: deploy
script: [./deploy.sh] # skipped: unit-tests failed
report-failure:
stage: deploy
when: on_failure # runs only because an earlier job failed
script:
- 'curl --fail -X POST -d "text=Pipeline $CI_PIPELINE_URL failed" "$CHAT_WEBHOOK_URL"'
build: compile (passed) ─> test: unit-tests (failed), flaky-lint (passed) ─> deploy: deploy (skipped), report-failure (passed)
Pipeline status: failed
Breaking the barrier. The needs keyword (chapter 3) lets a job start as soon as the specific jobs it depends on finish, regardless of stages. It turns the pipeline into a directed acyclic graph and often removes most of the waiting shown above.
Key takeaways
- Stages run in sequence, and jobs in a stage run in parallel.
- The next stage waits for all jobs of the current stage. The slowest job sets the pace.
- A failed job skips later stages, except jobs with
when: on_failureorwhen: always. Jobs withallow_failure: truenever block. - Use
needswhen strict stage barriers make the pipeline slower than necessary.
Practice
- (Beginner) Build a three-stage pipeline in which a test job fails. Predict which jobs are skipped, and add a
when: alwayscleanup job in the last stage that still runs.- (Intermediate) Measure a pipeline where one test job takes 5 minutes and another takes 30 seconds. Then add
needsso a packaging job depends only on the fast job, and compare the total duration.- Interview question: "Why can a purely stage-based pipeline be slower than necessary, and how would you find the critical path?" Hint: treat each stage as a barrier, and look at the pipeline graph's job durations and the "Job dependencies" view.
.pre and .post Stages
Why it matters. Some work has to happen before everything else (checking preconditions, computing a version number) or after everything else (notifications, cleanup), whatever stage list a project defines. The two built-in boundary stages guarantee that position.
What it is.
| Stage | Position | Can be reordered | Typical jobs |
|---|---|---|---|
.pre | Always first | No, even if listed elsewhere in stages | Validate configuration, check secrets exist, compute version metadata |
.post | Always last | No | Send notifications, publish summaries, clean up temporary environments |
Two more rules matter:
- A pipeline that would contain only
.preand.postjobs is not created. These stages decorate a pipeline and cannot make one on their own. .postjobs follow normal failure rules. With the defaultwhen: on_success, they are skipped if an earlier job failed. Usewhen: alwaysfor cleanup and notifications that must run every time.
stages: [build, test] # .pre and .post do not need to be listed
compute-version:
stage: .pre # runs before "build", whatever the stage list is
image: alpine:3.20
script:
- echo "APP_VERSION=1.4.$CI_PIPELINE_IID" >> version.env
artifacts:
reports:
dotenv: version.env # makes APP_VERSION available to later jobs
build:
stage: build
script:
- echo "Building version $APP_VERSION"
test:
stage: test
script: [make test]
cleanup:
stage: .post
when: always # run even if build or test failed
script:
- ./scripts/delete-temp-resources.sh "$CI_PIPELINE_ID"
Because .pre and .post exist in every pipeline, they suit included templates: a template can add a validation job in .pre without asking every project to change its stages list.
Key takeaways
.preis always first and.postalways last. Their positions cannot be changed.- They need no declaration in
stages, so included templates can rely on them. - A pipeline with only
.preand.postjobs is not created. .postjobs are skipped after a failure unless they usewhen: always.
Practice
- (Beginner) Add a
.prejob that fails if theCHANGELOG.mdfile is missing. Confirm that no other stage runs when it fails.- (Intermediate) Add a
.postjob that posts the pipeline status to a webhook, and make sure it runs for both successful and failed pipelines. PrintCI_PIPELINE_URLand the outcome.- Interview question: "Why are
.preand.postespecially useful in shared templates, and what is one limitation of using them?" Hint: think about thestagesmerge behavior with includes, and what happens when a.postjob needs to run after a failure.
<a id="24-global-defaults-and-inheritance"></a>
2.4 Global Defaults and Inheritance
Most jobs in a pipeline share settings: the same image, the same runner tags, the same retry policy. This subchapter covers how to define those settings once with default, how jobs inherit them, how a job can opt out with inherit, and the exact rules for overriding them.
default Keyword
Why it matters. Without defaults, every job repeats image, tags, retry and before_script. A change such as upgrading the base image then touches every job, and missing one causes inconsistent builds. default sets a value once for the whole pipeline, much as a company dress code applies to everyone unless a role specifies otherwise.
What it is. default is a global keyword whose value is a mapping of job keywords. Every job that does not set a keyword itself gets the value from default. Only some job keywords are supported:
Supported in default | Typical use |
|---|---|
image | Base toolchain for all jobs |
services | Sidecar containers such as databases |
before_script / after_script | Common setup and teardown |
tags | Default runner selection |
cache | Shared dependency cache settings |
artifacts | Default artifact settings, such as expire_in |
retry | Retry policy for flaky infrastructure |
timeout | Default job timeout |
interruptible | Allow new pipelines to cancel redundant running jobs |
hooks | Commands run at runner lifecycle points, such as before the source fetch |
id_tokens | OIDC tokens for cloud authentication |
variables is not part of default. Global variables are set with the top-level variables keyword (chapter 5). Keywords that describe a specific job, such as script, stage, rules and needs, cannot be set in default either.
default:
image: python:3.12-slim
tags: [docker, linux]
retry:
max: 2
when: [runner_system_failure, stuck_or_timeout_failure] # retry only infrastructure failures
timeout: 20m
interruptible: true
before_script:
- pip install -r requirements.txt
lint:
script:
- ruff check . # gets every value from default
test:
script:
- pytest
docs:
image: node:22 # overrides only the image; tags, retry, timeout... still apply
before_script:
- npm ci # replaces the default before_script entirely
script:
- npm run docs
The core rule: default fills in missing keywords. It is never merged into a keyword that the job already sets. docs above gets node:22 and npm ci instead of the defaults, not as well as them.
Key takeaways
defaultsets job keywords once for all jobs, and jobs override them by setting the keyword themselves.- Only some keywords are supported (
image,services,before_script,after_script,tags,cache,artifacts,retry,timeout,interruptible,hooks,id_tokens). - Global variables belong in the top-level
variableskeyword, not indefault. - Defaults fill gaps and are never merged into values the job defines itself.
Practice
- (Beginner) Refactor a pipeline with four jobs that repeat the same
image,tagsandretryso that these live indefault. Check that the Full configuration tab shows the same values per job.- (Intermediate) Add
interruptible: truetodefaultand push two commits quickly to the same branch with auto-cancel enabled. Observe which jobs are canceled, then mark the deployment jobinterruptible: falseand repeat.- Interview question: "Why do you think GitLab allows only some keywords in
default? Give an example of a keyword that would be dangerous as a default." Hint: considerrules,stageorenvironmentapplied to every job.
Global image and services
Why it matters. The image and the supporting services, such as a database or a cache server, are the defaults teams set most often. They are also the ones with the biggest performance cost when set carelessly. Older configurations set them at the top level, so you need to recognize both the old and the new forms.
What it is.
imagesets the container every job runs in (see Jobs, earlier in this chapter).servicesstarts extra containers alongside the job container, on a shared network. The job reaches each service by a hostname derived from the image name (postgres:16becomespostgres) or by an explicitalias. Services receive the job's CI/CD variables, which is how images such aspostgresget their configuration.
| Form | Example | Status |
|---|---|---|
| Top-level | image: ruby:3.3 at the root of the file | Deprecated, still works |
Under default | default: { image: ruby:3.3 } | Recommended |
| Job-level | test: { image: ruby:3.3 } | Overrides either of the above |
variables: # passed to the job AND to its service containers
POSTGRES_DB: app_test
POSTGRES_USER: runner
POSTGRES_PASSWORD: "test-only-password" # throwaway credentials for an ephemeral database
DATABASE_URL: "postgres://runner:test-only-password@db:5432/app_test"
default:
image: ruby:3.3
.with-postgres: # services only where they are needed
services:
- name: postgres:16
alias: db # reachable as host "db"
rubocop:
script:
- bundle install
- bundle exec rubocop # no database started: faster and cheaper
rspec:
extends: .with-postgres
script:
- bundle install
- bundle exec rails db:schema:load
- bundle exec rspec
Trade-off of global services. A service set in default starts for every job, including linters and deployment jobs that never use it. Each startup adds time and resource usage. Put services in a template (as .with-postgres above) or in individual jobs unless nearly all jobs need them.
default: services: [postgres] template / per-job services
lint [pg start][lint] lint [lint]
build [pg start][build] build [build]
rspec [pg start][rspec] rspec [pg start][rspec]
deploy [pg start][deploy] deploy [deploy]
Services are covered in depth in chapter 7.
Key takeaways
- Set the pipeline-wide
imageandservicesunderdefault. Top-levelimageandservicesare deprecated. - Services run as sidecar containers, reachable by a derived hostname or an explicit
alias, and receive the job's variables. - A service set in
defaultstarts for every job. Scope services to the jobs that need them. - A job's own
imageorservicesreplaces the default value entirely.
Practice
- (Beginner) Migrate a configuration using top-level
imageandservicestodefault. Verify in the Full configuration tab that nothing changed.- (Intermediate) Create a test job that uses
redis:7as a service with aliascache, and runsredis-cli -h cache pingfrom a job image that includesredis-cli. Then add a second service and explain how the job reaches each one.- Interview question: "A pipeline got 40% slower after someone added a database service to
default. Explain why, and how you would restructure the configuration." Hint: count how many jobs start the service and how many actually use it.
inherit Keyword
Why it matters. Defaults are convenient until one job must not have them. Examples are a job that triggers a downstream pipeline, a lightweight notification job that should not install dependencies, or a job that must not pass global variables to another project. Overriding each default one by one is tedious and easy to get wrong. inherit opts out wholesale or selectively.
What it is. inherit controls what a job takes from the two global sources:
| Setting | Effect |
|---|---|
inherit:default: true | (Default) inherit every keyword from default |
inherit:default: false | Inherit nothing from default |
inherit:default: [image, tags] | Inherit only the listed default keywords |
inherit:variables: true | (Default) inherit every top-level variable |
inherit:variables: false | Inherit no top-level variables |
inherit:variables: [VAR_A, VAR_B] | Inherit only the listed top-level variables |
inherit affects only default and top-level variables. It does not affect what a job gets through extends, !reference or YAML anchors.
variables:
DOCKER_REGISTRY: registry.example.com
DEPLOY_REGION: eu-west-1
INTERNAL_BUILD_FLAGS: "--internal"
default:
image: python:3.12-slim
tags: [docker]
before_script:
- pip install -r requirements.txt # slow; not needed everywhere
retry: 2
notify-chat:
inherit:
default: [tags] # keep the runner tags, skip image, before_script and retry
variables: false # no global variables needed
image: curlimages/curl:8.10.1
script:
- 'curl --fail -X POST -d "text=Build $CI_PIPELINE_IID started" "$CHAT_WEBHOOK_URL"'
trigger-deploy-project:
inherit:
variables: [DEPLOY_REGION] # forward only this variable downstream
trigger:
project: platform/deployments # trigger jobs pass their variables to the downstream pipeline
Trigger jobs are the classic use of inherit:variables. A trigger job forwards its variables, global ones included, to the downstream pipeline. Limiting inheritance keeps unrelated or internal values out of other projects.
Key takeaways
inherit:defaultandinherit:variablesaccepttrue,falseor a list of names to keep.- They control inheritance from
defaultand top-levelvariablesonly, not fromextends. - Use
inherit:default: falsefor jobs that need a completely different environment. - Use
inherit:variableson trigger jobs to control which variables reach downstream pipelines.
Practice
- (Beginner) Add a job with
inherit:default: falseto a pipeline that has adefault:before_script, and confirm in the log that the setup commands no longer run.- (Intermediate) Create a job that inherits only
tagsandtimeoutfromdefaultand only one of three global variables. Printenv | sortin the job to verify which variables are present.- Interview question: "A downstream project's pipeline started behaving differently after you added a global variable in the upstream project. What happened, and how do you prevent it?" Hint: consider which variables a trigger job forwards by default.
Job-Level Overrides of Defaults
Why it matters. Defaults, templates and job definitions are all combined into one final job configuration. When a job behaves unexpectedly, the cause is almost always a misunderstanding of how those layers combine: "I added a before_script, and my default setup disappeared." The rules are short, but they need to be exact.
What it is. For each keyword, GitLab resolves the value in this order of precedence:
highest ┌────────────────────────────────────┐
│ 1. Keyword set in the job itself │
├────────────────────────────────────┤
│ 2. Keyword from extends templates │ (later templates beat earlier ones)
├────────────────────────────────────┤
│ 3. Keyword from default │ (unless excluded with inherit:default)
├────────────────────────────────────┤
│ 4. Runner configuration │ (e.g. the runner's default image)
lowest └────────────────────────────────────┘
How a value from a higher layer combines with a lower one depends on the keyword type:
| Keyword type | Example keywords | Behavior when overridden |
|---|---|---|
| Scalars | image (string form), timeout, interruptible | Replaced |
| Lists | before_script, script, tags, services | Replaced as a whole, never appended |
Mappings from default | retry, cache, artifacts | Replaced as a whole (default fills gaps only) |
Mappings through extends | variables, artifacts, ... in templates | Merged recursively, key by key |
| Variables | Top-level variables and job variables | Merged by name, and the job value wins for the same name |
Removing a default. Set the keyword to an empty value, or exclude it with inherit:
docs-only:
before_script: [] # no setup commands
services: [] # no sidecar containers
tags: [] # untagged: needs a runner that accepts untagged jobs
cache: [] # no cache
script:
- echo "Building docs"
Alternatively, inherit:default: [retry] keeps only the listed defaults and drops the rest in one line.
Appending instead of replacing. Because lists are replaced, a job that needs the default setup plus one extra step has to restate the setup. Do it with a hidden job and !reference instead of copying the commands:
.common-setup:
before_script:
- pip install -r requirements.txt
default:
image: python:3.12-slim
before_script:
- !reference [.common-setup, before_script]
integration-tests:
before_script:
- !reference [.common-setup, before_script] # keep the shared setup...
- apt-get update && apt-get install -y --no-install-recommends postgresql-client # ...and add one step
script:
- pytest tests/integration
A full resolution example.
variables:
LOG_LEVEL: info
REGION: eu-west-1
default:
image: alpine:3.20
tags: [docker]
retry: 1
before_script:
- echo "default setup"
.go-template:
image: golang:1.23
variables:
GOFLAGS: "-mod=readonly"
build:
extends: .go-template
tags: [docker, arm64]
variables:
LOG_LEVEL: debug
script:
- go build ./...
The resolved build job, as shown in the Full configuration tab and at runtime:
build:
image: golang:1.23 # from .go-template (beats default)
tags: [docker, arm64] # from the job (replaces default list)
retry: 1 # from default (not set anywhere else)
before_script:
- echo "default setup" # from default
variables: # template and job variables merged; global ones merged at runtime
GOFLAGS: "-mod=readonly" # from .go-template
LOG_LEVEL: debug # from the job (beats global "info")
# REGION=eu-west-1 is still available as a global variable
script:
- go build ./...
Key takeaways
- Precedence is job keyword, then
extendstemplates, thendefault, then runner configuration. - Lists such as
before_script,tagsandservicesare replaced, never appended. Use!referenceto append. - Variables are the exception: global and job variables are merged by name, and the job value wins.
- Clear a default with an empty value (
[]) or exclude it withinherit:default.
Practice
- (Beginner) Given a
defaultwithimage,tagsandbefore_script, write a job that keeps the image, replaces the tags and removesbefore_script. Check the result in the Full configuration tab.- (Intermediate) Two templates both set
variablesandimage, and a job usesextends: [.a, .b]and sets one variable of its own. Predict the resolved job, verify it in the pipeline editor, and explain each value's origin.- Interview question: "A teammate added a
before_scriptto one job to install an extra package, and now the job fails withcommand not foundfor a tool that the defaultbefore_scriptinstalls. Explain the root cause and two fixes." Hint: lists are replaced, not appended, and consider!reference.
<a id="3-pipeline-architecture"></a>