A red pipeline can waste a whole afternoon. But a green pipeline that quietly skips a deploy or reuses a stale cache can cost far more. Teams often learn this the hard way after a Friday release goes sideways. GitLab CI/CD is powerful, flexible, and forgiving enough to let small configuration mistakes live in a `.gitlab-ci.yml` file for months before anyone notices. The trick is knowing where those traps hide.
This guide walks through the most common GitLab CI mistakes, why they hurt, and what actually works in practice. It covers legacy syntax, cache versus artifacts, protected variables, runner matching, and the merge rules that silently drop configuration. If you spend any time reviewing merge requests that touch pipelines, this one is for you.
Why Small YAML Mistakes Cause Big Problems
A pipeline config is code, but it rarely gets the same review scrutiny as application code. A one-line change to a `rules:` block can disable an entire stage, and nothing screams at you. The pipeline just passes, faster than usual, and everyone moves on.
That silence is the danger. According to the GitLab CI/CD documentation, the `.gitlab-ci.yml` keyword reference is enormous, and many keywords interact in ways that are not obvious from a quick skim. GitLab ships new releases roughly every month, so behavior shifts over time. A config that worked in one version may behave differently after an upgrade.
The practical fix is to treat pipeline changes as production changes. Require review, add a merge request pipeline that exercises the affected jobs, and keep a short note in the repo about why a keyword is used. Future-you will be grateful.
Mistake 1: Still Relying on `only` and `except`

The `only`/`except` keywords are the legacy way to control when a job runs. The current recommended approach is `rules`. Many older tutorials and Stack Overflow answers still use `only`/`except`, so teams copy them into new projects without realizing they are building on a syntax that is no longer actively developed.
The bigger problem is readability. A `rules` block reads top to bottom, and the first matching rule wins. That is easy to reason about. `only`/`except` combines inclusion and exclusion lists in a way that is genuinely hard to trace when a job does not run as expected.
What works in practice: migrate to `rules` one job at a time, starting with the jobs that misbehave most often. Do not attempt a big-bang rewrite of a 600-line config on a Friday.
Mistake 2: Misunderstanding How `rules` Are Evaluated
Even after switching to `rules`, teams trip on evaluation order. Rules are checked from top to bottom, and the first one that matches decides the job’s fate. A job with no matching rule and no default is simply excluded from the pipeline.
The classic failure looks like this: an engineer writes a broad rule first, then a more specific rule below it, expecting the specific one to override. It never fires, because the broad rule already matched.
| What people expect | What actually happens |
|---|---|
| Later rules override earlier ones | First match wins; later rules are ignored |
| A job with no matching rule still runs | The job is excluded |
| `when: never` at the bottom acts as a safety net | It only applies if reached |
The reliable pattern is to order rules from most specific to most general, and to end with an explicit catch-all when the intent is “run by default.”
Mistake 3: Trusting `changes:` in the Wrong Pipeline Type
The `changes:` keyword looks like a clean way to run jobs only when relevant files change. In practice, its behavior depends on the pipeline type. In pipelines triggered without a git push, such as scheduled pipelines, manual runs, or API triggers, `changes:` can evaluate in ways that surprise people. Some jobs run when they should not; others get skipped.
This is one of the most version-sensitive behaviors in GitLab CI. If your team relies on `changes:`, pin your assumptions to a specific GitLab version and re-test after upgrades. Better yet, combine `changes:` with an explicit `rules` condition on the pipeline source so scheduled runs behave predictably.
Mistake 4: Confusing Cache With Artifacts
This is the single most common conceptual error. Cache exists to speed up jobs by reusing files like dependency directories. It is a best-effort mechanism; there is no guarantee the cache exists or is fresh. Artifacts exist to pass files between stages within a pipeline, and they are guaranteed within that pipeline run. (Continuous integration)
Teams regularly use `cache` to hand a compiled binary from a build stage to a deploy stage. It usually works in testing, then fails intermittently in production when the cache is cold or evicted. The correct tool is `artifacts`, with `dependencies` or `needs` controlling which jobs receive them.
A quick rule of thumb: if a later job cannot function without the file, it belongs in artifacts. If the job merely runs faster with the file, cache is fine.
Mistake 5: Cache Keys That Collide Across Branches
A static `cache:key` such as `cache:key: “deps”` means every branch shares one cache entry. Feature branches then inherit dependencies installed for a different branch, producing bizarre build failures that vanish on a clean run.
Scoping the key to the branch fixes it. A common approach is keying on the commit ref slug so each branch gets its own cache, with a fallback key for the default branch so new branches still start warm. The trade-off is more storage, which is usually cheaper than debugging phantom failures.
Mistake 6: Protected Variables That Vanish on Feature Branches
Protected variables are only exposed to pipelines running on protected branches or tags. This is a security feature, and a good one. But it produces a very specific symptom: the deploy job works on `main` and fails on every merge request with an empty credential.
Engineers then spend hours checking the variable is defined, which it is. The variable is simply not available in that context.
What actually works: document which variables are protected, and design merge request pipelines that do not need production secrets. If a job genuinely needs a protected variable, it should not run on an unprotected branch in the first place. Masked variables add a second layer, hiding values in job logs, but masking has format requirements; a value that does not meet them silently fails to mask.
Mistake 7: Manual Jobs That Nobody Notices
A manual job with `allow_failure: true` does not block the pipeline. That is sometimes exactly what you want, such as an optional security scan. It becomes a problem when a production deploy is manual and non-blocking: the pipeline shows green, everyone assumes the release shipped, and it did not.
The fix is to be explicit. Set `allow_failure: false` on any manual job that must be actioned, so the pipeline stays blocked until someone clicks. Then add a notification so the click actually happens.
Mistake 8: Jobs Stuck in Pending Because of Runner Tags
If a job declares `tags:` and no available runner carries a matching tag, the job does not fail. It waits, indefinitely, in a pending state. New team members interpret this as “the pipeline is slow” rather than “this job can never run.”
Auditing runner tags against job tags is a five-minute task that prevents hours of confusion. Keep the tag vocabulary small and documented. Avoid per-developer tags, which turn into orphaned jobs the moment someone changes laptops.
Mistake 9: Assuming `extends` Merges Arrays
The `extends` keyword performs a deep merge of hash keys, but arrays are replaced, not concatenated. So if a base job defines a `script:` list and a child job adds one more line, the child’s list wins entirely. The base steps disappear.
The same subtlety applies to YAML anchors, which cannot cross file boundaries without `!reference`. Teams that split configuration across `include` files often discover this only after a deploy step silently vanishes.
Practical habit: after any refactor involving `extends` or anchors, run the pipeline with a deliberately failing step in the base job. If the pipeline still passes, your inheritance is broken.
Mistake 10: Shallow Clones Breaking Versioning Jobs
Default fetch behavior and shallow cloning can break jobs that need commit history. Tools that call `git describe`, generate changelogs, or compute version numbers from tags will fail or produce wrong output when the history is truncated.
If a job needs history, set the fetch depth explicitly rather than relying on defaults. This is a small config change that prevents a whole class of confusing failures. It is also worth checking after GitLab upgrades, since default behavior can shift.
Mistake 11: Skipping Pipeline Hygiene
Pipeline configs accumulate cruft. Deprecated keywords linger, duplicate jobs multiply, and nobody remembers why a particular `when: never` exists. The same discipline you apply to application code applies here.
A few habits pay off. Keep jobs small and single-purpose. Name them so their intent is obvious. Delete dead jobs rather than commenting them out. And when a pipeline change saves time, note it in the merge request so the reasoning survives.
If your team also maintains mirrored repositories across platforms, the same hygiene applies to your hosting setup; a comparison like GitHub vs GitLab in 2026 can help you decide where pipeline logic should live. And when you document pipeline behavior for your team, screenshots of failing jobs help enormously; here is how to take a screenshot on Windows or capture your screen on a Mac cleanly for bug reports.
A Practical Review Checklist
Before merging any pipeline change, run through these checks. They take two minutes and catch most of the mistakes above.
- Does the job use `rules` rather than `only`/`except`?
- Are rules ordered from specific to general, with an explicit catch-all?
- Are build outputs passed via `artifacts`, not `cache`?
- Is every cache key scoped to a branch or commit?
- Do any jobs need protected variables on unprotected branches?
- Are manual jobs that must run marked `allow_failure: false`?
- Does every `tags:` value match a real runner?
- Did you verify `extends` did not drop array entries?
Print it, pin it, or paste it into your merge request template. The checklist matters less than the habit of asking.
Conclusion
GitLab CI mistakes are rarely dramatic. They hide in a green pipeline, a pending job, or a cache that quietly serves the wrong branch. The patterns that cause them are well known: legacy syntax, misunderstood evaluation order, cache and artifacts swapped, protected variables assumed to be everywhere, and inheritance that silently drops configuration.
The good news is that every one of these is fixable with a small, targeted change. Start with the mistake that has cost your team the most time recently. Fix it, document why, and move to the next. Over a few sprints, the pipeline stops being a source of surprise and starts being the boring, reliable backbone it should be.
FAQ
What is the most common GitLab CI mistake?
Confusing `cache` with `artifacts` is the most common. Cache is best-effort and meant for speed; artifacts are guaranteed within a pipeline and meant for passing files between stages. Using cache to move build outputs works in testing and fails intermittently in production.
Are `only` and `except` deprecated?
They are legacy syntax and no longer actively developed; `rules` is the recommended replacement. Sources differ on whether they are formally deprecated or simply not recommended, so check the current official documentation for your GitLab version before making claims to your team.
Why does my job stay pending forever?
Almost always a runner tag mismatch. The job declares `tags:` that no registered runner carries, so it waits instead of failing. Audit your job tags against available runners and keep the tag vocabulary small and documented.
Why does my deploy job fail only on merge requests?
Protected variables are exposed only to pipelines on protected branches or tags. A deploy job running on a feature branch gets an empty value for those variables. Keep jobs that need production secrets off unprotected branches entirely.
Do `rules` evaluate top to bottom?
Yes. The first matching rule decides the job’s outcome, and later rules are ignored. A job with no matching rule and no default is excluded from the pipeline. Order rules from most specific to most general and add an explicit catch-all.
Does `extends` merge my `script` arrays?
No. `extends` deep-merges hash keys but replaces arrays. If a child job defines its own `script:` list, it overwrites the parent’s list entirely. Test inheritance by adding a deliberately failing step to the base job and confirming the pipeline fails.


