If you’ve worked with GitLab CI/CD for any amount of time, your
.gitlab-ci.yml has probably grown organically. A job here,
a stage there, copy-paste from Stack Overflow - it works, so you move
on.
But “works” and “works well” are different things. Here are five mistakes we see constantly - each one wastes pipeline minutes, causes subtle bugs, or makes your config harder to maintain than it needs to be.
1. Using
only/except instead of rules
The only and except keywords have been
around since the early days of GitLab CI. They still work, but they’re
legacy - and they come with real limitations. You can’t combine
conditions with AND logic, they don’t play well with merge request
pipelines, and they lead to duplicate pipeline runs.
The rules keyword replaces both with a single,
expressive syntax that supports if, changes,
exists, and explicit when control.
Before:
test:
script: ./run-tests.sh
only:
- merge_requests
- mainThis creates two pipelines when you open an MR against
main - one for the branch, one for the MR event.
After:
test:
script: ./run-tests.sh
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"With rules, GitLab evaluates conditions top-to-bottom
and runs the job on the first match. No duplicates, no surprises.
If you’re starting fresh, use rules exclusively. If
you’re migrating, GitLab’s rules
migration guide walks through the conversion.
2. No
interruptible: true on long jobs
Picture this: you push a commit, CI starts running your 12-minute test suite. You spot a typo, push a fix. Now you have two pipelines running - and the first one is burning minutes for a commit you’ve already superseded.
The fix is one keyword:
test:
script: ./run-tests.sh
interruptible: trueWhen a newer pipeline starts for the same branch, GitLab
automatically cancels jobs marked interruptible from the
older pipeline. This is safe for any job that doesn’t deploy or mutate
state.
You can also enable auto-cancel globally under Settings >
CI/CD > General pipelines > Auto-cancel redundant
pipelines. But marking individual jobs as
interruptible gives you fine-grained control - your deploy
jobs stay protected while test and lint jobs get cancelled
immediately.
For teams with large pipelines, this alone can cut CI minute usage by 30-40%.
3. Hardcoding image tags
This looks harmless:
build:
image: node:20
script: npm ci && npm run buildBut node:20 is a moving target. It points to the latest
patch release, which changes without warning. One day your pipeline
works, the next day it breaks because a new patch introduced a
regression - or worse, it silently changes behavior.
Option A - Pin the digest:
build:
image: node:20.11.1@sha256:bf0ef0687ffbd6...
script: npm ci && npm run buildThe digest is immutable. You’ll never get a surprise update. The tradeoff is you need to update it manually, but that’s a conscious decision rather than an accidental one.
Option B - Use a CI/CD variable:
variables:
NODE_IMAGE: "node:20.11.1"
build:
image: $NODE_IMAGE
script: npm ci && npm run buildThis centralizes the version in one place. When you want to upgrade, you change one line and every job picks it up. You can even override it per-pipeline in the GitLab UI for testing.
Either approach beats the default. Pick whichever fits your workflow - the important thing is making image versions a deliberate choice.
4. Missing
needs for parallel execution
By default, GitLab CI runs jobs in stages: all jobs in
build must finish before any job in test
starts. If your build stage has three independent jobs and
one of them takes 8 minutes, the other two are holding up the entire
test stage even though they finished in 30 seconds.
The needs keyword breaks this stage barrier by declaring
explicit dependencies between jobs. GitLab calls this a Directed
Acyclic Graph (DAG):
stages:
- build
- test
- deploy
build-frontend:
stage: build
script: npm run build
build-backend:
stage: build
script: cargo build --release
test-frontend:
stage: test
needs: [build-frontend]
script: npm test
test-backend:
stage: test
needs: [build-backend]
script: cargo test
deploy:
stage: deploy
needs: [test-frontend, test-backend]
script: ./deploy.shNow test-frontend starts the moment
build-frontend finishes, without waiting for
build-backend. In pipelines with many independent paths,
this can cut total pipeline duration dramatically - we’ve seen 40-60%
reductions on real-world configs.
One thing to note: needs also controls artifact passing.
A job with needs: [build-frontend] only receives artifacts
from build-frontend, not from all jobs in previous stages.
This is usually what you want, but it’s worth knowing.
5. Not using
!reference for DRY config
YAML anchors (&anchor / *anchor) work
in .gitlab-ci.yml, but they have a major limitation: they
don’t work across include files. Once your config is split
into multiple files - and it should be, once it grows past a few hundred
lines - anchors break.
GitLab’s !reference tag solves this. It can pull any key
from any job, even across included files:
# templates.yml
.default-retry:
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
.default-cache:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .npm/# .gitlab-ci.yml
include:
- local: templates.yml
test:
script: npm test
retry: !reference [.default-retry, retry]
cache: !reference [.default-cache, cache]Each !reference pulls in exactly the block you point it
at. You can reference nested keys, combine multiple references, and
everything resolves at pipeline creation time.
Compare this to the anchor approach, which requires everything to
live in the same file and merges entire blocks rather than letting you
pick specific keys. For any config that uses include,
!reference is strictly better.
GitLab’s documentation
on !reference has more examples, including nested
references and combining with extends.
That’s the list. If even one of these is in your
.gitlab-ci.yml right now, fixing it will save you time,
minutes, and debugging headaches. Start with whichever one hurts the
most and work your way through.
Have a pattern we missed? Let us know on our GitLab repo.