GitLab CI/CD Specialist common mistakes
GitLab CI/CD Specialist · Understand the traps and how to avoid them.
Writing a job with both `rules:` and `only:`/`except:`, or nesting `if` conditions inside `changes`/`exists` clauses, expecting the pipeline to still validate.
Candidates learn older only/except syntax from legacy docs and blog posts, then mix it with modern rules: without realizing GitLab rejects the combination at lint time with 'rules:allowed to be used with only/except'.
What to do instead
Standardize on rules: everywhere. Remember rules:if can only combine with rules:changes or rules:exists as sibling entries joined by AND via separate list items, never nested. Practice running .gitlab-ci.yml through CI Lint (API /ci/lint) so syntax errors surface before commit.
Assuming `needs:` can reference a job in an earlier stage that is not a direct dependency, or that `needs:` bypasses stage ordering entirely.
Candidates think DAG mode removes stages. In reality needs: lets a job start early but the referenced job must exist in the config and, unless needs: [], the dependency must be satisfiable; jobs without needs still follow stage order, and a missing/renamed dependency fails the pipeline with 'job needs X, but Y is not in any previous stage'.
What to do instead
Map the DAG explicitly: every needs: target must be a real job name, and cross-stage references are allowed only backward. Use needs: [] for jobs that should start immediately. Validate with CI Lint, which reports unresolvable needs.
Expecting a `cache:` entry to be shared across runners or branches by default, then debugging why the cache misses on a different runner.
Caches are stored per runner (or per distributed cache backend) and keyed by key:, which defaults to the branch/ref. Candidates assume a global cache like artifacts, so a job on a different runner or merge request ref silently rebuilds.
What to do instead
Use cache:key:files (e.g. lockfile) or a fixed string key for cross-branch sharing, and enable a distributed cache (S3, GCS, Azure) in the runner config for multi-runner fleets. Keep artifacts for things that must pass between stages reliably; caches are best-effort.
Declaring a variable in `variables:` and expecting it to be masked or protected just because the name looks sensitive, or placing secrets in plain `variables:` in `.gitlab-ci.yml`.
Masking and protection are properties of project/group/instance CI/CD variables, not of YAML variables:. Candidates conflate the two and leak secrets into job logs or expose them to unprotected branches.
What to do instead
Store secrets as project or group CI/CD variables with the Masked and Protected flags set, and reference them by name. Use variables: only for non-secret defaults. Verify masking rules (min length, allowed characters) and confirm protected variables are only available on protected branches/tags.
Using `when: manual` and assuming the pipeline blocks until someone clicks, or using `allow_failure: true` on a manual job and being surprised the pipeline can succeed without it.
Manual jobs are optional by default (allow_failure: true), so the pipeline can pass while the job is skipped. Candidates expect a hard gate and misdiagnose 'pipeline succeeded but deploy never ran'.
What to do instead
For a blocking gate set when: manual with allow_failure: false. For deploy approvals use protected environments with deployment approvals. Test the behavior in a scratch project so the allow_failure default is internalized.
Configuring a runner with the `shell` executor and expecting job isolation, or with `docker` executor but no image and no privileged setup for Docker-in-Docker builds.
Candidates pick executors by name without understanding tradeoffs: shell shares the host, docker needs an image:, DinD needs privileged = true and a service, and docker+machine/kubernetes have their own auth models. Builds then fail with permission or 'docker: command not found' errors.
What to do instead
Match executor to workload: docker for containerized jobs with an explicit image:, DinD only with privileged = true plus docker:dind service and TLS config, kubernetes for cluster scheduling. Read the runner's config.toml and verify with a trivial docker info job.
Treating `artifacts:paths` as a way to pass data between jobs on different runners without `dependencies`/`needs`, or expecting artifacts to persist beyond the pipeline.
Candidates assume artifacts are a shared filesystem. Artifacts are uploaded to GitLab and downloaded by later-stage jobs by default, but jobs in the same stage don't get them, and artifacts expire per expire_in.
What to do instead
Use artifacts for intra-pipeline handoff with explicit needs:/dependencies: and set expire_in deliberately. For cross-pipeline or long-term storage use a package registry or external object store, not artifacts.
Writing `script:` lines that rely on shell features (multiline heredocs, `set -e` semantics, variable expansion) that behave differently across executors, or forgetting that each `script:` line runs in the same shell but `before_script` state may not carry as expected.
Candidates test locally in bash but the runner may use sh, PowerShell, or a different image. Quoting and $VAR expansion differ, and a failing command not at the end of a line can be masked.
What to do instead
Pin the image and shell explicitly, quote variables, use set -euo pipefail where the shell supports it, and break complex logic into scripts committed to the repo. Test the exact image locally with docker run.
Putting `include:` paths that rely on the current project's relative layout when including from another project, or using `include:local` for a remote URL.
Candidates mix include:local, include:remote, include:project, and include:template. local resolves against the including project; project needs file: and optionally ref:; remote needs a full URL and is fetched at pipeline creation.
What to do instead
Use include:project with explicit file: and ref: for cross-project templates, include:local only for files in the same repo, and include:remote for public URLs. Remember included files are merged and later definitions can override, so check merge order with CI Lint.
Debugging a failed pipeline by re-running the whole pipeline instead of using `retry`, `interruptible`, and job logs, or ignoring that `rules` changes can silently skip jobs.
Candidates chase flaky failures without reading the job trace, and they don't realize retry:when/retry:max can mask real errors, or that interruptible: true cancels superseded pipelines and looks like a failure.
What to do instead
Read the job trace and the pipeline's 'Jobs' tab first, use retry narrowly with when: runner_system_failure or script_failure limits, mark long jobs interruptible: true only when safe, and use CI Lint plus rules evaluation to confirm which jobs are expected to run for the given ref.