Include & Extends

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: