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 Pipeline Rules

PreviousPrev
Next

Learn GitLab CI rules, rules if, rules changes, rules exists, workflow rules, merge request pipeline conditions, manual jobs, and how to control exactly when GitLab pipelines and jobs run.

One of the fastest ways to waste CI minutes is to run every GitLab job on every push, tag, merge request, and schedule. That noisy approach might look simple at first, but it scales poorly. Frontend tests run even when only backend files changed. Production deployment jobs appear in feature branch pipelines where they can never be used. Documentation-only commits trigger expensive integration suites. Teams start ignoring red pipelines because too much irrelevant automation is running.

That is why GitLab CI rules matter. Rules let you decide when a job should run, when it should be manual, when it should be skipped, and even when the entire pipeline should exist. Once you learn GitLab CI rules if, GitLab CI rules changes, GitLab CI rules exists, and workflow:rules, you can build pipelines that are cheaper, faster, and much easier to understand.

Why Controlling When Jobs Run Matters

A well-designed pipeline should create signal, not noise. Job conditions matter because they help you:

  • save CI minutes and infrastructure cost
  • shorten feedback loops by skipping irrelevant work
  • avoid duplicate pipelines on the same change
  • keep deployment jobs away from unsafe contexts
  • show developers only the actions that matter for their branch or merge request

For example, a monorepo may contain frontend, backend, infrastructure, and docs directories. If a docs change triggers the full stack every time, the pipeline becomes slow and expensive. With rules, you can make GitLab intelligent enough to run only the required jobs.

rules vs the Older only/except

Historically, GitLab pipelines used only and except to decide whether jobs should run. That syntax still appears in older tutorials, but rules is the modern and more flexible approach.

Older pattern with only

deploy-prod:
  script: ./deploy.sh
  only:
    - main

This is simple, but limited. Once you want branch conditions, merge request logic, file change checks, manual behavior, and failure handling in one place, only/except becomes awkward.

Recommended pattern with rules

deploy-prod:
  script: ./deploy.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

With rules, each rule item can combine conditions and behaviors such as if, changes, exists, when, and allow_failure. That makes pipeline logic much more expressive.

rules:if for Variable-Based Conditions

The most common rule type is rules:if, which evaluates a condition using CI/CD variables.

A simple example that runs only on the main branch:

unit-tests:
  script: npm test
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

If the expression is true, the job is included. If no rule matches, the job is skipped unless you add a catch-all rule.

Common CI variables

You will use a few predefined variables frequently:

  • CI_COMMIT_BRANCH - the branch name for branch pipelines
  • CI_MERGE_REQUEST_ID - set when the pipeline belongs to a merge request context
  • CI_PIPELINE_SOURCE - why the pipeline was created, such as push, merge_request_event, schedule, or web
  • CI_COMMIT_TAG - set for tag pipelines

These variables let you describe most GitLab pipeline trigger conditions.

Example: run only on main branch

build-release:
  script: npm run build
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Example: run only on merge requests

mr-lint:
  script: npm run lint
  rules:
    - if: '$CI_MERGE_REQUEST_ID'

Here, the rule matches when CI_MERGE_REQUEST_ID exists. This is a concise way to create a GitLab CI merge request pipeline job.

Example: skip on tags

branch-tests:
  script: npm test
  rules:
    - if: '$CI_COMMIT_TAG'
      when: never
    - when: on_success

This pattern says: if the pipeline is for a tag, never run the job; otherwise run it normally.

Example: target by pipeline source

nightly-scan:
  script: ./security-scan.sh
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'

This is useful when the same repository supports push pipelines, scheduled pipelines, API-triggered pipelines, and merge request pipelines.

rules:changes for File-Based Execution

Sometimes you do not care which branch changed. You care what files changed. That is what rules:changes is for.

Suppose you have a frontend directory and only want frontend tests when frontend code changes.

frontend-tests:
  script:
    - cd frontend
    - npm ci
    - npm test
  rules:
    - changes:
        - frontend/**/*

This is one of the most valuable optimizations in a monorepo. It makes GitLab CI rules changes a powerful cost-control feature.

Other good use cases include:

  • run Terraform validation only when infra/ changes
  • run docs checks only when docs/ changes
  • rebuild a Docker image only when Dockerfile or app code changes
  • trigger mobile jobs only when ios/ or android/ changes

Example with multiple paths

backend-tests:
  script: pytest
  rules:
    - changes:
        - api/**/*
        - requirements.txt

That job now runs when backend code or Python dependencies change.

rules:exists for Repository-Aware Jobs

The third major condition type is rules:exists. This tells GitLab to include a job only if specific files are present in the repository.

docker-build:
  script: docker build -t myapp .
  rules:
    - exists:
        - Dockerfile

This is useful for reusable templates or shared CI configuration. You might include a generic job in many repositories, but only want it to activate when the repo actually contains certain files.

Examples:

  • run a Docker build only if Dockerfile exists
  • run Node jobs only if package.json exists
  • run Terraform checks only if *.tf files exist

rules:exists is especially helpful in platform engineering, where one central CI template serves many project types.

rules:when and Job Behavior

A rule does not just decide whether a job is created. It can also decide how that job behaves through rules:when.

Common values are:

  • on_success - default behavior, run if earlier stages succeed
  • always - run regardless of earlier stage failures
  • on_failure - run only if something earlier failed
  • manual - require a person to trigger the job
  • never - explicitly exclude the job
  • delayed - schedule the job to run after a delay

Manual job example

deploy-staging:
  script: ./deploy-staging.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

This is common when you want a deploy button to appear, but you do not want GitLab to deploy automatically.

Failure-handling example

collect-debug-logs:
  script: ./collect-logs.sh
  rules:
    - when: on_failure

That job is useful for diagnostics after a failing build or test stage.

Delayed job example

post-release-check:
  script: ./verify-release.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: delayed
      start_in: 10 minutes

Delayed jobs are less common, but helpful for staged rollouts or post-deploy checks.

rules:allow_failure

Some jobs are informative but should not block the whole pipeline. For those, you can combine rules with allow_failure.

experimental-scan:
  script: ./experimental-check.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      allow_failure: true

If this job fails, the pipeline still succeeds. That is useful for optional quality checks, early-stage security scanning, or experimental tooling.

Manual Jobs with when: manual and allow_failure: true

A very common pattern is a manual action that should not mark the pipeline as failed if nobody clicks it.

deploy-preview:
  script: ./deploy-preview.sh
  rules:
    - if: '$CI_MERGE_REQUEST_ID'
      when: manual
      allow_failure: true

This says:

  • only show the job in merge request pipelines
  • make it manual
  • do not fail the pipeline if the job is never started or if it fails in an optional context

Teams often use this for review apps, ad hoc preview deployments, or optional extra checks.

Combining Multiple Rules

Understanding how multiple rule items are evaluated is essential.

OR logic between rule items

Each item in the rules: list is evaluated from top to bottom. The first matching item decides the outcome. In practice, this means the list behaves like OR logic between items.

job:
  script: echo hello
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_MERGE_REQUEST_ID'

This job runs if the branch is main or if the pipeline is a merge request pipeline.

AND logic within one rule item

Within a single rule item, conditions combine together. If you place if and changes in the same item, both must match.

frontend-build:
  script: npm run build
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - frontend/**/*

This job runs only when the pipeline is a merge request pipeline and frontend files changed.

Rule ordering matters

Because the first matching rule wins, put the most specific exclusions first.

job:
  script: echo test
  rules:
    - if: '$CI_COMMIT_TAG'
      when: never
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual
    - when: on_success

This means:

  1. skip tag pipelines
  2. make the job manual on main
  3. run normally everywhere else

Merge Request Pipelines vs Branch Pipelines

A common source of confusion is duplicate pipelines. If a push to a branch that has an open merge request creates both a branch pipeline and a merge request pipeline, your jobs may run twice.

Why does this happen? GitLab can create pipelines for different sources:

  • push
  • merge_request_event
  • schedule
  • web
  • api

If your rules do not distinguish these sources, duplicate or noisy execution is easy to create.

A typical strategy is:

  • run rich validation in merge request pipelines
  • run deployment or release jobs on main branch pipelines
  • limit branch-only pipelines for feature branches

Example:

lint:
  script: npm run lint
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

This job runs in merge request pipelines and on main branch pipelines, but not on every feature branch push.

workflow:rules for Controlling the Entire Pipeline

Job-level rules are powerful, but sometimes you want to stop an entire pipeline from being created. That is where workflow:rules comes in.

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - when: never

This says:

  • create pipelines for merge request events
  • create pipelines for main branch pushes
  • create no pipeline for anything else

This is one of the best ways to avoid unnecessary feature branch pipelines and duplicate execution. It lets you centralize high-level pipeline creation logic before job-level rules are even considered.

Complete Practical Example: MR vs Main Branch

Here is a realistic .gitlab-ci.yml example that behaves differently for merge requests and for the main branch.

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
    - when: never

stages:
  - lint
  - test
  - build
  - deploy

lint:
  stage: lint
  image: node:20
  script:
    - npm ci
    - npm run lint
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

frontend-tests:
  stage: test
  image: node:20
  script:
    - cd frontend
    - npm ci
    - npm test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - frontend/**/*
        - package-lock.json
    - if: '$CI_COMMIT_BRANCH == "main"'

build-app:
  stage: build
  image: node:20
  script:
    - npm ci
    - npm run build
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

preview-deploy:
  stage: deploy
  script: ./deploy-preview.sh
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual
      allow_failure: true

production-deploy:
  stage: deploy
  script: ./deploy-prod.sh
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: manual

How this works:

  • workflow:rules creates pipelines only for merge requests and main.
  • lint runs in both important contexts.
  • frontend-tests runs in merge requests only when frontend files changed, and always on main.
  • build-app runs only on main.
  • preview-deploy appears as an optional manual job in merge requests.
  • production-deploy appears as a manual job on main.

This is a strong pattern because it keeps merge request pipelines focused on validation and keeps main branch pipelines ready for release.

Common Rule Design Mistakes

  1. Using only/except in new pipelines when rules would be clearer.
  2. Forgetting a catch-all rule and wondering why a job never appears.
  3. Ignoring CI_PIPELINE_SOURCE and accidentally creating duplicate pipelines.
  4. Using changes without thinking about shared files like lock files or reusable configs.
  5. Putting broad rules before specific exclusions, which makes later rules irrelevant.

Final Takeaway

GitLab pipeline rules are not just syntax trivia. They are how you turn a pipeline from a blunt instrument into a precise automation system. Use rules:if for branch, tag, and pipeline-source logic. Use rules:changes to save CI minutes in monorepos. Use rules:exists for reusable templates. Use when, manual, and allow_failure to shape job behavior. And when you need to decide whether a pipeline should exist at all, reach for workflow:rules.


Knowledge Check

Exercise

Question 1: Why Rules Matter

What is a major benefit of using GitLab CI rules instead of running every job on every pipeline?

Exercise

Question 2: Changes Logic

When should you use rules:changes in a GitLab pipeline?

Exercise

Question 3: Workflow Rules

What is the main purpose of workflow:rules in GitLab CI/CD?

PreviousPrev
Next

Continue Learning

GitLab CI Cache

Speed up your GitLab CI pipelines with caching, learn how to define cache paths, set cache keys, understand cache policies, and cache node_modules, pip packages, and Maven dependencies.

10 min read·Easy

Variables, Secrets and Environments in GitLab CI

Master GitLab CI/CD variables, predefined variables, custom variables, masked and protected secrets, group-level variables, environment-scoped variables, and .env file injection.

12 min read·Easy

GitLab Runners

Learn what a GitLab Runner is, how GitLab CI runners execute jobs, runner types, executor choices, runner registration, tags, security, concurrency, and a full Docker executor example.

12 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

Why Controlling When Jobs Run Matters`rules` vs the Older `only/except`Older pattern with `only`Recommended pattern with `rules``rules:if` for Variable-Based ConditionsCommon CI variablesExample: run only on main branchExample: run only on merge requestsExample: skip on tagsExample: target by pipeline source`rules:changes` for File-Based ExecutionExample with multiple paths`rules:exists` for Repository-Aware Jobs`rules:when` and Job BehaviorManual job exampleFailure-handling exampleDelayed job example`rules:allow_failure`Manual Jobs with `when: manual` and `allow_failure: true`Combining Multiple RulesOR logic between rule itemsAND logic within one rule itemRule ordering mattersMerge Request Pipelines vs Branch Pipelines`workflow:rules` for Controlling the Entire PipelineComplete Practical Example: MR vs Main BranchCommon Rule Design MistakesFinal TakeawayKnowledge Check