DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Cicd Tutorial

Introduction to CI/CD
Getting Started with GitLab CI/CD
Stages, Jobs, Artifacts & Cache
Variables, Secrets and Environments in GitLab CI
GitLab Runners
GitLab CI Pipeline Rules
GitLab CI Environments
GitLab CI Docker Workflows
GitLab CI Testing
GitLab CI Security Scanning
GitLab CI Deploy to AWS
GitLab CI Multi-Project Pipelines
GitLab CI Best Practices

GitLab CI Stages and Jobs

PreviousPrev
Next

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:

  1. Run all jobs assigned to build
  2. If build succeeds, run all jobs assigned to test
  3. If test succeeds, run all jobs assigned to deploy

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)

🔨buildcompile / package
passes →
✅testunit / integration
passes →
🚀deployto staging / prod

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:

  • build
  • test
  • deploy

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 default test stage.
  • 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-app runs first because it is in the build stage.
  • unit-tests and lint both belong to test, so GitLab can run them in parallel.
  • deploy-prod waits 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 behavior
  • manual - requires a human to click Run
  • always - run even if earlier jobs failed
  • never - 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

  • lint runs early in validate to provide fast feedback.
  • build-app compiles the project.
  • unit-tests and package-app run after their conditions are met.
  • integration-tests only run on main, which keeps feature branch pipelines faster.
  • preview-deploy runs for merge requests.
  • production-deploy appears only on main and 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-tests
  • api-integration-tests
  • build-web-app
  • deploy-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

  1. Forgetting to declare a stage A job references stage: package, but package is missing from stages:.

  2. Putting every command in one job This removes the advantages of visibility and parallel execution.

  3. Using only and except everywhere in new pipelines It works, but new work is usually cleaner with rules.

  4. Making production deployment automatic too early A manual gate is often safer while your pipeline matures.

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

Exercise

Question 1: Stage Ordering

What is the default behavior of GitLab when you define stages named build, test, and deploy?

Exercise

Question 2: Rules vs Only

Why is rules usually preferred over only and except in newer GitLab pipelines?

Exercise

Question 3: The Needs Keyword

What is the main benefit of using needs in a GitLab pipeline?

PreviousPrev
Next

Continue Learning

Introduction to CI/CD

Learn what CI/CD means, why every software team needs it, how Continuous Integration, Continuous Delivery, and Continuous Deployment differ, and what a modern pipeline looks like from commit to production.

12 min read·Easy

Getting Started with GitLab CI/CD

Create your first GitLab CI/CD pipeline from scratch, write a .gitlab-ci.yml file, understand GitLab Runners, trigger your first pipeline, and read the job output. Step-by-step tutorial for beginners.

14 min read·Easy

Stages, Jobs, Artifacts & Cache

Understand how GitLab pipelines are organized with stages and jobs, how artifacts move files between jobs, and how cache speeds up repeated work.

6 min read·Easy

Explore Related Topics

Do

Docker Tutorials

Build and push Docker images in your pipelines

Gi

Git Tutorials

Trigger CI/CD pipelines from Git events

Try the Tool

YAML Validator

Validate GitHub Actions and CI/CD pipeline YAML files.

Cron Parser

Build and understand scheduled workflow cron expressions.

On This Page

What Are Stages in GitLab CI?Sequential execution between stagesDefault Stages in GitLabThe `stages:` Keyword and Ordering RulesWhat Are Jobs?Core Job Keywords`script``before_script``after_script``image``tags``environment`A Simple Multi-Stage PipelineControlling When Jobs Run with `rules``rules: if``rules: changes``rules: when`Rule order matters`only` and `except`: Older SyntaxManual Jobs with `when: manual`Parallel JobsJobs in the same stage run in parallelThe `parallel` keywordMatrix buildsJob Dependencies with `needs` for DAG PipelinesWhy `needs` mattersComplete Realistic `.gitlab-ci.yml` Example for a Node.js AppHow this pipeline behavesPractical Tips for Better Jobs and StagesKeep job names descriptivePrefer many small jobs over one giant jobUse `rules` intentionallyUse `needs` to reduce waiting, not to create confusionMatch job images to the taskCommon Beginner MistakesFinal TakeawayKnowledge Check