Skip to main content

Includes

Depending on what you're doing, the number of steps, rule variations, etc., there will come a time when the .gitlab-ci.yml file will get quite large.

We can separate the code into different yaml files and keep .gitlab-ci.yml clean, just making inclusions and declaring the main things.

I recommend keeping the default block always at the root to avoid overwriting. If yaml is read from top to bottom, then do the includes afterwards to avoid problems.

Include Documentation

default:
tags:
- general
image: node:22-alpine

stages:
- check # New stage
- build
- deploy

I'm going to create a folder tree to divide the stages.

❯ tree cicd
cicd
β”œβ”€β”€ globals.yaml
β”œβ”€β”€ build
β”‚ └── build.yaml
β”œβ”€β”€ check
β”‚ └── check.yaml
└── deploy
└── deploy.yaml

How the files look.

❯ cat cicd/check/check.yaml
.check:
stage: check
before_script:
- env
- npm ci
artifacts:
when: always
expire_in: "3 months"

# THIS WILL GO INSIDE globals.yaml as we use it for various stages
# .rules-only-main-mr:
# rules:
# - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
# when: always

unit-test:
extends:
- .check
- .rules-only-main-mr
script:
- npm test
artifacts:
reports:
junit: reports/junit.xml

lint-test:
extends: [.check,.rules-only-main-mr]
script:
- npm run lint
artifacts:
reports:
codequality: gl-codequality.json

vulnerability-test:
extends: [.check,.rules-only-main-mr]
script:
- npm audit --audit-level=high --json > vulnerability-report.json
artifacts:
paths:
- vulnerability-report.json

❯ cat cicd/build/build.yaml
# THIS WILL GO INSIDE globals.yaml as we use it for various stages
# .rules-only-main-mr:
# rules:
# - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
# when: always

build:
stage: build
extends: [.rules-only-main-mr]
script:
- node --version
- npm --version
- npm ci
- npm run build
artifacts:
when: on_success
expire_in: "1 hour"
paths:
- build/

❯ cat cicd/deploy/deploy.yaml
deploy:
stage: deploy
image: alpine
script:
- echo "Deploy starting with TOKEN=$TOKEN"
environment:
name: $DEPLOY_ENV
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
variables:
DEPLOY_ENV: develop
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_ENV: production

Our stage would look like this then, so we could call all these files.

default:
tags:
- general
image: node:22-alpine

stages:
- check # New stage
- build
- deploy

include:
- local: '/cicd/globals.yaml' # A specific file
- local: '/cicd/check/check.yaml' # A specific file
- local: '/cicd/build/*.yaml' # Any .yaml file in the build folder
- local: '/cicd/deploy/*.yaml'

I preferred to separate into folders by stage because in the future I can decide to separate each of the jobs into different yaml files. So far our jobs are simple, but there can be much larger jobs in the future.

We can still use wildcards to make it easier.

default:
tags:
- general
image: node:22-alpine

stages:
- check
- build
- deploy

# include:
# - local: '/cicd/**/*.yaml' # This DOESN'T WORK

include: # This works
- 'cicd/globals.yaml'
- 'cicd/**/*.yaml'

Be careful with this gotcha there that the official documentation itself confuses us.

Includes From Other Repositories​

We can do includes directly from another repository if you read the documentation well. It must have crossed your mind to have a central repository that we can reuse code instead of developing for each repository all the jobs.

Advantages:

  • Standardization: Different teams follow the same pipelines (build, test, deploy).
  • DRY (Don't Repeat Yourself): Centralizes templates and reduces repetition.
  • Centralized maintenance: Updating a pipeline (e.g., new security rule) affects all projects automatically.
  • Security/control: You can control exactly what is executed in projects, important in more regulated environments.

Disadvantages:

  • Coupling: All projects depend on a single repository. A wrong change breaks everything.
  • Debugging complexity
  • Less autonomy: Teams may complain about not being able to easily adapt the pipeline to their needs.

Common use cases:

  • Companies with many projects and teams that need to follow standards (e.g., microservices).
  • Organizations focused on compliance/security.
  • Internal platforms like internal PaaS, where the pipeline is part of the platform.

You can adopt a hybrid approach: each project maintains a simple .gitlab-ci.yml, which includes templates from the central repository, but allows overwriting or extending jobs when necessary. This offers flexibility without giving up standardization.

Thinking like LEGO pieces, it's possible to assemble custom pipelines from reusable blocks. Additionally, you can create repository templates β€” for example, a boilerplate for Node projects already configured with the standard Node pipeline, and the same for Python projects.

What would such a repository structure look like? It's just a suggestion, not a rule.

ci-templates/
β”œβ”€β”€ templates/
β”‚ β”œβ”€β”€ node/
β”‚ β”‚ β”œβ”€β”€ install.yml
β”‚ β”‚ β”œβ”€β”€ test.yml
β”‚ β”‚ └── build.yml
β”‚ β”œβ”€β”€ python/
β”‚ β”‚ β”œβ”€β”€ lint.yml
β”‚ β”‚ β”œβ”€β”€ test.yml
β”‚ β”‚ └── package.yml
β”‚ └── common/
β”‚ β”œβ”€β”€ security-scan.yml
β”‚ β”œβ”€β”€ docker-build.yml
β”‚ └── notify-slack.yml
β”œβ”€β”€ full-pipelines/
β”‚ β”œβ”€β”€ node.yml # Includes node + common templates
β”‚ β”œβ”€β”€ python.yml
β”‚ └── microservice.yml

At the end of the project we can think about this, because at this moment keeping the pipeline together with the repository facilitates our evolution. This idea is very interesting especially for companies that have a team just for pipelines.

Let's imagine we have a centralized repository with jobs in platform/ci-templates and another repository that will leverage this.

include:
- project: 'platform/ci-templates'
file: '/templates/node/install.yml'
ref: main

- project: 'platform/ci-templates'
file: '/templates/node/test.yml'
ref: main

- project: 'platform/ci-templates'
file: '/templates/common/docker-build.yml'
ref: main

# Example of optional override (if you want to customize something)
custom_test:
extends: .node_test_template
variables:
RUN_TESTS: "true"
script:
- echo "Running tests with coverage"
- npm run test:coverage

But for this to work, a development standard is necessary.