GitLab CI Stages and Jobs
Master GitLab CI/CD stages and jobs, learn how to define pipeline stages, write job scripts, use rules and only/except conditions, control job ordering, and run parallel jobs.
GitLab CI/CD pipelines look simple at first: you commit a .gitlab-ci.yml file, GitLab reads it, and a runner executes the jobs. The real power comes from how you break that work into stages and jobs. If you understand those two building blocks well, you can design pipelines that are readable, fast, and safe.
This lesson explains how stages set the high-level order of a pipeline, how jobs do the actual work, how to control when jobs run, and how to speed things up with parallel execution and DAG-style dependencies.
What Are Stages in GitLab CI?
A stage is a named phase in your pipeline. Think of stages as checkpoints in a delivery process. A common sequence is:
stages:
- build
- test
- deploy
This means:
- Run all jobs assigned to
build - If
buildsucceeds, run all jobs assigned totest - If
testsucceeds, run all jobs assigned todeploy
Stages create a clean mental model. Instead of seeing a long flat list of jobs, you see a workflow: build the application, verify it, then release it.
Sequential execution between stages
GitLab executes stages sequentially by default. Every job in one stage must finish successfully before the next stage starts. That behavior is useful because it creates natural safety gates.
For example, you usually do not want deployment to begin if tests are still failing. With stages, GitLab gives you that protection automatically.
GitLab CI/CD Pipeline Stages (Sequential)
Each stage must pass before the next begins — jobs within a stage can run in parallel
Inside a stage, however, multiple jobs can run at the same time if runners are available. That means the stage order is sequential, but the jobs within a stage can be parallel.
Default Stages in GitLab
If you do not define a custom stages: array, GitLab has default stage names. The most commonly discussed defaults are:
buildtestdeploy
In practice, most real projects define their own explicit stages: list even when they use those same names. Doing so makes the pipeline easier to read and prevents confusion later when you add extra phases such as lint, package, security, or release.
A beginner-friendly pipeline might look like this:
stages:
- build
- test
- deploy
A more realistic production pipeline might look like:
stages:
- validate
- build
- test
- security
- package
- deploy
The names are flexible. GitLab does not care what you call a stage as long as jobs refer to valid stage names.
The stages: Keyword and Ordering Rules
The stages: keyword defines the order of stages from top to bottom.
stages:
- lint
- unit_test
- integration_test
- deploy_staging
- deploy_production
GitLab uses the list order exactly as written. If a job belongs to integration_test, it will not start until every required job in unit_test is done. If a stage name is missing from stages:, the job configuration is invalid.
A few practical rules matter:
- Stages are global for the pipeline.
- Every job belongs to a stage.
- If you omit
stage:on a job, GitLab assigns it to the defaultteststage. - Jobs in the same stage are eligible to run in parallel.
- A failure in one stage normally stops later stages from starting.
That last point is one of the biggest reasons stages are useful. They turn your pipeline into a guarded path rather than a pile of unrelated tasks.
What Are Jobs?
A job is the actual unit of execution in GitLab CI/CD. It is a named block in .gitlab-ci.yml that tells a runner what to do.
build-app:
stage: build
image: node:20
script:
- npm ci
- npm run build
Here, build-app is the job name. It belongs to the build stage, runs in a node:20 container, and executes the shell commands listed under script:.
You can think of a job as a task card with three basic questions:
- When does it run? (
stage,rules,only,except) - Where does it run? (
image,tags) - What does it do? (
script,before_script,after_script)
A pipeline can have one job or many. In a real project, you usually separate concerns into small focused jobs because that makes failures easier to debug.
Core Job Keywords
Jobs support many keywords, but a few appear in almost every pipeline.
script
script is the heart of the job. It contains the commands the runner executes.
unit-tests:
stage: test
image: node:20
script:
- npm ci
- npm test
If any command exits with a non-zero status, the job fails.
before_script
before_script defines commands that run before script. It is commonly used for setup steps such as printing tool versions or preparing environment files.
lint:
stage: test
image: node:20
before_script:
- node --version
- npm --version
script:
- npm ci
- npm run lint
You can define before_script globally or per job.
after_script
after_script runs after the main script, even if the job fails. It is useful for cleanup, diagnostics, or collecting logs.
integration-tests:
stage: test
script:
- ./run-integration-tests.sh
after_script:
- echo "Tests finished"
- ./collect-debug-logs.sh
image
image chooses the container image used by the job when you run on Docker-based runners.
security-scan:
stage: test
image: alpine:3.20
script:
- echo "Run lightweight checks here"
This lets different jobs use different runtimes in the same pipeline. Your frontend build can use Node, a test job can use Python, and a packaging job can use Alpine.
tags
tags tell GitLab which runners are allowed to pick up the job. This matters when you have multiple self-hosted runners with different capabilities.
docker-build:
stage: build
tags:
- docker
- linux
script:
- docker build -t myapp .
If no available runner matches the tags, the job stays pending.
environment
environment connects a job to a deployment environment such as staging or production.
deploy-staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
script:
- ./deploy-staging.sh
This makes deployments visible in GitLab and is especially useful once you start promoting releases between environments.
A Simple Multi-Stage Pipeline
Let us combine stages and jobs in one example:
stages:
- build
- test
- deploy
build-app:
stage: build
image: node:20
script:
- npm ci
- npm run build
unit-tests:
stage: test
image: node:20
script:
- npm ci
- npm test
lint:
stage: test
image: node:20
script:
- npm ci
- npm run lint
deploy-prod:
stage: deploy
image: alpine:3.20
script:
- echo "Deploying application"
What happens here?
build-appruns first because it is in thebuildstage.unit-testsandlintboth belong totest, so GitLab can run them in parallel.deploy-prodwaits until both test jobs succeed.
That single behavior explains a huge part of how GitLab pipelines feel in practice.
Controlling When Jobs Run with rules
Not every job should run on every pipeline. You might want to run lightweight checks on feature branches, but deploy only from main. Modern GitLab pipelines usually use rules for this.
A rule can inspect branch names, pipeline sources, file changes, and desired timing.
rules: if
Use if expressions to decide based on CI variables.
deploy-prod:
stage: deploy
script:
- ./deploy.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
That job only runs on the main branch.
You can also target merge request pipelines:
review-check:
stage: test
script:
- npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
rules: changes
Use changes to run a job only when specific files are touched.
docs-check:
stage: test
script:
- echo "Validate docs"
rules:
- changes:
- docs/**/*
- README.md
This is powerful for monorepos and large projects because it avoids unnecessary work.
rules: when
A rule can also set when the job runs.
release-notes:
stage: deploy
script:
- ./publish-release-notes.sh
rules:
- if: '$CI_COMMIT_TAG'
when: on_success
Common when values include:
on_success- default behaviormanual- requires a human to click Runalways- run even if earlier jobs failednever- do not run
Rule order matters
GitLab evaluates rules from top to bottom and stops at the first match. That means ordering is important.
example-job:
stage: test
script:
- echo "hello"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
- if: '$CI_PIPELINE_SOURCE == "push"'
On main, the first rule matches and the job becomes manual. GitLab does not continue to later rules.
only and except: Older Syntax
Before rules became the preferred approach, GitLab pipelines often used only and except.
deploy-prod:
stage: deploy
script:
- ./deploy.sh
only:
- main
Or:
nightly-job:
stage: test
script:
- ./nightly.sh
except:
- branches
only and except still appear in older repositories and are worth understanding, but rules are more flexible and usually easier to extend. For new pipelines, prefer rules unless you are maintaining an existing style for consistency.
Manual Jobs with when: manual
Some steps should not run automatically, even in a successful pipeline. Production deploys are the classic example. GitLab supports this with manual jobs.
deploy-production:
stage: deploy
script:
- ./deploy-production.sh
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
When the pipeline reaches this job, GitLab pauses and shows a play button in the UI. A human can then trigger the deployment when ready.
Manual jobs are useful for:
- production deploy approvals
- database migrations that need supervision
- optional troubleshooting or rollback tasks
- release publishing after review
They are one of the simplest ways to add a control gate without redesigning the entire pipeline.
Parallel Jobs
Parallelism is where GitLab can save you a lot of time.
Jobs in the same stage run in parallel
The easiest form of parallelism comes automatically. If you place multiple jobs in the same stage, GitLab can run them at the same time.
stages:
- test
unit-tests:
stage: test
script:
- npm test
lint:
stage: test
script:
- npm run lint
security-audit:
stage: test
script:
- npm audit --production
If you have enough runners, those three jobs can execute together.
The parallel keyword
GitLab also lets you split one job into several copies.
browser-tests:
stage: test
script:
- ./run-browser-tests.sh
parallel: 4
GitLab creates four parallel job instances. This is useful when your test suite can be sharded across workers.
Matrix builds
For structured combinations, use parallel:matrix.
build-matrix:
stage: build
image: node:20
script:
- echo "Node $NODE_VERSION on $TARGET_ENV"
- npm ci
- npm run build
parallel:
matrix:
- NODE_VERSION: [18, 20]
TARGET_ENV: [staging, production]
That single job definition expands into multiple jobs:
- Node 18 / staging
- Node 18 / production
- Node 20 / staging
- Node 20 / production
Matrix builds are excellent for cross-version testing and multi-environment packaging.
Job Dependencies with needs for DAG Pipelines
Stages are easy to understand, but sometimes they are too strict. Suppose one test job only depends on the build output, not on every other test job. With normal stage ordering, it still waits for the full previous stage.
The needs keyword solves this by creating a DAG pipeline.
stages:
- build
- test
- deploy
build-app:
stage: build
script:
- npm ci
- npm run build
unit-tests:
stage: test
needs:
- build-app
script:
- npm test
lint:
stage: test
script:
- npm run lint
With needs, unit-tests can start as soon as build-app finishes, even if other jobs in the earlier stage are still running or if the stage structure would otherwise cause waiting.
This produces a directed acyclic graph, or DAG, instead of a strict stage-by-stage wall.
Why needs matters
needs helps when:
- one downstream job only depends on one upstream job
- a slow job should not block unrelated work
- you want faster feedback from critical tests
- you want to download artifacts directly from a needed job
A common pattern is to keep stages for readability but use needs to remove unnecessary waiting.
Complete Realistic .gitlab-ci.yml Example for a Node.js App
The following example brings the concepts together. It assumes a Node.js application with linting, unit tests, a build step, and a manual production deploy.
stages:
- validate
- build
- test
- deploy
default:
image: node:20
before_script:
- node --version
- npm --version
lint:
stage: validate
script:
- npm ci
- npm run lint
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
unit-tests:
stage: test
script:
- npm ci
- npm test -- --ci
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
build-app:
stage: build
script:
- npm ci
- npm run build
rules:
- if: '$CI_COMMIT_BRANCH'
package-app:
stage: test
needs:
- build-app
script:
- test -d dist
- tar -czf app.tar.gz dist
rules:
- if: '$CI_COMMIT_BRANCH'
integration-tests:
stage: test
needs:
- build-app
script:
- npm ci
- npm run test:integration
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
preview-deploy:
stage: deploy
environment:
name: preview/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.preview.example.com
script:
- ./scripts/deploy-preview.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
production-deploy:
stage: deploy
environment:
name: production
url: https://example.com
script:
- ./scripts/deploy-production.sh
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
How this pipeline behaves
lintruns early invalidateto provide fast feedback.build-appcompiles the project.unit-testsandpackage-apprun after their conditions are met.integration-testsonly run onmain, which keeps feature branch pipelines faster.preview-deployruns for merge requests.production-deployappears only onmainand requires a manual click.
This is the kind of pipeline structure you see in real teams: clear phases, controlled execution, and faster paths where dependencies allow it.
Practical Tips for Better Jobs and Stages
A few habits make pipelines much easier to maintain:
Keep job names descriptive
Names like test or build are acceptable in tiny demos, but in real repositories clearer names help:
frontend-unit-testsapi-integration-testsbuild-web-appdeploy-staging
Prefer many small jobs over one giant job
A giant job is hard to debug. If linting fails, you should not need to scroll through packaging logs to find the issue. Smaller jobs also enable better parallelism.
Use rules intentionally
Do not let every expensive job run on every branch by default. Ask whether it belongs on feature branches, merge requests, scheduled pipelines, tags, or only on main.
Use needs to reduce waiting, not to create confusion
If the pipeline graph becomes hard to understand, simplify it. Fast pipelines are good, but readable pipelines are maintainable.
Match job images to the task
Use a Node image for Node work, a Python image for Python work, and a lightweight utility image for simple shell tasks. This keeps jobs predictable.
Common Beginner Mistakes
-
Forgetting to declare a stage A job references
stage: package, butpackageis missing fromstages:. -
Putting every command in one job This removes the advantages of visibility and parallel execution.
-
Using
onlyandexcepteverywhere in new pipelines It works, but new work is usually cleaner withrules. -
Making production deployment automatic too early A manual gate is often safer while your pipeline matures.
-
Ignoring runner tags A job can stay pending forever if no runner matches its required tags.
Final Takeaway
Stages give your pipeline structure. Jobs do the work. rules decide when work should happen. parallel and needs make the pipeline faster. Once those concepts click, .gitlab-ci.yml stops feeling like magic and starts feeling like a workflow you control.
The next step after mastering stages and jobs is learning how files move through the pipeline with artifacts and how repeated work is sped up with cache.
Knowledge Check
Question 1: Stage Ordering
What is the default behavior of GitLab when you define stages named build, test, and deploy?
Question 2: Rules vs Only
Why is rules usually preferred over only and except in newer GitLab pipelines?
Question 3: The Needs Keyword
What is the main benefit of using needs in a GitLab pipeline?