Include & Extends
Modular Pipelines
Reusable Templates
Configuration Inheritance
Learn how to split large pipelines into manageable files with include and reuse job configuration with extends. Combine both to build maintainable, DRY CI/CD configurations.
Include
Extends
GitLab CI/CD
Why Modular Pipelines?
As pipelines grow, a single .gitlab-ci.yml file can become hundreds or thousands of lines. Every job lives in one place, making it hard to navigate, review, and maintain. Changes that should affect only deployment jobs end up buried among test configurations. Teams working on different parts of the pipeline step on each other's toes in merge requests. This is the classic monolith problem, applied to CI configuration.
GitLab offers two complementary solutions. include lets you split your pipeline into multiple files — and even pull configuration from other projects or remote URLs. You can have a build.yml, a test.yml, and a deploy.yml, each owned by the relevant team, all assembled into one pipeline at runtime [citation:5]. extends solves a different problem: within a single file, multiple jobs often share the same image, before_script, cache, or rules. Instead of copying those lines, you define a hidden template job and have other jobs inherit from it [citation:2].
Together, these keywords turn a sprawling, duplicated pipeline into a clean, modular configuration where each piece has a single home and a single responsibility. The result is easier reviews, fewer merge conflicts, and configuration that scales with your project.
Core Principle: include assembles files into one pipeline. extends inherits configuration between jobs. Use both together for maximum maintainability.
include
Imports configuration from other files, projects, or URLs. Splits one monolithic file into modular, team-owned pieces.
extends
Inherits configuration from hidden jobs (templates). Eliminates duplication of shared settings like image, cache, and rules.
Multi-level Inheritance
extends supports up to 11 levels of inheritance, but 2-3 levels is recommended for maintainability.
Include + Extends
Combine both: include a template file, then extends from a job defined in it. The most powerful pattern.
Using the include Keyword
The include keyword brings external YAML configuration into your pipeline. It supports four types of sources: local (files in the same repository), project (files from another GitLab project), remote (any public URL), and template (GitLab-provided templates like SAST or Dependency Scanning) [citation:5]. This flexibility means you can share CI/CD standards across an entire organization without copying and pasting.
When multiple included files define the same job or the same top-level key, the last included file wins. This is important to understand: include order matters. If defaults.yml defines a before_script and overrides.yml also defines one, whichever is included last takes effect [citation:5]. This allows you to create a base template and layer environment-specific overrides on top of it.
You can also use rules with include to conditionally include files based on variables or branch names. This is particularly useful in monorepos: instead of loading every sub-project's pipeline configuration and then using job-level rules to skip jobs, you can include only the configuration for the sub-projects that actually changed [citation:13].
# Include local files
include:
- local: '/ci/build.yml'
- local: '/ci/test.yml'
- local: '/ci/deploy.yml'
# Include from another project
include:
- project: 'my-group/ci-templates'
ref: 'v1.2.0' # Pin to a tag for stability
file:
- '/templates/build.yml'
- '/templates/test.yml'
# Include from a remote URL
include:
- remote: 'https://example.com/ci/standard.yml'
# Include GitLab-provided template
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Container-Scanning.gitlab-ci.yml
# Conditional include (monorepo pattern)
include:
- local: '/frontend/.gitlab-ci.yml'
rules:
- changes:
- frontend/**/*
- local: '/backend/.gitlab-ci.yml'
rules:
- changes:
- backend/**/*
# Include with variables (available in include)
# Note: Cannot use job-level variables or global variables
include:
- project: 'my-group/ci-templates'
ref: $CI_COMMIT_REF_NAME # Use branch name
file: '/templates/$CI_PROJECT_NAME.yml'
# Include order matters: last definition wins
include:
- local: '/ci/defaults.yml' # Defines before_script
- local: '/ci/overrides.yml' # Overrides before_script
# YAML anchors do NOT work across included files
# Use extends or !reference instead
# Include with cache (GitLab 18.0+)
include:
- remote: 'https://example.com/large-template.yml'
cache: '1 day' # Cache remote file for 1 day
Include Gotchas:
- YAML anchors don't cross files: An anchor defined in one included file cannot be referenced in another. Use
extends or !reference [citation:3]
- Include order matters: The last included file overrides previous definitions [citation:5]
- Limited variables: You can use project/group variables and predefined variables like
$CI_COMMIT_REF_NAME, but not job-level variables [citation:5]
- Pin remote refs: Use a tag or versioned ref instead of
main for stable templates [citation:16]
- Nested includes: Included files can themselves include other files, but GitLab limits nesting depth
Include Best Practices:
- Organize CI files in a
ci/ directory within your repo
- Use separate files for build, test, security, and deploy
- Pin project includes to a tag or release branch, never
main
- Use conditional includes in monorepos to reduce pipeline size
- Document what each included file is responsible for
Using the extends Keyword
extends is GitLab's answer to configuration duplication within a pipeline. You define a hidden job (a job whose name starts with a dot, like .base_job) containing shared configuration. Other jobs then inherit from it using extends: .base_job. The inheriting job gets all the keys from the template, and can override any of them [citation:2].
What makes extends more powerful than YAML anchors is that it works across included files. If you include a template from another project, you can still use extends to inherit from a job defined in that template. YAML anchors cannot do this because they are scoped to a single file [citation:3]. This is why extends is the recommended approach for reusable configuration in modern GitLab pipelines.
GitLab uses a reverse deep merge strategy. When a job extends multiple templates, keys from later templates override earlier ones. Within a single template, keys defined in the child job override the parent. However, arrays are not merged — a child's script array completely replaces the parent's. To merge arrays, you need YAML anchors [citation:2].
# Basic extends: inherit from a hidden job
.base_job:
image: node:18
before_script:
- npm ci
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- node_modules/
build:
extends: .base_job
stage: build
script:
- npm run build
test:
extends: .base_job
stage: test
script:
- npm test
# Multi-level inheritance (2 levels)
.tests:
rules:
- if: $CI_PIPELINE_SOURCE == "push"
.rspec:
extends: .tests
script:
- rake rspec
rspec_unit:
extends: .rspec
variables:
RSPEC_SUITE: 'unit'
rspec_integration:
extends: .rspec
variables:
RSPEC_SUITE: 'integration'
# Multiple inheritance (merge multiple templates)
.only_main:
rules:
- if: $CI_COMMIT_BRANCH == "main"
.docker:
image: docker:24
services:
- docker:24-dind
deploy:
extends:
- .only_main
- .docker
stage: deploy
script:
- docker build -t myapp .
- docker push myapp
# Excluding a key from extends (set to null)
.base:
script: echo "base"
variables:
VAR1: base var 1
test:
extends: .base
variables:
VAR1: null # Exclude VAR1 from base
VAR2: test var 2
# extends with include (the powerful combination)
include:
- local: '/ci/templates.yml'
# templates.yml defines .node_base
# .gitlab-ci.yml extends from it
build:
extends: .node_base
script:
- npm run build
# Merge behavior:
# - Hashes (variables, cache, etc.) merge deeply
# - Arrays (script, before_script) do NOT merge; child wins
# - Child keys always override parent keys
# Inheritance depth: up to 11 levels
# Recommended: 2-3 levels maximum
# Deeper inheritance is hard to trace and debug
extends vs YAML Anchors:
- extends: Works across files, simpler syntax, merges hashes, supports multi-level inheritance [citation:2]
- YAML anchors: Only within one file, can merge arrays, more verbose syntax, cannot be used with include across files [citation:3]
- Recommendation: Use
extends as the default. Use YAML anchors only when you specifically need to merge arrays.
Combining Include and Extends
The real power emerges when you combine include and extends. This pattern lets you maintain shared templates in a central repository, include them into any project, and inherit from them with job-specific customizations. It's the foundation of scalable CI/CD across an organization [citation:2].
Here's how it works: your central ci-templates project contains files like node.yml, python.yml, and docker.yml, each defining hidden template jobs (.node_base, .python_base, etc.). A consuming project includes those files and then uses extends to build actual jobs from the templates. The consuming project writes only what's unique — the script, any overrides — and inherits everything else.
This separation of concerns is powerful. The platform team owns the templates: they decide the base image version, the caching strategy, the security scanning integration. Application teams own their scripts and their specific overrides. When the platform team updates the base image, every consuming project picks up the change on its next pipeline run — no manual updates across dozens of repositories [citation:9].
# Central repository: ci-templates/templates/node.yml
.node_base:
image: node:18
before_script:
- npm ci --cache .npm --prefer-offline
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .npm/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
.node_test_base:
extends: .node_base
stage: test
artifacts:
when: always
reports:
junit: test-results.xml
# Consuming project: .gitlab-ci.yml
include:
- project: 'my-group/ci-templates'
ref: 'v2.1.0'
file: '/templates/node.yml'
build:
extends: .node_base
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 day
test_unit:
extends: .node_test_base
script:
- npm run test:unit
artifacts:
reports:
junit: test-results/unit.xml
test_integration:
extends: .node_test_base
script:
- npm run test:integration
artifacts:
reports:
junit: test-results/integration.xml
deploy:
extends: .node_base
stage: deploy
script:
- ./deploy.sh dist/
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
# The consuming project writes ~25 lines
# But inherits image, cache, before_script, rules, artifacts config
# Changes to node.yml in ci-templates propagate to all consumers
Complete Modular Pipeline Example
Here's a complete example showing a project that splits its pipeline into multiple files and uses extends to share configuration across jobs.
# .gitlab-ci.yml (root file)
# This file orchestrates everything
stages:
- build
- test
- deploy
# Include modular CI files
include:
- local: '/ci/base.yml'
- local: '/ci/build.yml'
- local: '/ci/test.yml'
- local: '/ci/deploy.yml'
# ci/base.yml
# Shared configuration for all jobs
.base_job:
image: node:18
before_script:
- npm ci --cache .npm --prefer-offline
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .npm/
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
# ci/build.yml
build:
extends: .base_job
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 day
name: "build-$CI_COMMIT_SHORT_SHA"
# ci/test.yml
.test_base:
extends: .base_job
stage: test
artifacts:
when: always
reports:
junit: test-results.xml
expire_in: 1 week
test_unit:
extends: .test_base
script:
- npm run test:unit
artifacts:
reports:
junit: test-results/unit.xml
test_integration:
extends: .test_base
dependencies:
- build
script:
- npm run test:integration
artifacts:
reports:
junit: test-results/integration.xml
lint:
extends: .base_job
stage: test
cache: []
dependencies: []
script:
- npm run lint
# ci/deploy.yml
.deploy_base:
extends: .base_job
stage: deploy
dependencies:
- build
deploy_staging:
extends: .deploy_base
script:
- ./deploy.sh staging dist/
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy_production:
extends: .deploy_base
script:
- ./deploy.sh production dist/
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG
when: manual
# Result:
# - Root .gitlab-ci.yml is small and readable
# - Each file has a single responsibility
# - Shared config lives in .base_job
# - Changes to base_job affect all jobs consistently
# - Teams can own their respective files
Troubleshooting Include & Extends
# include: file not found
# - Check path is relative to project root (starts with /)
# - Check the ref/branch exists
# - Check the file exists in that ref
# - For project includes, ensure you have read access
# include: "jobs config should contain at least one visible job"
# - The included file only contains hidden jobs (.template)
# - You need at least one visible job in the final configuration
# - Add a job that extends a template
# extends: "job not found"
# - Check the template job name (must start with dot)
# - Check spelling
# - If using include, ensure the template is actually included
# - Check include order (template must be included before use)
# extends not merging as expected
# - Remember: arrays (script, before_script) do NOT merge
# - Child's array completely replaces parent's array
# - To merge arrays, use YAML anchors
# YAML anchors not working across files
# - Anchors are file-scoped; cannot be referenced from another file
# - Use extends or !reference instead
# Include order issues (last wins)
include:
- local: '/ci/defaults.yml' # Defines before_script
- local: '/ci/overrides.yml' # Must come after to override
# Reverse order: overrides would be overwritten by defaults
# Debug: use CI Lint
# In GitLab UI: CI/CD > Editor > Validate
# Or API: POST /projects/:id/ci/lint
# Too many nested includes
# - GitLab limits include depth
# - Flatten the include tree if possible
# - Avoid circular includes
# Included file has different stages
# - All stages must be defined in root .gitlab-ci.yml
# - Included files cannot add new stages to the pipeline
# - Define all stages at root level
Frequently Asked Questions
What's the difference between include and extends?
include imports external files into your pipeline. extends inherits configuration from a hidden job. They solve different problems and are often used together.
Can I use YAML anchors across included files?
No. YAML anchors are scoped to a single file. Use extends or !reference tags for cross-file reuse [citation:3].
How many levels of extends are supported?
Up to 11 levels, but 2-3 levels is recommended. Deeper inheritance is harder to trace and debug [citation:2].
What happens when two included files define the same job?
The last included file wins. Include order matters. Use this intentionally to layer defaults and overrides [citation:5].
Can I use variables in include paths?
Yes, but only certain variables: project/group variables, CI_COMMIT_REF_NAME, and predefined CI variables. Job-level variables won't work [citation:5].
How do I conditionally include files?
Use include:rules with if or changes. This is useful for monorepos to include only changed sub-project configurations [citation:13].
Why does my extended job have the wrong script?
Arrays don't merge with extends. The child's script completely replaces the parent's. Use YAML anchors if you need to merge scripts.
Should I use include or CI/CD components?
For simple file organization within a project, use include:local. For organization-wide reusable templates with versioning, consider CI/CD Components (include:component) [citation:9].