DEV Community

Cover image for Scaling GitLab CI/CD with reusable templates
Yoriiis
Yoriiis

Posted on

Scaling GitLab CI/CD with reusable templates

Managing dozens of projects relying on GitLab CI/CD templates can be a challenge.

At Prisma Media, on front-end libraries and some Symfony apps, we had a mix of configurations that were inconsistent, hard to maintain, and often not optimized. This article explains how we centralized our GitLab CI/CD pipelines into reusable, modular templates, and how you can adopt a similar approach.


Why a dedicated CI templates repository

The problem: scattered CI configurations

Before reusable templates:

  • Each project had its own .gitlab-ci.yml with a lot of duplication
  • Jobs were inconsistent across projects (npm ci vs npm install, different caching strategies)
  • Pipelines were often slow and hard to debug
  • Documentation was minimal or missing
  • Synchronizes updates were complicated to deploy

Our solution: modular templates

We created a dedicated repository (ci) hosting modular GitLab CI templates. Each template:

  • Handles a single task (install dependencies, build, run tests, publish)
  • Is optimized (cache, artifacts, retries)
  • Is fully documented with usage examples and configurable variables
  • Uses semantic versioning (v1, v2) so projects can upgrade at their own pace

Our templates are stage-agnostic, with no predefined needs or stages, so consumer projects keep full control over their pipeline flow.

Templates also support different working directory, depending on where NPM commands are executed.

💡 Tip: prefix template names (e.g. .npm-install) to avoid conflicts with jobs in the consuming project.


Our template catalog

Template Purpose Key features
npm-install Install dependencies Cache optimization
npm-build Build application Custom command support
npm-qa Run linter Custom command support
npm-unit-test Run unit tests JUnit reports integration
npm-pack Create .tgz package Artifact output
npm-publish Publish to registry Manual trigger
create-tag Git tag creation Version auto-detection

Each template is independent and hosted in a dedicated directory with its own documentation.


Pipeline overview

GitLab pipeline


Shared configuration

All templates extend a common.yaml to centralize variables, cache, and default behaviors. This ensures consistency across templates and reduces duplication.

variables:
  MAIN_BRANCH: main
  NODE_IMAGE: node:22-alpine
  WORKING_DIRECTORY: ./

.default:
  variables:
    FF_USE_FASTZIP: 'true' # Optimize cache/artifact compression
  interruptible: true
  artifacts:
    expire_in: 3 days
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure

.cache-npm:
  cache:
    key:
      files:
        - ${WORKING_DIRECTORY}/package-lock.json
    paths:
      - ${WORKING_DIRECTORY}/node_modules
    policy: pull
Enter fullscreen mode Exit fullscreen mode

💡 Note on branch rules:
All our templates use $MAIN_BRANCH in rules instead of $CI_DEFAULT_BRANCH.
This ensures the pipeline behaves consistently relative to the versioned branch (v1, v2, etc.) rather than the repository's default branch, which may change over time.


Selected CI templates

Here are a few representative templates; all templates are available in the GitHub repository.

npm-install: install dependencies

.npm-install:
  extends:
    - .default
    - .cache-npm
  image: ${NODE_IMAGE}
  cache:
    policy: pull-push
  script:
    - cd ${WORKING_DIRECTORY}
    - npm ci
  rules:
    - if: >
        $CI_PIPELINE_SOURCE == 'merge_request_event'
        || $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

npm-build: build application

.npm-build:
  variables:
    COMMAND: 'npm run build'
  extends:
    - .default
    - .cache-npm
  image: ${NODE_IMAGE}
  script:
    - cd ${WORKING_DIRECTORY}
    - ${COMMAND}
  rules:
    - if: >
        $CI_PIPELINE_SOURCE == 'merge_request_event'
        || $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

npm-qa: run linter (Biome)

.npm-qa:
  variables:
    COMMAND: 'npm run test:qa'
  extends:
    - .default
    - .cache-npm
  image: ${NODE_IMAGE}
  script:
    - cd ${WORKING_DIRECTORY}
    - ${COMMAND}
  rules:
    - if: >
        $CI_PIPELINE_SOURCE == 'merge_request_event'
        || $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

npm-unit-test: run unit tests (Jest)

.npm-unit-test:
  variables:
    COMMAND: 'npm run test:unit'
  extends:
    - .default
    - .cache-npm
  image: ${NODE_IMAGE}
  script:
    - cd ${WORKING_DIRECTORY}
    - ${COMMAND}
  artifacts:
    paths:
      - junit.xml
    reports:
      junit: junit.xml
  rules:
    - if: >
        $CI_PIPELINE_SOURCE == 'merge_request_event'
        || $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

💡 The job improves unit test report in GitLab merge request with jest-junit package and GitLab unit test reports.

npm-pack: generate local package

.npm-pack:
  variables:
    PACKAGES_DIR: 'npm-pack-packages'
  extends:
    - .default
    - .cache-npm
  image: ${NODE_IMAGE}
  script:
    - cd ${WORKING_DIRECTORY}
    - mkdir $PACKAGES_DIR
    - PACKAGE_TGZ=$(npm pack)
    - mv "$PACKAGE_TGZ" $PACKAGES_DIR
  artifacts:
    paths:
      - $WORKING_DIRECTORY/$PACKAGES_DIR
  when: manual
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
Enter fullscreen mode Exit fullscreen mode

💡 Use this template for local testing before publishing or integration validation. The local NPM package can be installed in your project for testing.

npm-publish: publish to NPM registry

.npm-publish:
  extends:
    - .default
  image: ${NODE_IMAGE}
  script:
    - npm publish
  allow_failure: false
  when: manual
  rules:
    - if: $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

💡 To publish to a private registry, update the .npmrc file with the registry URL and authentication token before running the publish command.

⚠ As of September 2025, NPM enforces shorter token lifetimes for write operations and plans to retire legacy tokens. To future-proof your CI, consider migrating to Trusted Publishing (OIDC).

create-tag: create Git tag

.create-tag:
  extends:
    - .default
  image: node:22-alpine
  before_script:
    - apk add --no-cache git
    - git config --global user.email "gitlab@gitlab.com"
    - git config --global user.name "GitLab CI"
  script:
    - VERSION_NUMBER=$(npm pkg get version | xargs echo)
    - git tag $VERSION_NUMBER
    - git remote set-url origin "${CI_SERVER_PROTOCOL}://oauth2:${GITLAB_TOKEN}@${CI_SERVER_HOST}:${CI_SERVER_PORT}/$CI_PROJECT_PATH.git"
    - git push origin $VERSION_NUMBER
  allow_failure: false
  rules:
    - if: $CI_COMMIT_REF_NAME == $MAIN_BRANCH
Enter fullscreen mode Exit fullscreen mode

💡 Requires a Project Access Token in GITLAB_TOKEN variable.


Final consumer usage

In the consumer project, the .gitlab-ci.yml becomes very simple. The project only imports the templates it needs and defines the dependencies between jobs itself. This keeps the pipeline clean, flexible, and easy to maintain.

include:
  - { project: 'user/ci', file: 'templates.yaml', ref: 'v1' }

workflow:
  rules:
    - if: $CI_COMMIT_TAG # Disable tag pipeline
      when: never
    - when: always

variables:
  WORKING_DIRECTORY: '.'

npm-install:
  extends: .npm-install

npm-build:
  extends: .npm-build
  needs: [npm-install]

npm-qa:
  extends: .npm-qa
  needs: [npm-install]

npm-unit-test:
  extends: .npm-unit-test
  needs: [npm-install]

npm-pack:
  extends: .npm-pack
  needs: [npm-build]

npm-publish:
  extends: .npm-publish
  needs: [npm-build, npm-qa, npm-unit-test]

create-tag:
  extends: .create-tag
  needs: [npm-publish]
Enter fullscreen mode Exit fullscreen mode

Versioning and evolution

We tag template releases (v1, v2, etc.) so teams can upgrade on their schedule. Each release includes a changelog and migration notes for breaking changes. We also use code owners for approval deployment to secure our templates.

Planned improvements:

  • Consider GitLab CI Components in the future for even better reuse
  • Extend templates for other ecosystems (Python, PHP) as needed

Results and lessons learned

For the projects that adopted the templates we observed:

  • Significant reduction in per-repo CI maintenance effort
  • Faster onboarding for new projects and engineers
  • More consistent build and publish behavior across teams

Operational notes

  • Template bugs can affect consumers widely, but fixing a template fixes it for all consumers at once
  • Adoption takes time: provide migration guides and a short period of backward compatibility

Conclusion

Centralizing GitLab CI templates gives us modular, documented, and versioned building blocks for pipelines. It reduces duplicated effort, enforces sensible defaults, and lets teams focus on product work rather than CI boilerplate.

If you'd like to try the repository and examples, the code is available on GitHub:

Discover our reusable CI templates on GitHub

What's your current CI approach? Have you centralized templates, or do you prefer per-repo pipelines? I'd be interested to read your experience.

Resources


Top comments (0)