GitLab CI Dynamic Child Pipelines For Monorepo Microservices Deployment
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:
- A parent pipeline detects which services changed
- It dynamically generates child pipeline configurations
- Each child pipeline handles building, testing, and deploying a specific service
- 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 /app/node_modules ./node_modules
COPY /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:
- Invest in the detection logic—accurate change detection is crucial
- Keep child pipelines simple—complex logic belongs in the parent
- Monitor pipeline performance—optimize based on real metrics
- Plan for failures—robust error handling saves debugging time
- 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.
Was this article helpful?
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
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
Fix GitHub Actions 'Resource not accessible by integration'
Resolve the GitHub Actions 'Resource not accessible by integration' error by fixing workflow permissions, GITHUB_TOKEN scopes, and repository settings.
GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines
Eliminate duplicated CI/CD logic across repositories using GitHub Actions reusable workflows and composite actions with real-world examples.
Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions
Harden GitHub Actions security with least-privilege permissions, OIDC federation, SHA-pinned actions, and secrets management best practices.
GitHub Actions Matrix Builds for Multi-Platform Testing
Master GitHub Actions matrix builds to test across multiple OS versions, language versions, and configurations in parallel.
GitLab CI Pipeline Optimization: Caching, DAG, and Parallel Jobs
Cut your GitLab CI pipeline time from 25 minutes to 6 with smart caching, DAG dependency graphs, parallel test splitting, and stage optimization.
More in CI/CD
View all →Fix GitHub Actions Workflow Stuck in 'Queued'
Diagnose and resolve GitHub Actions workflows that remain stuck in 'queued' status, covering runner availability, concurrency limits, and label mismatches.
Automated Semantic Versioning with Conventional Commits and release-please
Stop manually bumping versions. Use conventional commits and release-please to automate versioning, changelogs, and releases.