GitLab CI’s include keyword lets you break down massive .gitlab-ci.yml files into manageable, reusable pieces.
Here’s a simple pipeline that uses include to pull in a separate file for a build stage:
# .gitlab-ci.yml
stages:
- build
- test
- deploy
include:
- local: '.gitlab/ci/build_stage.yml'
test_job:
stage: test
script:
- echo "Running tests..."
deploy_job:
stage: deploy
script:
- echo "Deploying..."
And here’s the build_stage.yml file:
# .gitlab/ci/build_stage.yml
build_job:
stage: build
script:
- echo "Building the application..."
- echo "Dependencies installed."
When GitLab CI runs this, it first reads .gitlab-ci.yml. It sees the include directive and fetches the build_stage.yml file. The contents of build_stage.yml are then merged into the main pipeline configuration. So, effectively, GitLab CI sees a pipeline definition that looks like this:
stages:
- build
- test
- deploy
build_job:
stage: build
script:
- echo "Building the application..."
- echo "Dependencies installed."
test_job:
stage: test
script:
- echo "Running tests..."
deploy_job:
stage: deploy
script:
- echo "Deploying..."
This is powerful because you can define common build steps, testing frameworks, or deployment scripts once and reuse them across many projects or within different parts of the same project.
The Problem Solved: Monolithic CI Files
As CI/CD pipelines grow, the .gitlab-ci.yml file can become enormous and unwieldy. Managing hundreds or thousands of lines of YAML for different environments, services, or build configurations becomes a nightmare. include allows you to modularize this, making your CI configuration:
- More readable: Smaller, focused files are easier to understand.
- More maintainable: Changes to a common build process only need to be made in one place.
- More reusable: Standardize common patterns across multiple projects.
- Easier to test: Isolate and test individual CI components.
How include Works Internally
When GitLab CI parses your .gitlab-ci.yml, it encounters include directives. It then fetches the specified files and merges their contents into the main configuration. The order of merging matters: local files are processed first, then remote files (from other GitLab instances or raw URLs), and finally, values from later include statements can override those from earlier ones. Jobs defined in included files are merged with jobs defined in the main file. If there are duplicate job names, the last one defined (based on the order of inclusion and the main file) wins. Keywords like stages, variables, and default are also merged, with later definitions taking precedence.
Types of Includes
You can include CI configurations from several sources:
-
Local files:
include: - local: '.gitlab/ci/common_jobs.yml' - local: '.gitlab/ci/deployments/staging.yml'This is the most common for monorepos or structured project directories.
-
Remote files (from another GitLab project):
include: - project: 'my-group/my-shared-ci' ref: main file: '/templates/ci.yml'This is fantastic for sharing CI templates across different GitLab projects managed by your organization. The
refcan be a branch, tag, or commit SHA. -
Remote files (from a URL):
include: - remote: 'https://raw.githubusercontent.com/my-user/my-repo/main/ci-template.yml'Useful for including public CI templates or ones hosted externally.
-
Included files can themselves include other files: This allows for hierarchical or deeply nested CI configurations.
Templates: A Conceptual Leap
While include is the mechanism, the concept of reusable CI logic is often referred to as "templates." You can create a .gitlab-ci.yml file in a dedicated "templates" project, or a templates/ directory within a monorepo, and then include these files into your application projects.
Consider a common docker-build-template.yml:
# .gitlab/ci/templates/docker-build.yml
.docker_build_template: &docker_build_template
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: "" # Disable TLS for dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- export IMAGE_TAG="$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
- export LATEST_TAG="$CI_REGISTRY_IMAGE:latest"
script:
- docker build -t $IMAGE_TAG .
- docker push $IMAGE_TAG
- docker tag $IMAGE_TAG $LATEST_TAG
- docker push $LATEST_TAG
tags:
- docker
build_app_image:
<<: *docker_build_template
stage: build
script:
- echo "Building application image..."
- !reference [.docker_build_template, script] # Execute the template's script
And in your main .gitlab-ci.yml:
# .gitlab-ci.yml
include:
- local: '.gitlab/ci/templates/docker-build.yml'
stages:
- build
- test
test_job:
stage: test
script:
- echo "Running tests..."
This setup defines a reusable Docker build job using YAML anchors (&) and references (!reference) and then applies it to a specific job build_app_image. The !reference keyword is crucial here; it allows you to pull specific parts of a job definition (like script) from an included template, rather than the entire job. This is how you truly template-ize CI logic.
The key insight is that GitLab CI doesn’t just copy-paste YAML. It parses, merges, and resolves references. You can define abstract job templates (often prefixed with a dot . to indicate they are not meant to be run directly) and then use <<: to inherit from them, or !reference to pull specific keys. This allows for powerful composition.
The Nuance of Merging Keywords
When you include multiple files or a file and the main .gitlab-ci.yml, GitLab merges keywords like stages, variables, and default. If a keyword is defined in both the main file and an included file, or in multiple included files, the definition from the last one processed will win. This is usually the main .gitlab-ci.yml if it’s the last thing in your include list, or the last file listed in the include array. This precedence is critical for overriding default settings or ensuring specific configurations are applied. For example, if stages is defined in .gitlab-ci.yml and also in an included file, the stages from the file that GitLab processes last will be the active ones for the entire pipeline.
The next challenge you’ll face is managing complex conditional logic across included files.