Back to Blog

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
    - main

This 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: true

When 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 build

But 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 build

The 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 build

This 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.sh

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