GitLab CI Includes: 4 Patterns and a Checklist for Platform Teams
Practical GitLab CI include patterns with copyable examples, a short operational checklist, and debugging tips including the 150 include limit to keep...
GitLab CI includes imported external YAML files into your .gitlab-ci.yml, letting you split one sprawling pipeline into reusable, modular pieces. Included files evaluate first and merge into the main configuration, with your local file’s definitions taking precedence over anything it imports. Teams lean on this for shared templates, per-team overrides, and pipelines that assemble themselves conditionally based on branch, variable, or file changes.
TL;DR:
- Using
include:projectwith pinned refs ensures stable, versioned shared templates and prevents silent changes from unpinned branch updates.- Combining multiple include types in an ordered array allows for layered configurations, with the main file always overriding included definitions.
- The maximum include limit is 150 per pipeline, so it’s vital to monitor include complexity and avoid overly nested or duplicate includes to prevent failures.
- Conditional includes with
rules—especiallyexistsandchanges—optimize pipeline execution by loading expensive jobs only when relevant.- To troubleshoot include issues, run CI Lint on merged configurations and keep include trees shallow, explicit, and well-documented.
Table of Contents
- What Are the GitLab CI Include Types and Their Syntax?
- How Do You Write a Single Include or an Include Array?
- How Does GitLab Merge Multiple Includes?
- How Do Nested Includes and Duplicate Files Get Handled?
- Can You Use Variables Inside an Include Statement?
- How Do You Make Includes Conditional With Rules?
- How Do Wildcards and Branch Rules Work for Local Includes?
- Why Do Includes Fail and How Do You Debug Them?
- What Are Production-Ready Include Patterns Worth Copying?
- What Governance Habits Keep Include-Based Pipelines Maintainable?
- When Do Includes Add More Complexity Than They’re Worth?
- Get Help Building a Modular GitLab CI Pipeline
- Sources
- FAQ
What Are the GitLab CI Include Types and Their Syntax?
GitLab gives you five ways to pull configuration into a pipeline: include:local, include:project, include:remote, include:template, and include:component. Each one solves a slightly different reuse problem, and picking the wrong one is the fastest way to end up with a pipeline that’s fragile or slow to debug.
Local includes pull a file from the same repository and branch as the pipeline that’s running. This is the default choice for splitting a large .gitlab-ci.yml into logical chunks (build, test, deploy) without leaving the repo:
include:
- local: '.gitlab/ci/build.yml'
Use local includes when the config only matters to this one project and doesn’t need to travel anywhere else.
Project includes reach into a different GitLab project entirely, optionally pinned to a specific ref:
include:
- project: 'my-group/ci-templates'
ref: 'v2.3.1'
file: '/templates/deploy.yml'
This is the backbone of a central catalog pattern. Instead of every service team copying and pasting a deploy job, they all include it from one governed repository. Pin the ref to a tag or commit SHA, not main or HEAD. Practitioners who build shared templates this way consistently recommend pinning the ref rather than tracking a moving branch, because an unpinned include means someone else’s commit can silently change your pipeline’s behavior overnight.
Remote includes fetch a YAML file over HTTP or HTTPS from outside GitLab entirely:
include:
- remote: 'https://example.com/ci/templates/security.yml'
Avoid these when you have any other option. A remote include depends on a third-party server being reachable at pipeline runtime, it usually can’t be authenticated the way a GitLab-hosted file can, and a compromised or unreachable endpoint breaks every pipeline that depends on it. Reserve remote includes for cases where the file genuinely can’t live in GitLab, such as a vendor-published scanner config.
Template includes pull from GitLab’s own library of built-in templates for common patterns like Auto DevOps, Dependency Scanning, or SAST:
include:
- template: 'Security/SAST.gitlab-ci.yml'
Component includes reference versioned, reusable job definitions from the CI/CD Catalog, GitLab’s newer model for packaging a job (with its own inputs and interface) as a standalone unit rather than a raw YAML file:
include:
- component: 'gitlab.com/my-group/security-scan-component@1.0'
All five source types are documented directly in GitLab’s include syntax reference, and knowing which one fits your situation is most of the battle before you even touch merge behavior.
How Do You Write a Single Include or an Include Array?
The shorthand form works when you just need one local file and nothing fancy:
include: '.gitlab/ci/build.yml'
GitLab infers local as the type here because you gave it a plain string with no explicit key. That inference only applies to local paths. The moment you need a project, remote, template, or component source, you switch to the explicit array form.
Most real pipelines need more than one included file, and that’s where the array syntax earns its keep:
include:
- template: 'Security/SAST.gitlab-ci.yml'
- project: 'my-group/ci-templates'
ref: 'v2.3.1'
file: '/templates/deploy.yml'
- local: '.gitlab/ci/lint.yml'
- local: '.gitlab/ci/test.yml'
A few things to keep straight when you’re building one of these arrays:
- Each array entry needs its own type key (
local,project,remote,template, orcomponent) unless it’s a bare string, which is treated aslocal. - Order matters for merge precedence, covered in detail in the next section, so put foundational or default-setting includes first.
- You can mix source types freely in one array; nothing stops you from combining a template, a project include, and two local files in the same list.
fileaccepts an array too, so oneprojectentry can pull multiple files from the same external repo without repeating theprojectandrefkeys.
A common real-world arrangement looks like this: pull a security template from GitLab, pull a shared deploy job from a central templates project pinned to a tagged release, then layer in two local files for anything specific to this repo. That’s four include sources doing the work that used to require copying hundreds of lines of YAML into every project.
How Does GitLab Merge Multiple Includes?
GitLab evaluates includes in a fixed, predictable order, and understanding that order is what separates people who fight their pipelines from people who don’t.
Here’s the actual sequence, straight from GitLab’s own documentation on using CI/CD configuration from other files: if an included file itself contains an include (a nested include), those nested files are merged first. Then each file in your main include array is merged in the order it’s listed. Finally, your main .gitlab-ci.yml is merged last, on top of everything the includes brought in.
That last step is the one worth memorizing: your main file always wins. Whatever job definitions, variables, or defaults you write directly in .gitlab-ci.yml override anything an included file set for the same key.
The merge itself is a deep merge for maps. If an included file defines a job with three keys and your main file redefines that job with one different key, GitLab merges them into one job with four keys, four keys, with the overlapping one taking your main file’s value. This is what makes the “base template plus local override” pattern work at all: you define a job skeleton once in a shared file, then tweak just the variables or script section per project without rewriting the whole job.
The 150-include ceiling. GitLab enforces a default limit of 150 total includes per pipeline, counting both direct includes and everything pulled in through nested includes. Large organizations running deep include graphs across dozens of shared templates hit this more often than you’d expect, and it fails the pipeline outright rather than warning you in advance.
Arrays don’t merge the same way maps do, and this catches people off guard constantly. If an included file defines a script array with three steps, you cannot override “just the second step.” Redefining script in your main file replaces the entire array. There’s no partial patching, no index targeting, nothing like a JSON merge patch for list items. If you need to keep two of three steps from a shared job and change the third, you have to redeclare all three yourself.
The practical fallout shows up in a few predictable places:
- Variables: a variable defined in an included file gets fully replaced if your main file redefines the same variable key, but untouched variables from the include still apply.
- Default sections: a global
default:block from an include applies to every job that doesn’t explicitly override it, so a shareddefault: image:can quietly control dozens of jobs across a project. - Job names: if your main file defines a job with the exact same name as one in an included file, your version replaces it entirely rather than merging fields, since GitLab treats a duplicate job name as a full override, not a partial one.
How Do Nested Includes and Duplicate Files Get Handled?
Nested includes work exactly like you’d hope: if File A includes File B, and File B includes File C, GitLab resolves File C first, merges it into File B, then merges that combined result into File A, then finally merges File A into your main pipeline. It’s recursive, and it’s consistent with the top-level merge order described above.
Duplicate includes are handled more gracefully than most people assume. If two different branches of your include tree both reference the same file, GitLab doesn’t error out. That said, relying on this is a bad habit. A duplicate include that “happens to work” today can produce a confusing override the moment someone reorders the include array or edits one of the two paths pulling in the same file.
The include ceiling is worth restating here because nesting is exactly what pushes teams over it. The 150-include limit counts every direct and nested include combined, so a project with 10 direct includes, each of which nests 15 more, has already used its entire budget before the main file is even considered.
A few habits keep you well clear of that ceiling and make the include graph easier for a new hire to reason about:
- Flatten deeply nested chains where you can; three levels of nesting is usually a sign the templates need consolidating, not more layers.
- Audit your include tree periodically rather than assuming it stayed small, since shared templates that include other shared templates tend to grow without anyone noticing.
- Avoid pulling the same file through two different paths in the include array just because it was convenient at the time.
- Self-managed GitLab instances can raise or lower this limit through administration settings, but GitLab.com SaaS users are stuck with the default and need to design around it.
Can You Use Variables Inside an Include Statement?
Mostly no, and the reason trips up a lot of people the first time they try it. GitLab processes the include keyword before any job runs, which means job-level variables defined inside .gitlab-ci.yml jobs are not available yet when GitLab is deciding what to include. You can’t write include: local: "configs/${ENVIRONMENT}.yml" and expect a job-level ENVIRONMENT variable to resolve it.
What does work is a narrower set of predefined CI/CD variables and pipeline-level variables that exist before job processing starts, things like $CI_COMMIT_REF_NAME or variables set at the pipeline trigger level (through the API, a scheduled pipeline, or manual pipeline run). These are evaluated early enough to actually affect which files get pulled in.
Pro Tip: If you need a genuinely dynamic include path based on branch name or environment, don’t fight the include keyword for it. Pair a small set of rules:if conditions (covered next) with separate, explicitly named include files instead of trying to interpolate a variable into a path string. It’s more verbose, but it’s something a teammate can actually read and debug six months from now.
A safer pattern than variable interpolation is pinning your ref explicitly on project includes and letting the pipeline’s built-in variables handle anything conditional through rules, rather than through string substitution in the path itself. The common pitfall is assuming that because a variable is visible in job logs, it must also be visible during include resolution. Those are two different phases of pipeline processing, and the gap between them is where most “why isn’t my include working” tickets come from.
How Do You Make Includes Conditional With Rules?
Combining include with rules lets a pipeline assemble itself differently depending on branch, environment, or which files changed, instead of running the same fixed set of jobs every time. GitLab supports three rule types for this, each suited to a different kind of condition.
rules:ifincludes a file based on a CI/CD variable’s value. This is the most general-purpose option: include a heavier test suite only onmain, include a deploy template only when$CI_COMMIT_TAGis set, or swap in a different security template for scheduled pipelines versus merge request pipelines.rules:existsincludes a file only if certain paths exist in the repository. This is handy for a shared template that should only activate if, say, aDockerfileor aterraform/directory is present, so one central pipeline can serve multiple project shapes without every project needing its own bespoke config. Be careful with cross-project includes here:existschecks paths relative to the project running the pipeline, not the project the include file came from, which is a common source of confusion.rules:changesincludes a file based on which paths changed in the current commit or merge request. This is the backbone of path-scoped pipelines in a monorepo: only pull in the frontend test template when files underfrontend/changed, only pull in the infrastructure template whenterraform/changed. Devopsaitoolkit has covered this pattern in more depth in a dedicated look at path-scoped pipelines with rules:changes.
Here’s a compact example combining two of these:
include:
- local: '.gitlab/ci/security-scan.yml'
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- local: '.gitlab/ci/terraform.yml'
rules:
- changes:
- 'terraform/**/*'
Using rules with includes genuinely improves runner efficiency, since expensive jobs only load into the pipeline when the conditions that justify running them are actually met, which matters if you’re paying for compute minutes on shared runners.
Pro Tip: Resist the urge to stack more than two or three rule conditions on a single include entry. GitLab’s own guidance on job rules notes that overusing conditional logic makes pipelines harder to reason about, and a debugging session where you can’t tell why a template didn’t load is a bad way to spend an afternoon. If an include needs four conditions to decide whether it applies, that’s usually a sign it should be split into two separate, simpler includes.
How Do Wildcards and Branch Rules Work for Local Includes?
include:local supports wildcard patterns for pulling in multiple files at once without listing every path by hand. GitLab recognizes * for matching files within a single directory level and ** for matching recursively across subdirectories. A pattern like configs/**.yml picks up every YAML file nested at any depth under configs/, which is documented directly in GitLab’s include reference and works well for a folder of independently maintained job definitions that don’t need to be listed one by one.
A few rules govern how these resolve in practice:
include:localalways resolves relative to the branch and ref of the file that contains the include statement, not the branch that triggered the pipeline in some other sense. If your CI file lives onmainand includes a local path, GitLab looks for that path onmain.- This means a local include cannot reach across branches. If a file only exists on a feature branch and your pipeline configuration is being read from
main, the include fails. Useinclude:projectwith an explicitrefif you genuinely need to pull configuration from a different branch or repository. - Git submodule paths are a frequent trap here: a local include path that looks correct in your working directory can still fail in CI if that path only resolves correctly once submodules are checked out, and plain
include:localdoesn’t trigger a submodule checkout on its own.
Why Do Includes Fail and How Do You Debug Them?
Three error patterns cover most include failures, and each one has a distinct fix.
“File not found” for a local include almost always means either a typo in the path, or the file genuinely doesn’t exist on the branch/ref that’s currently running. Remember that include:local resolves against the ref containing the include statement, so a file that exists on your feature branch but not yet on main will fail once merged if the path assumption was wrong.
Remote fetch failures happen when the HTTP(S) endpoint in a remote include is unreachable, returns a non-200 response, or times out. Because remote includes depend on a server outside GitLab’s control, these failures are often intermittent and hard to reproduce locally, which is exactly why they’re the include type worth avoiding when a project include can do the same job.
Hitting the maximum includes limit produces a pipeline error rather than a partial pipeline. If you’re systematically adding shared templates across many projects, this is worth monitoring proactively rather than discovering it the day a release pipeline won’t start.
The single most useful habit for catching all three before they hit main is running CI Lint against your merged configuration. GitLab’s CI Lint tool renders the fully merged YAML, exactly what would run, so you can see the actual result of your include chain instead of guessing. This matters more than it sounds like it should, because debugging nested includes is fundamentally harder than debugging a flat file: the YAML that fails isn’t the YAML you’re looking at in your editor, it’s the merged output several layers downstream.
A short, repeatable debugging checklist:
- Run CI Lint or trigger a pipeline render before merging any change to a shared include file.
- Add a comment at the top of each included file naming its purpose and which projects depend on it, since there’s no built-in “trace this include’s origin” tool and comments are the closest low-cost substitute.
- Test include changes on a feature branch pipeline first, ideally against a pinned ref rather than
HEAD, so a bad change doesn’t propagate to every consuming project simultaneously. - Roll out changes to shared templates in stages, one or two consuming projects first, if the template is used broadly across an organization.
Pro Tip: Keep a scratch project (or a feature branch of one) whose only job is to include your shared templates and run CI Lint against them. It turns “did my template change break anything” from a guess into a five-second check before you tag a new version.
What Are Production-Ready Include Patterns Worth Copying?
Four patterns cover most of what production teams actually need from includes. Each one solves a different reuse problem, and most mature GitLab setups end up combining two or three of them at once.

1. Central components repo with pinned refs. One dedicated project holds versioned, reusable job definitions (or CI/CD Catalog components), and every consuming project references them through include:project with an explicit ref set to a tag, never main.
include:
- project: 'platform/ci-catalog'
ref: 'v3.1.0'
file: '/jobs/build-and-push.yml'
This is the pattern Devopsaitoolkit’s guide to building a reusable components catalog covers in more depth, and it’s the one that scales best once you’re past a handful of teams, because upgrading the shared template is a version bump, not a mass find-and-replace across dozens of repos.
2. Base template plus local override. A shared file defines a job skeleton; the consuming project’s .gitlab-ci.yml redefines just the parts that differ.
# .gitlab/ci/base-deploy.yml (shared)
deploy:
stage: deploy
image: alpine:3.19
variables:
DEPLOY_ENV: "staging"
script:
- ./deploy.sh
# .gitlab-ci.yml (consuming project)
include:
- local: '.gitlab/ci/base-deploy.yml'
deploy:
variables:
DEPLOY_ENV: "production"
Safe to override: variables, script, image, rules. Risky to override: the top-level stages key, since redefining stages in the main file replaces the entire stage list rather than merging it, and a shared job referencing a stage that no longer exists in your redefined list will fail the pipeline outright.
3. Conditionally included expensive jobs. Security scans, load tests, and other slow or costly jobs get their own include file, gated behind rules:exists or rules:changes so they only run when relevant.
include:
- local: '.gitlab/ci/security-scan.yml'
rules:
- exists:
- 'Dockerfile'
4. Monorepo path-scoped includes. A monorepo with several independent services groups its CI config by directory, using wildcards to pull in all job definitions under a service’s folder and rules:changes to activate only the services actually touched by a given commit.
include:
- local: 'services/**/ci.yml'
rules:
- changes:
- 'services/*/src/**/*'
| Pattern | Best for | Main risk to watch |
|---|---|---|
| Central components repo | Multi-team orgs standardizing shared jobs | Unpinned refs breaking consumers on template changes |
| Base template + override | Single project needing per-environment variation | Overriding stages and breaking job references |
| Conditional expensive jobs | Controlling runner cost on slow scans/tests | Overly complex rules reducing transparency |
| Monorepo path-scoped includes | Multi-service repos with independent pipelines | Wildcard patterns matching more files than intended |
Each of these is composable. A components repo pattern and a monorepo path-scoped pattern aren’t mutually exclusive; plenty of platform teams run both at once, one for cross-organization standards, the other for internal service boundaries.
What Governance Habits Keep Include-Based Pipelines Maintainable?
Keep include trees shallow. Two or three levels of nesting is manageable; five or six is a debugging session waiting to happen. Prefer explicit ordering in your include array over relying on merge behavior you have to look up every time, and pin refs on every shared project include rather than tracking a branch that can change under you.
Add CI Lint or a rendered-YAML check as a required step in pre-merge pipelines for any repository that maintains shared include files, not just an optional habit for when something breaks. And keep a short, written review checklist for anyone adding a new shared include: does it need a pinned ref, does it push the project closer to the include limit, does it duplicate something that already exists elsewhere in the include graph. A checklist that takes ninety seconds to run through catches most of the mistakes that otherwise surface three weeks later as a confusing production pipeline failure.
When Do Includes Add More Complexity Than They’re Worth?
Includes earn their complexity budget when multiple projects genuinely share behavior. They don’t when a small project’s entire pipeline fits comfortably in one screen of YAML. Forcing a single-repo project into a base-template-plus-override structure just because it’s the “proper” pattern usually makes the pipeline harder for a new teammate to read, not easier.
Highly dynamic pipelines, where the job list itself depends on runtime discovery rather than a fixed set of conditions, often fit generated YAML and dynamic child pipelines better than a deep include graph. The real judgment call is balancing DRY configuration against discoverability. A new hire should be able to find where a job’s behavior actually comes from without tracing through four nested include files first.
— James
Get Help Building a Modular GitLab CI Pipeline
Reading through merge precedence and wildcard rules is one thing. Untangling an include graph that’s grown across forty repositories over three years is another. Help is available for production-focused audits and playbooks reviewed by systems engineers, designed to give platform teams a safe, staged path to modular pipelines instead of a risky rewrite.

A Terraform / IaC Audit applies directly here if your include-based pipelines drive infrastructure changes, since a tangled include graph and a tangled Terraform module structure tend to cause the same kind of production incidents. For teams that need broader support, from mapping the current include tree to designing a components catalog with pinned refs to training the team on rules-based conditional includes, hourly consulting starting at $150 per hour covers everything from a focused review to hands-on implementation work. If you’d rather start with self-serve resources first, the Pro plan at $19 per month gives you ongoing access to Devopsaitoolkit’s prompt libraries and troubleshooting guides for GitLab, Kubernetes, and the rest of the production stack. Book a consult or check current pricing before your next shared template rollout.
FAQ
What Is the Difference Between Include and Trigger in GitLab CI?
include merges external YAML into your current pipeline before it runs, producing one combined pipeline with all included jobs. trigger launches a separate, downstream pipeline (in the same project or a different one) that runs independently with its own status. Use include for shared job definitions and trigger for orchestrating genuinely separate pipelines, such as a parent pipeline kicking off a child or cross-project pipeline.
What Are GitLab CI/CD Components?
Components are versioned, reusable job definitions published to GitLab’s CI/CD Catalog and pulled in with include:component, similar in spirit to a project include but with a defined interface for inputs. They’re one of five include source types GitLab supports, alongside local, project, remote, and template includes.
What Do You Need to Use GitLab CI?
At minimum, a GitLab project with a .gitlab-ci.yml file at its root and an available runner (GitLab-hosted or self-managed) to execute jobs. Beyond that, the include keyword lets you pull in shared configuration instead of writing every job from scratch in each project.
What Is a GitLab CI Pipeline?
A GitLab CI pipeline is the sequence of stages and jobs GitLab runs based on your .gitlab-ci.yml configuration, triggered by events like a push, merge request, or schedule. When that configuration uses include, the pipeline that actually runs is the merged result of every included file combined with your main file, evaluated in the order GitLab’s documentation defines.
Why Did My Pipeline Fail With a “Maximum Includes” Error?
You’ve exceeded GitLab’s default limit of 150 total includes per pipeline, counting both direct includes and everything nested underneath them. Flatten your include graph, remove duplicate paths pulling in the same file, or, on self-managed instances, adjust the limit through administration settings.
Recommended
- GitLab Pipeline Automation Examples: 2026 Practical Guide
- A GitLab CI rules:if Cookbook Built on Predefined Variables
- GitLab CI + Terraform: A Safe, Reviewable Infrastructure
- GitLab CI Variables and Environments Hygiene: A Practical
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.