GitLab CI Pipeline Rules
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 pipelinesCI_MERGE_REQUEST_ID- set when the pipeline belongs to a merge request contextCI_PIPELINE_SOURCE- why the pipeline was created, such aspush,merge_request_event,schedule, orwebCI_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
Dockerfileor app code changes - trigger mobile jobs only when
ios/orandroid/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
Dockerfileexists - run Node jobs only if
package.jsonexists - run Terraform checks only if
*.tffiles 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 succeedalways- run regardless of earlier stage failureson_failure- run only if something earlier failedmanual- require a person to trigger the jobnever- explicitly exclude the jobdelayed- 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:
- skip tag pipelines
- make the job manual on main
- 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:
pushmerge_request_eventschedulewebapi
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:rulescreates pipelines only for merge requests and main.lintruns in both important contexts.frontend-testsruns in merge requests only when frontend files changed, and always on main.build-appruns only on main.preview-deployappears as an optional manual job in merge requests.production-deployappears 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
- Using
only/exceptin new pipelines whenruleswould be clearer. - Forgetting a catch-all rule and wondering why a job never appears.
- Ignoring
CI_PIPELINE_SOURCEand accidentally creating duplicate pipelines. - Using
changeswithout thinking about shared files like lock files or reusable configs. - 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
Question 1: Why Rules Matter
What is a major benefit of using GitLab CI rules instead of running every job on every pipeline?
Question 2: Changes Logic
When should you use rules:changes in a GitLab pipeline?
Question 3: Workflow Rules
What is the main purpose of workflow:rules in GitLab CI/CD?