Skip to main content

patterns

GitLab CI/CD Patterns

Common pipeline patterns for efficient, maintainable CI/CD across multiple projects.

Table of Contents

Pipeline Types

Branch Pipelines

Basic branch pipeline:

workflow: rules: - if: $CI_COMMIT_BRANCH

Runs on: Every push to any branch

Use case: Development branches, feature branches

Merge Request Pipelines

MR-only pipeline:

workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Runs on: Merge request events only

Benefits:

  • Avoid duplicate branch + MR pipelines
  • Test exactly what will be merged
  • Show pipeline results in MR UI

Source: GitLab Merge Request Pipelines

Hybrid Workflow

Run on MR or specific branches:

workflow: rules: # Run on MR events - if: $CI_PIPELINE_SOURCE == "merge_request_event" # Run on main/development branches (not if MR exists) - if: $CI_COMMIT_BRANCH == "main" && $CI_OPEN_MERGE_REQUESTS == null - if: $CI_COMMIT_BRANCH == "development" && $CI_OPEN_MERGE_REQUESTS == null # Skip all other branches - when: never

Prevents: Duplicate pipelines when both branch and MR exist

Scheduled Pipelines

Nightly builds:

nightly-tests: rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - npm run test:full # Comprehensive tests

Create schedule: Project CI/CD Schedules New schedule

Use cases:

  • Comprehensive test suites (too slow for every commit)
  • Dependency updates
  • Security scans
  • Performance benchmarks

Tag Pipelines

Release pipeline:

workflow: rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # Semantic version tags release: stage: deploy script: - ./build-release.sh - ./publish-release.sh only: - tags

Trigger: git tag v1.0.0 && git push origin v1.0.0

Workflow Patterns

Pattern 1: Environment-Based Workflow

Different pipelines for different environments:

workflow: rules: # Production pipeline (main branch) - if: $CI_COMMIT_BRANCH == "main" variables: ENVIRONMENT: production DEPLOY_ENABLED: "true" # Staging pipeline (development branch) - if: $CI_COMMIT_BRANCH == "development" variables: ENVIRONMENT: staging DEPLOY_ENABLED: "true" # Feature pipeline (MR) - if: $CI_PIPELINE_SOURCE == "merge_request_event" variables: ENVIRONMENT: review DEPLOY_ENABLED: "false" deploy: script: - ./deploy.sh $ENVIRONMENT rules: - if: $DEPLOY_ENABLED == "true"

Pattern 2: Fast Feedback Workflow

Layered testing (fast slow):

stages: - quick-checks - build - test-fast - test-slow - deploy lint: stage: quick-checks script: npm run lint # 30 seconds unit-tests: stage: test-fast needs: [build] script: npm run test:unit # 2 minutes integration-tests: stage: test-slow needs: [unit-tests] # Only if fast tests pass script: npm run test:integration # 10 minutes e2e-tests: stage: test-slow needs: [unit-tests] script: npm run test:e2e # 15 minutes

Benefit: Fail fast on cheap checks, run expensive tests only if cheap ones pass

Pattern 3: Manual Gates

Require approval for production:

deploy-staging: stage: deploy script: ./deploy-staging.sh environment: name: staging rules: - if: $CI_COMMIT_BRANCH == "development" deploy-production: stage: deploy script: ./deploy-production.sh environment: name: production when: manual # Requires manual trigger needs: [deploy-staging] rules: - if: $CI_COMMIT_BRANCH == "main"

Pattern 4: Conditional Auto-Cancel

Cancel redundant pipelines:

workflow: auto_cancel: on_new_commit: interruptible default: interruptible: true # Don't interrupt production deploys deploy-production: interruptible: false script: ./deploy.sh

Benefit: Save CI minutes by canceling outdated pipelines

Source: GitLab Interruptible Jobs

Job Patterns

Pattern 1: DRY with YAML Anchors

Define reusable templates:

.node-job: &node-job image: node:18 cache: key: files: - package-lock.json paths: - node_modules/ before_script: - npm ci lint: <<: *node-job script: npm run lint test: <<: *node-job script: npm test build: <<: *node-job script: npm run build

Benefit: Single source of truth for common configuration

Pattern 2: Matrix Builds

Test across multiple versions/platforms:

test-matrix: parallel: matrix: - NODE_VERSION: ["16", "18", "20"] OS: ["linux", "macos"] image: node:${NODE_VERSION} script: - npm test

Generates 6 jobs:

  • node:16 on linux
  • node:16 on macos
  • node:18 on linux
  • node:18 on macos
  • node:20 on linux
  • node:20 on macos

Pattern 3: Job Inheritance

Base job with extensions:

.test-base: image: node:18 before_script: - npm ci cache: paths: - node_modules/ test-unit: extends: .test-base script: npm run test:unit test-integration: extends: .test-base script: npm run test:integration services: - postgres:14

Pattern 4: Dynamic Job Generation

Create jobs based on file changes:

generate-jobs: stage: .pre script: - | # Generate child pipeline for changed services cat > generated-pipeline.yml <<EOF stages: - test EOF for service in $(git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA | cut -d/ -f1 | sort -u); do cat >> generated-pipeline.yml <<EOF test-$service: stage: test script: - cd $service && npm test EOF done artifacts: paths: - generated-pipeline.yml trigger-generated: stage: test trigger: include: - artifact: generated-pipeline.yml job: generate-jobs strategy: depend

Use case: Monorepos with many services/packages

Deployment Patterns

Pattern 1: Blue-Green Deployment

Zero-downtime deployments:

deploy-blue: stage: deploy script: - ./deploy.sh blue - ./health-check.sh blue environment: name: production-blue url: https://blue.example.com switch-traffic: stage: deploy needs: [deploy-blue] when: manual script: - ./switch-traffic.sh blue environment: name: production url: https://example.com cleanup-green: stage: deploy needs: [switch-traffic] script: - ./cleanup.sh green

Pattern 2: Canary Deployment

Gradual rollout:

deploy-canary: stage: deploy script: - ./deploy.sh --replicas=1 --weight=10% # 10% traffic environment: name: production-canary monitor-canary: stage: verify needs: [deploy-canary] script: - ./monitor-metrics.sh - ./check-error-rate.sh deploy-full: stage: deploy needs: [monitor-canary] when: manual script: - ./deploy.sh --replicas=10 --weight=100% environment: name: production

Pattern 3: Environment-Specific Configuration

Use environment variables:

.deploy-base: script: - ./deploy.sh only: - main - development deploy-staging: extends: .deploy-base variables: ENVIRONMENT: staging REPLICAS: 2 LOG_LEVEL: debug environment: name: staging url: https://staging.example.com only: - development deploy-production: extends: .deploy-base variables: ENVIRONMENT: production REPLICAS: 5 LOG_LEVEL: info environment: name: production url: https://example.com when: manual only: - main

Pattern 4: Review Apps

Ephemeral environments per MR:

review-app: stage: deploy script: - ./deploy-review-app.sh environment: name: review/$CI_COMMIT_REF_SLUG url: https://$CI_COMMIT_REF_SLUG.review.example.com on_stop: cleanup-review-app auto_stop_in: 7 days rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" cleanup-review-app: stage: deploy script: - ./cleanup-review-app.sh environment: name: review/$CI_COMMIT_REF_SLUG action: stop when: manual rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Benefit: Test each MR in isolated environment

Testing Patterns

Pattern 1: Parallel Test Execution

Split test suite:

test: parallel: 5 script: - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL artifacts: reports: junit: junit-*.xml

Time savings: 20 min test 4 min with 5 parallel jobs

Pattern 2: Test Artifact Collection

Aggregate test reports:

test: parallel: 5 script: - npm test artifacts: reports: junit: junit-*.xml coverage_report: coverage_format: cobertura path: coverage/cobertura-coverage.xml test-summary: stage: report needs: [test] script: - ./generate-test-summary.sh artifacts: reports: junit: test-summary.xml

GitLab displays:

  • Test results in MR
  • Code coverage trends
  • Test failure notifications

Pattern 3: Conditional Testing

Run expensive tests only on MRs:

unit-tests: stage: test script: npm run test:unit # Always run integration-tests: stage: test script: npm run test:integration rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" e2e-tests: stage: test script: npm run test:e2e rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE !~ /^Draft:/ - if: $CI_COMMIT_BRANCH == "main"

Pattern 4: Contract Testing

Test API contracts:

contract-tests-provider: stage: test script: - npm run test:contract:provider artifacts: paths: - pacts/ expire_in: 1 day contract-tests-consumer: stage: test needs: [contract-tests-provider] script: - npm run test:contract:consumer dependencies: - contract-tests-provider

Use case: Microservices ensuring API compatibility

Security Patterns

Pattern 1: Security Scanning

SAST, DAST, dependency scanning:

include: - template: Security/SAST.gitlab-ci.yml - template: Security/Dependency-Scanning.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml # Run security scans on MRs and main sast: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main" dependency_scanning: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "main"

GitLab Ultimate: Displays vulnerabilities in MR UI

Pattern 2: Security Policy Enforcement

Block MRs with high-severity vulnerabilities:

security-check: stage: test script: - | HIGH_VULNS=$(cat gl-dependency-scanning-report.json | jq '[.vulnerabilities[] | select(.severity == "High" or .severity == "Critical")] | length') if [ "$HIGH_VULNS" -gt 0 ]; then echo "Found $HIGH_VULNS high/critical vulnerabilities" exit 1 fi needs: [dependency_scanning] allow_failure: false # Block merge

Pattern 3: Secret Management with OIDC

Use OIDC instead of long-lived tokens:

deploy: stage: deploy id_tokens: AWS_OIDC_TOKEN: aud: https://aws.amazon.com before_script: - ./assume-role-with-oidc.sh script: - ./deploy.sh

Benefit: No long-lived credentials in GitLab variables

Source: GitLab OIDC Authentication

Pattern 4: Approval Rules

Require security team approval:

# Project Settings Merge Requests Approval Rules deploy-production: stage: deploy script: ./deploy.sh environment: name: production rules: - if: $CI_COMMIT_BRANCH == "main" when: manual

Configure in UI: Require approval from "Security" group before merge

Anti-Patterns

Anti-Pattern 1: Redundant Pipelines

Problem: Branch + MR pipelines run simultaneously

# BAD: Runs on every branch workflow: rules: - when: always

Solution: Skip branch pipelines when MR exists

# GOOD: Avoid redundancy workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS == null

Anti-Pattern 2: Stage Bottlenecks

Problem: All test jobs wait for all build jobs

# BAD: test-A waits for build-B unnecessarily stages: - build - test build-A: stage: build script: ./build-a.sh build-B: stage: build script: ./build-b.sh # Takes 20 minutes test-A: stage: test script: ./test-a.sh # Waits 20 min for build-B

Solution: Use needs for explicit dependencies

# GOOD: test-A starts as soon as build-A completes test-A: stage: test needs: [build-A] script: ./test-a.sh

Anti-Pattern 3: No Caching

Problem: Reinstall dependencies on every run

# BAD: No caching test: script: - npm ci # Downloads 500MB every time - npm test

Solution: Cache dependencies

# GOOD: Cache dependencies test: cache: key: files: - package-lock.json paths: - node_modules/ script: - npm ci - npm test

Anti-Pattern 4: Large Artifacts

Problem: Unnecessary artifacts waste storage and transfer time

# BAD: Saves everything build: script: npm run build artifacts: paths: - dist/ - node_modules/ # 500MB not needed downstream - logs/ # 1GB debug logs

Solution: Only save necessary artifacts

# GOOD: Only production build build: script: npm run build artifacts: paths: - dist/ expire_in: 1 day

Anti-Pattern 5: Hardcoded Secrets

Problem: Secrets in .gitlab-ci.yml

# DANGER: Secret in plain text deploy: script: - API_TOKEN=abc123secret ./deploy.sh

Solution: Use GitLab CI/CD variables

# GOOD: Use protected variables deploy: script: - ./deploy.sh variables: API_TOKEN: $DEPLOY_API_TOKEN # Set in GitLab UI

Anti-Pattern 6: Long-Running Jobs

Problem: Single job takes 30 minutes

# BAD: Monolithic test job test: script: - npm run test:all # 30 minutes

Solution: Parallelize

# GOOD: Split into 10 parallel jobs test: parallel: 10 script: - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL # 3 min each

Anti-Pattern 7: No Fail-Fast

Problem: Run all jobs even after critical failure

# BAD: Expensive tests run even if build fails stages: - build - test build: stage: build script: ./build.sh allow_failure: true # Continues even if build fails expensive-tests: stage: test script: ./expensive-tests.sh # Wastes 20 min on broken build

Solution: Use needs and remove allow_failure

# GOOD: Stop immediately on build failure build: stage: build script: ./build.sh allow_failure: false expensive-tests: stage: test needs: [build] # Won't run if build fails script: ./expensive-tests.sh

Pattern Selection Guide

Choose Pipeline Type Based On

ScenarioRecommended Pattern
Development workflowMerge Request Pipelines
Production releasesTag Pipelines
Scheduled tasksScheduled Pipelines
Multi-environmentHybrid Workflow

Choose Testing Strategy Based On

ScenarioRecommended Pattern
Large test suiteParallel Test Execution
Multiple environmentsConditional Testing
Fast feedback neededLayered Testing (fast slow)
MicroservicesContract Testing

Choose Deployment Strategy Based On

ScenarioRecommended Pattern
Zero-downtime requiredBlue-Green Deployment
Gradual rollout neededCanary Deployment
Per-MR testingReview Apps
Manual approval neededManual Gates

Additional Resources


Last Updated: 2026-01-08 Priority: MEDIUM - Reference guide for common scenarios