DevOpsil
CI/CD
88%
Needs Review

GitLab CI Dynamic Child Pipelines For Monorepo Microservices Deployment

Aareez AsifAareez Asif13 min read

GitLab CI Dynamic Child Pipelines for Monorepo Microservices Deployment

After a decade of wrestling with CI/CD systems and watching monorepo deployments go from "simple" to "nightmare" and back to "elegant," I've seen every permutation of GitLab CI configurations. Today, I'm sharing the battle-tested approach that's saved my teams countless hours and prevented numerous production outages: Dynamic Child Pipelines for monorepo microservices deployment.

If you're managing a monorepo with multiple microservices and finding your .gitlab-ci.yml file growing into an unmaintainable monster, this guide will transform your deployment strategy. We'll build a system that only builds and deploys what actually changed, scales to hundreds of services, and keeps your pipeline execution times sane.

The Monorepo Microservices Challenge

Let's be honest—monorepos with microservices present a unique challenge. You want the code organization benefits of a monorepo, but you don't want to build and deploy 47 services every time someone fixes a typo in the README. I've seen teams abandon monorepos entirely because their CI/CD couldn't handle the complexity efficiently.

The traditional approach looks something like this:

# The old way - don't do this
stages:
  - build
  - test
  - deploy

build-user-service:
  stage: build
  script: 
    - cd services/user-service && docker build -t user-service .
  only:
    changes:
      - services/user-service/**/*

build-payment-service:
  stage: build
  script:
    - cd services/payment-service && docker build -t payment-service .
  only:
    changes:
      - services/payment-service/**/*

# ... repeat for 20+ services

This approach doesn't scale. It becomes unmaintainable, hard to debug, and inevitably leads to a 2000-line CI file that nobody wants to touch.

Enter Dynamic Child Pipelines

Dynamic Child Pipelines are GitLab's answer to complex, multi-project scenarios. Instead of defining every possible job statically, you generate pipeline configurations on-the-fly based on what actually changed in your commit.

Here's the high-level flow:

  1. A parent pipeline detects which services changed
  2. It dynamically generates child pipeline configurations
  3. Each child pipeline handles building, testing, and deploying a specific service
  4. The parent pipeline orchestrates everything

The beauty? You get service-specific pipelines without the maintenance nightmare.

Project Structure Setup

Let's establish a realistic monorepo structure that we'll work with:

my-monorepo/
├── .gitlab-ci.yml                    # Parent pipeline
├── ci/
│   ├── child-pipeline-template.yml   # Template for child pipelines
│   └── generate-pipelines.py         # Pipeline generation script
├── services/
│   ├── user-service/
│   │   ├── Dockerfile
│   │   ├── src/
│   │   └── .service-config.yml       # Service-specific config
│   ├── payment-service/
│   │   ├── Dockerfile
│   │   ├── src/
│   │   └── .service-config.yml
│   └── notification-service/
│       ├── Dockerfile
│       ├── src/
│       └── .service-config.yml
├── shared/
│   └── common-libs/
└── infrastructure/
    └── k8s-manifests/

The Parent Pipeline: Detection and Orchestration

The parent pipeline is responsible for detecting changes and triggering appropriate child pipelines. Here's our main .gitlab-ci.yml:

stages:
  - detect
  - trigger

variables:
  # Use shallow clones for faster checkout
  GIT_DEPTH: 50

detect-changes:
  stage: detect
  image: python:3.9-slim
  before_script:
    - pip install pyyaml gitpython
  script:
    - python ci/generate-pipelines.py
  artifacts:
    paths:
      - generated-pipelines/
    expire_in: 1 hour
  rules:
    # Run on merge requests and main branch
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_REF_NAME == "main"'

trigger-child-pipelines:
  stage: trigger
  trigger:
    strategy: depend
    include:
      - artifact: generated-pipelines/child-pipelines.yml
        job: detect-changes
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_REF_NAME == "main"'

The Brain: Change Detection Script

The magic happens in our Python script that detects changes and generates pipeline configurations. Here's the robust version I've refined through multiple production deployments:

#!/usr/bin/env python3
# ci/generate-pipelines.py

import os
import yaml
import subprocess
from pathlib import Path
from typing import List, Dict, Set
import json

class PipelineGenerator:
    def __init__(self):
        self.repo_root = Path.cwd()
        self.services_dir = self.repo_root / "services"
        self.generated_dir = self.repo_root / "generated-pipelines"
        self.generated_dir.mkdir(exist_ok=True)
        
    def get_changed_files(self) -> Set[str]:
        """Get list of changed files using git diff."""
        try:
            # For MR pipelines, compare against target branch
            if os.getenv('CI_PIPELINE_SOURCE') == 'merge_request_event':
                target_branch = os.getenv('CI_MERGE_REQUEST_TARGET_BRANCH_NAME', 'main')
                cmd = ['git', 'diff', '--name-only', f'origin/{target_branch}...HEAD']
            else:
                # For main branch, compare with previous commit
                cmd = ['git', 'diff', '--name-only', 'HEAD~1', 'HEAD']
                
            result = subprocess.run(cmd, capture_output=True, text=True, check=True)
            return set(result.stdout.strip().split('\n')) if result.stdout.strip() else set()
        except subprocess.CalledProcessError as e:
            print(f"Error getting changed files: {e}")
            # Fallback: trigger all services (safe but not optimal)
            return set()

    def get_affected_services(self, changed_files: Set[str]) -> Dict[str, Dict]:
        """Determine which services are affected by the changes."""
        affected_services = {}
        
        for service_dir in self.services_dir.iterdir():
            if not service_dir.is_dir():
                continue
                
            service_name = service_dir.name
            service_config_path = service_dir / ".service-config.yml"
            
            # Load service configuration
            service_config = self.load_service_config(service_config_path)
            
            # Check if this service is affected
            if self.is_service_affected(service_dir, changed_files, service_config):
                affected_services[service_name] = {
                    'path': str(service_dir.relative_to(self.repo_root)),
                    'config': service_config
                }
                
        return affected_services

    def load_service_config(self, config_path: Path) -> Dict:
        """Load service-specific configuration."""
        default_config = {
            'build_context': '.',
            'dockerfile': 'Dockerfile',
            'dependencies': [],
            'test_command': 'echo "No tests configured"',
            'deploy_environment': ['staging', 'production'],
            'resource_requirements': {
                'cpu': '100m',
                'memory': '128Mi'
            }
        }
        
        if config_path.exists():
            with open(config_path, 'r') as f:
                user_config = yaml.safe_load(f) or {}
            # Merge with defaults
            return {**default_config, **user_config}
        
        return default_config

    def is_service_affected(self, service_dir: Path, changed_files: Set[str], 
                          service_config: Dict) -> bool:
        """Check if a service is affected by the changes."""
        service_path = service_dir.relative_to(self.repo_root)
        
        # Check direct changes to service
        for changed_file in changed_files:
            if changed_file.startswith(str(service_path)):
                return True
                
        # Check dependencies
        for dependency in service_config.get('dependencies', []):
            for changed_file in changed_files:
                if changed_file.startswith(dependency):
                    return True
                    
        # Check shared libraries (common pattern)
        shared_paths = ['shared/', 'common/']
        for shared_path in shared_paths:
            for changed_file in changed_files:
                if changed_file.startswith(shared_path):
                    return True
                    
        return False

    def generate_child_pipeline_config(self, affected_services: Dict[str, Dict]) -> Dict:
        """Generate the child pipeline configuration."""
        if not affected_services:
            # No services affected, create a minimal pipeline
            return {
                'stages': ['info'],
                'no-changes':  {
                    'stage': 'info',
                    'script': ['echo "No services affected by this change"'],
                    'rules': [{'when': 'always'}]
                }
            }

        pipeline_config = {
            'stages': ['build', 'test', 'deploy-staging', 'deploy-production'],
            'variables': {
                'DOCKER_DRIVER': 'overlay2',
                'DOCKER_TLS_CERTDIR': '/certs'
            },
            'services': ['docker:20.10.16-dind']
        }

        # Generate jobs for each affected service
        for service_name, service_info in affected_services.items():
            service_config = service_info['config']
            service_path = service_info['path']
            
            # Build job
            pipeline_config[f'build-{service_name}'] = {
                'stage': 'build',
                'image': 'docker:20.10.16',
                'before_script': [
                    'echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY'
                ],
                'script': [
                    f'cd {service_path}',
                    f'docker build -f {service_config["dockerfile"]} -t $CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA .',
                    f'docker push $CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA'
                ],
                'rules': [{'when': 'always'}]
            }
            
            # Test job
            pipeline_config[f'test-{service_name}'] = {
                'stage': 'test',
                'image': f'$CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA',
                'script': [service_config['test_command']],
                'needs': [f'build-{service_name}'],
                'rules': [{'when': 'always'}]
            }
            
            # Deploy jobs for each environment
            for env in service_config.get('deploy_environment', []):
                deploy_job_name = f'deploy-{service_name}-{env}'
                pipeline_config[deploy_job_name] = {
                    'stage': f'deploy-{env}',
                    'image': 'bitnami/kubectl:latest',
                    'script': [
                        f'echo "Deploying {service_name} to {env}"',
                        f'kubectl set image deployment/{service_name} {service_name}=$CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA -n {env}'
                    ],
                    'environment': {
                        'name': f'{env}/{service_name}',
                        'url': f'https://{service_name}-{env}.example.com'
                    },
                    'needs': [f'test-{service_name}'],
                    'rules': [
                        {'if': f'$CI_COMMIT_REF_NAME == "main"', 'when': 'manual'} if env == 'production' else {'when': 'always'}
                    ]
                }

        return pipeline_config

    def save_pipeline_config(self, config: Dict):
        """Save the generated pipeline configuration."""
        output_file = self.generated_dir / "child-pipelines.yml"
        
        with open(output_file, 'w') as f:
            yaml.dump(config, f, default_flow_style=False, sort_keys=False)
        
        print(f"Generated pipeline configuration saved to {output_file}")
        print(f"Affected services: {list(config.keys()) if config else 'None'}")

    def run(self):
        """Main execution function."""
        print("🔍 Detecting changed files...")
        changed_files = self.get_changed_files()
        print(f"Changed files: {changed_files}")
        
        print("📊 Analyzing affected services...")
        affected_services = self.get_affected_services(changed_files)
        print(f"Affected services: {list(affected_services.keys())}")
        
        print("⚙️  Generating pipeline configuration...")
        pipeline_config = self.generate_child_pipeline_config(affected_services)
        
        print("💾 Saving pipeline configuration...")
        self.save_pipeline_config(pipeline_config)

if __name__ == "__main__":
    generator = PipelineGenerator()
    generator.run()

Service-Specific Configuration

Each service should have a .service-config.yml file that defines its specific requirements:

# services/user-service/.service-config.yml
build_context: "."
dockerfile: "Dockerfile"
dependencies:
  - "shared/user-models"
  - "shared/auth-library"
test_command: |
  pip install -r requirements-test.txt
  python -m pytest tests/ -v --cov=src/
deploy_environment:
  - "staging"
  - "production"
resource_requirements:
  cpu: "200m"
  memory: "256Mi"
environment_variables:
  staging:
    DATABASE_URL: "$STAGING_USER_DB_URL"
    REDIS_URL: "$STAGING_REDIS_URL"
  production:
    DATABASE_URL: "$PROD_USER_DB_URL"
    REDIS_URL: "$PROD_REDIS_URL"
# services/payment-service/.service-config.yml
build_context: "."
dockerfile: "Dockerfile"
dependencies:
  - "shared/payment-models"
  - "shared/encryption-library"
test_command: |
  npm ci
  npm run test:unit
  npm run test:integration
deploy_environment:
  - "staging"
  - "production"
resource_requirements:
  cpu: "500m"
  memory: "512Mi"
security_scanning: true
environment_variables:
  staging:
    STRIPE_API_KEY: "$STAGING_STRIPE_KEY"
    DATABASE_URL: "$STAGING_PAYMENT_DB_URL"
  production:
    STRIPE_API_KEY: "$PROD_STRIPE_KEY"
    DATABASE_URL: "$PROD_PAYMENT_DB_URL"

Advanced Patterns and Optimizations

1. Dependency Graph Optimization

For complex service dependencies, implement a proper dependency graph:

# Addition to PipelineGenerator class
def build_dependency_graph(self, affected_services: Dict) -> Dict:
    """Build a proper dependency graph for optimal build ordering."""
    dependency_graph = {}
    
    for service_name, service_info in affected_services.items():
        dependencies = []
        for dep_path in service_info['config'].get('dependencies', []):
            # Check if dependency is another service
            dep_service = self.path_to_service_name(dep_path)
            if dep_service and dep_service in affected_services:
                dependencies.append(dep_service)
        
        dependency_graph[service_name] = dependencies
    
    return dependency_graph

def generate_build_stages(self, dependency_graph: Dict) -> List[List[str]]:
    """Generate build stages based on dependency graph."""
    # Topological sort to determine build order
    visited = set()
    temp_visited = set()
    stages = []
    current_stage = []
    
    def visit(service):
        if service in temp_visited:
            raise ValueError(f"Circular dependency detected involving {service}")
        if service in visited:
            return
            
        temp_visited.add(service)
        
        for dependency in dependency_graph.get(service, []):
            visit(dependency)
            
        temp_visited.remove(service)
        visited.add(service)
        current_stage.append(service)
    
    # Process all services
    for service in dependency_graph:
        if service not in visited:
            visit(service)
    
    return [current_stage] if current_stage else []

2. Selective Testing Strategies

Implement smart testing that runs different test suites based on what changed:

def determine_test_strategy(self, service_name: str, changed_files: Set[str]) -> str:
    """Determine what level of testing is needed."""
    service_files = [f for f in changed_files if f.startswith(f'services/{service_name}')]
    
    # Check if only documentation changed
    doc_only = all(f.endswith(('.md', '.txt', '.rst')) for f in service_files)
    if doc_only:
        return 'skip'
    
    # Check if only tests changed
    test_only = all('/test' in f or f.endswith('_test.py') for f in service_files)
    if test_only:
        return 'unit-only'
    
    # Check if core logic changed
    core_changed = any('/src/' in f or '/lib/' in f for f in service_files)
    if core_changed:
        return 'full'
    
    return 'unit-only'

3. Parallel Execution with Resource Limits

Control resource usage during parallel builds:

# In generated pipeline
build-user-service:
  stage: build
  image: docker:20.10.16
  resource_group: docker-builds  # Limit concurrent Docker builds
  parallel: 
    matrix:
      - PLATFORM: ["linux/amd64", "linux/arm64"]
  script:
    - docker buildx create --use
    - docker buildx build --platform $PLATFORM -t $CI_REGISTRY_IMAGE/user-service:$CI_COMMIT_SHA-$PLATFORM .

Production Deployment Strategies

Blue-Green Deployments

Integrate blue-green deployment patterns into your child pipelines:

def generate_blue_green_deployment(self, service_name: str, config: Dict) -> Dict:
    """Generate blue-green deployment jobs."""
    return {
        f'deploy-{service_name}-blue': {
            'stage': 'deploy-blue',
            'script': [
                f'kubectl patch deployment {service_name}-blue -p \'{{"spec":{{"template":{{"spec":{{"containers":[{{"name":"{service_name}","image":"$CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA"}}]}}}}}}}}\'',
                f'kubectl rollout status deployment/{service_name}-blue',
                f'kubectl get pods -l app={service_name},slot=blue'
            ],
            'environment': {
                'name': f'production-blue/{service_name}',
                'url': f'https://{service_name}-blue.example.com'
            }
        },
        f'smoke-test-{service_name}': {
            'stage': 'smoke-test',
            'script': [
                f'curl -f https://{service_name}-blue.example.com/health',
                f'python ci/smoke-tests/{service_name}_smoke_test.py'
            ],
            'needs': [f'deploy-{service_name}-blue']
        },
        f'switch-traffic-{service_name}': {
            'stage': 'switch-traffic',
            'script': [
                f'kubectl patch service {service_name} -p \'{{"spec":{{"selector":{{"slot":"blue"}}}}}}\'',
                f'echo "Traffic switched to blue slot for {service_name}"'
            ],
            'when': 'manual',
            'needs': [f'smoke-test-{service_name}']
        }
    }

Canary Releases

For high-risk services, implement canary releases:

deploy-payment-service-canary:
  stage: deploy-canary
  script:
    - kubectl patch deployment payment-service-canary -p '{"spec":{"template":{"spec":{"containers":[{"name":"payment-service","image":"$CI_REGISTRY_IMAGE/payment-service:$CI_COMMIT_SHA"}]}}}}'
    - kubectl scale deployment payment-service-canary --replicas=1
    - sleep 60  # Let canary warm up
  environment:
    name: production-canary/payment-service
    url: https://payment-service-canary.example.com
  rules:
    - if: '$CI_COMMIT_REF_NAME == "main"'
      when: manual

monitor-payment-service-canary:
  stage: monitor-canary
  script:
    - python ci/canary-monitor.py payment-service --duration=600 --error-threshold=1%
  needs: ["deploy-payment-service-canary"]
  timeout: 15 minutes

Monitoring and Observability

Pipeline Performance Metrics

Track your pipeline performance with custom metrics:

# ci/pipeline-metrics.py
import time
import json
from datetime import datetime

class PipelineMetrics:
    def __init__(self):
        self.start_time = time.time()
        self.metrics = {
            'pipeline_id': os.getenv('CI_PIPELINE_ID'),
            'commit_sha': os.getenv('CI_COMMIT_SHA'),
            'services_built': [],
            'total_build_time': 0,
            'cache_hit_ratio': 0
        }
    
    def record_service_build(self, service_name: str, build_time: float, cache_hit: bool):
        self.metrics['services_built'].append({
            'name': service_name,
            'build_time': build_time,
            'cache_hit': cache_hit,
            'timestamp': datetime.utcnow().isoformat()
        })
    
    def export_metrics(self):
        self.metrics['total_build_time'] = time.time() - self.start_time
        
        with open('pipeline-metrics.json', 'w') as f:
            json.dump(self.metrics, f)

Error Handling and Notifications

Implement robust error handling:

def handle_pipeline_failure(self, service_name: str, error: str):
    """Handle pipeline failures with proper notifications."""
    failure_info = {
        'service': service_name,
        'pipeline_id': os.getenv('CI_PIPELINE_ID'),
        'commit_sha': os.getenv('CI_COMMIT_SHA'),
        'error': error,
        'timestamp': datetime.utcnow().isoformat()
    }
    
    # Send to monitoring system
    self.send_to_datadog(failure_info)
    
    # Slack notification for critical services
    if service_name in self.critical_services:
        self.send_slack_alert(failure_info)

Common Pitfalls and How to Avoid Them

1. Git Depth Issues

Problem: Shallow clones can cause issues with change detection.

Solution: Set appropriate GIT_DEPTH and handle edge cases:

variables:
  GIT_DEPTH: 100  # Deeper for better change detection

detect-changes:
  before_script:
    - |
      # Ensure we have enough history for comparison
      if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
        git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME --depth=100
      fi

2. Artifact Propagation

Problem: Child pipelines don't automatically inherit artifacts from parent.

Solution: Use the GitLab API to pass data:

def pass_data_to_child_pipeline(self, data: Dict):
    """Pass data to child pipeline via GitLab variables API."""
    import requests
    
    headers = {'PRIVATE-TOKEN': os.getenv('CI_JOB_TOKEN')}
    
    # Store data as pipeline variable
    url = f"{os.getenv('CI_API_V4_URL')}/projects/{os.getenv('CI_PROJECT_ID')}/variables"
    
    for key, value in data.items():
        requests.post(url, headers=headers, data={
            'key': f'PIPELINE_DATA_{key}',
            'value': json.dumps(value),
            'variable_type': 'env_var'
        })

3. Resource Exhaustion

Problem: Too many parallel builds overwhelming the infrastructure.

Solution: Implement intelligent throttling:

# Use resource groups to limit concurrency
build-service:
  resource_group: build-pool-${CI_CONCURRENT_ID}
  parallel: 
    matrix:
      - SERVICE: [user-service, payment-service, notification-service]
  rules:
    - if: '$CI_CONCURRENT_BUILDS_LIMIT && $CI_CONCURRENT_BUILDS_LIMIT > "5"'
      when: never  # Skip if too many builds running
    - when: always

Security Considerations

1. Secure Variable Handling

# Parent pipeline variables
variables:
  SECURE_VAR_PREFIX: "SECURE_"
  
trigger-child-pipelines:
  trigger:
    strategy: depend
    include:
      - artifact: generated-pipelines/child-pipelines.yml
        job: detect-changes
    # Forward only specific secure variables
    forward:
      yaml_variables: false
      pipeline_variables: true

2. Container Image Scanning

Integrate security scanning into your child pipelines:

def add_security_scanning(self, service_name: str) -> Dict:
    """Add container security scanning job."""
    return {
        f'security-scan-{service_name}': {
            'stage': 'security',
            'image': 'registry.gitlab.com/security-products/container-scanning:latest',
            'script': [
                f'container-scanning $CI_REGISTRY_IMAGE/{service_name}:$CI_COMMIT_SHA'
            ],
            'artifacts': {
                'reports': {
                    'container_scanning': 'gl-container-scanning-report.json'
                }
            },
            'needs': [f'build-{service_name}']
        }
    }

Performance Optimization Tips

1. Smart Caching Strategy

# Multi-stage Dockerfile for better caching
FROM node:16-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:16-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

FROM node:16-alpine AS runtime
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
EXPOSE 3000
CMD ["npm", "start"]

2. Parallel Test Execution

test-user-service:
  stage: test
  parallel: 3
  script:
    - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
  artifacts:
    reports:
      junit: junit-$CI_NODE_INDEX.xml

Conclusion

Dynamic Child Pipelines have transformed how my teams handle monorepo deployments. What used to be a 45-minute pipeline that built everything now runs in 8-12 minutes and only touches what changed. We've deployed this pattern across monorepos with 50+ services, and it scales beautifully.

The key insights I've learned:

  1. Invest in the detection logic—accurate change detection is crucial
  2. Keep child pipelines simple—complex logic belongs in the parent
  3. Monitor pipeline performance—optimize based on real metrics
  4. Plan for failures—robust error handling saves debugging time
  5. Security first—never compromise on security for speed

This approach isn't just about faster pipelines—it's about sustainable CI/CD that grows with your team. When your deployment process can handle a 100-service monorepo as easily as a 10-service one, you've built something that will serve your team for years.

The setup requires initial investment, but the payoff is massive. Your developers get faster feedback, your infrastructure costs decrease, and your deployment confidence increases. In my experience, teams that implement this pattern never go back to static CI configurations.

Start with a few services, prove the pattern works, then expand. Your future self will thank you when you're effortlessly managing deployments at scale instead of wrestling with an unmaintainable CI file.

Share:

Was this article helpful?

Aareez Asif
Aareez Asif

Senior Kubernetes Architect

10+ years orchestrating containers in production. Battle-tested opinions on everything from pod scheduling to service mesh. I've seen clusters burn and helped rebuild them better.

Related Articles

More in CI/CD

View all →

Discussion