CI/CD Anti-Patterns

Learn the anti-patterns that turn GitLab pipelines into liabilities. From hardcoded secrets and monolithic YAML to sequential stages and blanket retries, understand what goes wrong and how to fix it.

Anti-Patterns Fixes GitLab CI/CD
Why Anti-Patterns Matter

An anti-pattern is a practice that seems reasonable — even obvious — but causes problems as the project grows. The pipeline works at first, so nobody questions it. Then, six months later, the pipeline takes an hour, secrets are scattered across the configuration, and nobody can safely change a job without breaking three others. The technical debt was accumulated in small decisions that felt fine at the time.

Anti-patterns are insidious because they don't announce themselves. A hardcoded secret in .gitlab-ci.yml works. Sequential stages work. A monolithic file works. The problems are latent — they only manifest when the project scales, when a security incident occurs, or when a new developer tries to understand the pipeline and gives up. By then, the cost of fixing them is much higher than the cost of avoiding them .

The good news is that anti-patterns are well-understood. Each one has a known cause, a clear symptom, and a standard fix. This guide covers the anti-patterns that appear most often in real GitLab pipelines: the ones that security researchers write about, the ones that cost teams hours of debugging, the ones that are easy to avoid if you know about them. Fixing them isn't about perfection — it's about eliminating the patterns that cause the most pain.

Core Principle: An anti-pattern is not a mistake you make once — it's a pattern that repeats. Recognizing the pattern is the first step to fixing it.

Security

Hardcoded secrets, mutable include refs, over-privileged tokens, and unprotected variables.

Performance

Sequential stages, rebuilding artifacts, caching everything, and no test sharding.

Structure

Monolithic YAML, deep extends chains, copy-paste configuration, and undocumented jobs.

Reliability

Blanket retries, allow_failure on critical jobs, and hidden failures.
Security Anti-Patterns

The most dangerous anti-pattern is hardcoding secrets in .gitlab-ci.yml. A secret committed to version control is exposed to everyone with repository access, persists in git history forever, and cannot be deleted by simply removing the line. The 2025 tj-actions supply chain attack (CVE-2025-30066) demonstrated how mutable references in CI configuration let attackers reach thousands of repositories — the same risk applies to GitLab includes pinned to branches or tags .

The second most dangerous is an over-privileged CI_JOB_TOKEN. By default, this token can access other projects the user has access to, creating a pivot path for attackers. The fix is to enable the allowlist and explicitly permit only the projects that need to call in. Without this, a compromised pipeline in one project can compromise others .

The third is unprotected sensitive variables. A variable that isn't marked as protected is available to every pipeline, including feature branch pipelines that might run untrusted code. A variable that isn't masked appears in job logs if accidentally echoed. The fix is to mark every sensitive variable as both protected and masked, and to scope it to the environments where it's needed.

# ANTI-PATTERN: Hardcoded secret # variables: # DATABASE_PASSWORD: "supersecret123" # NEVER do this # Fix: Use protected + masked CI/CD variable # ANTI-PATTERN: Mutable include ref # include: # - project: 'security/ci-templates' # ref: main # Whoever controls main controls your pipeline # Fix: Pin to commit SHA include: - project: 'security/ci-templates' ref: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' file: '/templates/scan.yml' # ANTI-PATTERN: Unrestricted CI_JOB_TOKEN # Default: token can access all projects user can access # Fix: Enable allowlist in Settings > CI/CD > Token Access # and explicitly allow only projects that need access # ANTI-PATTERN: Unprotected, unmasked variable # DATABASE_PASSWORD (unprotected, unmasked) # Fix: Mark as protected and masked # Settings > CI/CD > Variables # - Protected: ✓ # - Masked: ✓ # - Environment scope: production # ANTI-PATTERN: Secrets in artifacts # artifacts: # paths: # - .env # Contains secrets, downloadable by anyone # Fix: Never put secrets in artifacts # ANTI-PATTERN: Long-lived cloud credentials # variables: # AWS_SECRET_ACCESS_KEY: $SECRET # Permanent # Fix: Use OIDC deploy: id_tokens: AWS_ID_TOKEN: aud: https://gitlab.example.com script: - aws sts assume-role-with-web-identity ... # Security checklist: # - Never commit secrets # - Pin includes to SHA # - Scope CI_JOB_TOKEN # - Protect and mask variables # - Use OIDC for cloud credentials # - Never put secrets in artifacts
Supply Chain Risk:
  • CVE-2025-30066 (tj-actions): Mutable references in CI config let attackers reach ~23,000 repositories
  • CVE-2025-33053 (foreshadowing): The same risk applies to any CI system that allows mutable includes
  • Fix: Pin every include to a commit SHA. Treat templates as production code.
Performance Anti-Patterns

The sequential stage anti-pattern is the most common cause of slow pipelines. In a basic pipeline, all build jobs must finish before any test job starts. This is simple but wasteful: a fast frontend test that only needs a fast frontend build waits for a slow backend build. The fix is to use needs to create a DAG, letting each job start as soon as its actual dependencies finish .

Rebuilding artifacts is another common waste. If the build stage creates a Docker image or a compiled binary, downstream jobs should pull that artifact, not rebuild it. Rebuilding wastes compute, adds time, and — worse — can produce a different artifact than the one that was tested. Build once, promote the same artifact through the pipeline. Tag images by commit SHA, not latest, so the artifact is immutable .

Over-caching is a subtler problem. Caching node_modules is good. Caching build outputs, test results, or .env files is not — those should be artifacts (for outputs) or secrets (for .env). If your cache includes files that change on every commit, the cache never hits, and you pay the upload cost on every job. Cache only what doesn't change often and is expensive to fetch.

# ANTI-PATTERN: Sequential-only pipeline # All build jobs finish before any test job starts # Fix: Use needs build_a: stage: build script: ./build-a.sh build_b: stage: build script: ./build-b.sh test_a: stage: test needs: [build_a] # Starts as soon as build_a finishes script: ./test-a.sh # ANTI-PATTERN: Rebuilding artifacts # build: # script: docker build -t app:$CI_COMMIT_SHA . # test: # script: docker build -t app:$CI_COMMIT_SHA . # Rebuild! # Fix: Build once, promote artifact build: stage: build script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA test: stage: test script: - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - ./test.sh # ANTI-PATTERN: Over-caching # cache: # paths: # - node_modules/ # - dist/ # Should be artifact # - .env # Should be secret # - test-results/ # Should be artifact # Fix: Cache dependencies only cache: key: files: [package-lock.json] paths: - node_modules/ - .npm/ # ANTI-PATTERN: No test sharding # test: # script: npm test # 20 minutes # Fix: Shard tests test: parallel: 5 script: npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL # Performance checklist: # - Use needs for DAG # - Build once, promote artifact # - Cache dependencies only # - Shard slow test suites # - Use parallel:matrix for version matrices
Performance Fixes: