CI/CD Best Practices

Learn the patterns that separate production-grade GitLab pipelines from fragile prototypes. From pipeline architecture and caching to security hardening and maintainability, build pipelines that are fast, safe, and easy to change.

Optimize Secure GitLab CI/CD
Why Best Practices Matter

A GitLab pipeline that works is not the same as a pipeline that's well-designed. The difference shows up over time: pipelines that take 45 minutes to give feedback, configurations that nobody dares to change, secrets that leak into logs, and jobs that rebuild the same artifact five times. These are not failures of GitLab — they're failures of design. The best practices in this guide are the patterns that experienced teams converge on after dealing with these problems firsthand.

The stakes are higher than they appear. A slow pipeline costs developer time every single day. An insecure pipeline can expose credentials or let untrusted code run with production privileges. A monolithic .gitlab-ci.yml becomes a bottleneck where every change requires reviewing a thousand lines of YAML. These costs compound. The time to fix them is before they become entrenched [citation:8].

Best practices are not rigid rules. They're starting points that you adapt to your context. A small team with a simple Node.js app doesn't need parent-child pipelines. A monorepo with twenty services does. The goal is to understand the principles — fast feedback, least privilege, DRY configuration, observable failures — and apply them proportionally. This guide covers the practices that have the highest impact across the most teams.

Core Principle: Optimize for the developer waiting for feedback. Every minute you shave off the pipeline is multiplied by every developer, every day.

Architecture

Choose the right pipeline structure: basic, needs (DAG), parent-child, or multi-project. Match complexity to need.

Performance

Cache dependencies, parallelize jobs, use needs, shard tests, and build once. Target sub-10-minute feedback.

Security

Pin includes to SHAs, scope CI_JOB_TOKEN, use OIDC, protect variables, and isolate runners.

Maintainability

Use default, extends, YAML anchors, and includes to eliminate duplication. Split large files.

Observability

Use interruptible, retry, and allow_failure deliberately. Make failures visible and actionable.

Anti-Patterns

Avoid sequential-only pipelines, rebuilding artifacts, hardcoded secrets, and monolithic YAML.
Pipeline Architecture

GitLab supports several pipeline architectures, and choosing the right one is the first and most important decision. The basic pipeline — stages running sequentially, jobs within a stage running in parallel — is simple and works well for small projects. But as projects grow, the sequential stage model becomes a bottleneck: every job in the build stage must finish before any test job starts, even if only one test needs a specific build artifact [citation:1].

The needs keyword solves this by creating a directed acyclic graph (DAG). Instead of waiting for an entire stage to complete, a job can start as soon as its specific dependencies finish. In a pipeline where build_a is fast and build_b is slow, test_a can start while build_b is still running, and deploy_a can start before build_b even finishes. This is the single biggest performance improvement for most pipelines [citation:1].

For large monorepos or complex multi-service applications, parent-child pipelines and multi-project pipelines provide further decomposition. A parent pipeline can trigger child pipelines for independent components, each with its own stages and jobs, running in parallel. This keeps configuration manageable and allows each team to own their pipeline without editing a shared file [citation:11]. The key is to match the architecture to the project's complexity — don't use parent-child pipelines for a simple app, and don't use a basic pipeline for a monorepo with twenty services.

# Basic pipeline (sequential stages) stages: - build - test - deploy # All build jobs finish before any test job starts # Simple, but can be slow # Pipeline with needs (DAG) build_a: stage: build script: echo "Fast build" build_b: stage: build script: echo "Slow build" test_a: stage: test needs: [build_a] # Starts as soon as build_a finishes script: echo "Test A" test_b: stage: test needs: [build_b] # Starts as soon as build_b finishes script: echo "Test B" deploy_a: stage: deploy needs: [test_a] # Starts as soon as test_a finishes script: echo "Deploy A" # Parent-child pipeline trigger_frontend: trigger: include: frontend/.gitlab-ci.yml strategy: depend trigger_backend: trigger: include: backend/.gitlab-ci.yml strategy: depend # Multi-project pipeline trigger_deploy: trigger: project: my-group/deployment branch: main strategy: depend # Choosing an architecture: # - Simple project: basic pipeline # - Complex project with dependencies: needs (DAG) # - Monorepo with independent components: parent-child # - Microservices across repos: multi-project
Architecture Best Practices:
  • Start with basic; add needs when you have real dependencies to optimize
  • Use parent-child for monorepos to give teams ownership of their pipelines
  • Use multi-project for microservices to avoid duplicating deploy logic
  • Don't over-engineer — a simple pipeline is easier to debug
  • Keep child pipelines focused; each should handle one component
Caching and Artifacts

Caching is the most impactful performance optimization for most pipelines, but it's also the most commonly misconfigured. The rule is simple: cache what's expensive to fetch and doesn't change often — node_modules, .m2, .cache/pip, .npm. Key the cache on your lockfile hash so it invalidates when dependencies change. Use pull-push for build jobs that generate cache, and pull for test jobs that only read it [citation:3].

A common mistake is caching too much or keying incorrectly. If the cache key doesn't change when dependencies change, you ship stale packages. If you cache test outputs or build artifacts, you're misusing cache — that's what artifacts are for. Cache is an optimization; the job must work without it. Always run the actual install command (npm ci, pip install) even if the cache is present [citation:8].

Artifacts are for passing outputs between jobs: build artifacts (dist/), test reports (JUnit XML), coverage reports. Use expire_in aggressively — a build artifact that's only needed for the current pipeline should expire in a day, not the default 30 days. Use dependencies to limit which jobs download which artifacts. Downloading every artifact in every job is a hidden performance cost [citation:13].

# Correct caching: keyed by lockfile, pull-only for tests default: cache: key: files: - package-lock.json paths: - node_modules/ - .npm/ before_script: - npm ci --cache .npm --prefer-offline build: stage: build script: - npm run build cache: policy: pull-push artifacts: paths: - dist/ expire_in: 1 day test: stage: test script: - npm test cache: policy: pull # Only read cache, don't write dependencies: - build # Only download build artifacts artifacts: when: always reports: junit: test-results.xml expire_in: 1 week # Wrong: no lockfile key, cache never invalidates # cache: # key: node-cache # paths: # - node_modules/ # Wrong: caching build outputs # cache: # paths: # - dist/ # This should be an artifact # Wrong: downloading all artifacts # deploy: # dependencies: [] # Prevents downloading everything
Caching Anti-Patterns: