TL;DR: if you run more than a couple of projects on GitLab, stop copy-pasting
jobs between .gitlab-ci.yml files. GitLab CI/CD Components let you define a
job once—parameterized, versioned, documented—and include: it from each
project that needs it. Fix a bug in the component, tag a release, and the
consumers pick it up on their next deliberate upgrade. That’s the whole post. The
rest is how I set this up on my self-hosted instance, what a component looks
like, and the conventions that make the system pleasant instead of merely
possible.
We tell each other constantly that application code should be DRY—Don’t Repeat Yourself, the principle that every piece of knowledge should live in a single place. Then we open the CI config and copy-paste like it’s a middle-school book report. Your pipeline config is code. It deserves the same discipline.
The Smell: Copy-Pasted Pipelines
For years, a new project of mine started the same way: open the last project’s
.gitlab-ci.yml, copy the file over, tweak the paths, delete the jobs that
don’t apply. Fast, familiar, and it works—on day one.
The trouble, however, is what happens on day ninety. Each copy starts drifting.
One repo, for example, gets a caching fix because its pipeline was slow that
week. Another gets a new Node version because a dependency demanded it. A third
gets a rules: tweak I don’t fully remember making. None of these improvements
travel to the other copies, because there’s no mechanism for them to travel. Six
months later I’m staring at two nearly-identical deploy jobs asking, “why does
this repo deploy differently?”—and the answer is that I didn’t decide anything.
The two jobs just eroded apart.
Copy-pasted pipelines drift the way family recipes drift. Grandma’s carrot cake recipe gets photocopied for five relatives. Over the years, one aunt pencils in “350°, not 375°—mine kept burning.” An uncle swaps the oil for butter. A cousin doubles the walnuts. Each edit improved that copy. But now there are five recipes, no two cakes match, and when someone finally figures out the frosting splits because of a typo in the original, there’s no way to fix it once. You have to chase down five photocopies, and you’ll find four.
Copy-pasted CI is that recipe drawer: each bug fix is a manhunt, and sooner or later the manhunt misses a copy.
What a CI/CD Component Is
GitLab’s answer is the
CI/CD component: a reusable pipeline
unit—usually one job, sometimes a small family of them—published from a
dedicated project and consumed with an include: statement. Components went GA
(generally available, meaning production-ready and supported) in GitLab 17.0,
after incubating as an experiment back in 16.x.
Let’s start from the consuming side, with a working include from my instance rather than a sketch:
include: - component: $CI_SERVER_FQDN/shared/ci-components/lint-biome/lint-biome@1.0.2 inputs: node_version: "22"Three things worth decoding in that one line, because each of them cost me a few minutes of confusion:
$CI_SERVER_FQDNis a predefined variable holding your GitLab instance’s fully qualified domain name—the complete hostname,gitlab.example.comor whatever yours resolves to. Using the variable instead of hardcoding the host means the config survives if the instance ever moves, and GitLab’s docs recommend it.- The path is the full project path, then the component name. The component
name is the basename of a file under
templates/in that project—not the project name itself. Since mylint-biomeproject containstemplates/lint-biome.yml, the path doubles the name:.../lint-biome/lint-biome. It looks like a stutter. The stutter is correct. @1.0.2pins the version. You can pin a commit SHA, a semver tag (semantic versioning:MAJOR.MINOR.PATCH, where the number itself tells you how big the change is), or a branch, and GitLab resolves them in that order of specificity. Shortcuts exist too:@1or@1.2, for example, grab the latest matching release, and@~latestgrabs the newest one outright—though@~latestonly resolves for components published to the CI/CD Catalog (more on that below).
The pin is more important than it looks. Without one, a change to the shared
component would ripple into each pipeline that includes it, all at once, on a
Tuesday. Pinning a tag is like ordering the 3rd edition of the textbook for
your class: the class’s page numbers agree, and they keep agreeing all semester
no matter what the publisher does next. @~latest is “whatever’s on the shelf
today”—fine for a personal experiment, hair-raising for anything you’d like to
keep working.
The component itself is a normal YAML file with a spec: header declaring its
inputs, a --- separator, and then the templated job body. Inputs are
interpolated with $[[ inputs.name ]], and they can carry defaults,
descriptions, types (string, number, boolean, array), even an options enum or
a regex constraint. Inputs are what turn a copied job into a parameterized
one—pass the Node version, the deploy target, or the working directory, for
example, and one definition covers a dozen repos.
Building a Shared “Common” Home
My original plan was one big “common” project holding all the components. I
ended up somewhere slightly different: a subgroup on my instance—call it
shared/ci-components—holding one small project per component. Each
project is small and pretty much identical in shape—a templates/<name>.yml,
a VERSION file, a CHANGELOG, a README, and a little .gitlab-ci.yml that
yamllints the template and runs release-cli (GitLab’s release-publishing tool)
whenever I push a semver tag. So the component projects run through CI too,
which I think is pleasingly recursive.
Right now the subgroup holds, for example, lint-biome, unit-tests-vitest,
e2e-playwright, lighthouse-audit, typecheck-svelte, auto-tag, a deploy
component per hosting target, a sourcemap-upload component for error
monitoring, and a family of Go components (lint, test, vulnerability check,
release, and friends)—one project per concern.
The mental model I’ve settled on is a power-tool battery platform. When you buy into DeWalt or Ryobi, the drill is almost incidental—what you’re buying is one battery specification that dozens of tools share. The battery’s interface (voltage, latch shape, charge connector) is fixed and documented; the tools vary wildly. You don’t hand-wire a custom power supply into each new tool, and when the battery design improves, the tools on the platform benefit the moment you swap in the new pack. My component subgroup is the charging shelf in the garage, each repo is a tool, and the inputs are the latch shape.
And that gives me the rule that makes the system work: check the common subgroup before hand-rolling a job. Reuse first; write a new component when none of the existing ones fit. The rule sounds obvious written down, but in practice reuse-first takes a lot more discipline than it sounds, because copy-pasting the last project’s job is thirty seconds faster in the moment—the same way the bargain drill with a one-off battery is less expensive right up until you have four different chargers on the bench.
Now let’s look at the producing side. This is lint-biome@1.0.2, lightly
trimmed—Biome is my default linter/formatter for Node
projects these days:
spec: inputs: stage: type: string default: "lint" job_name: type: string default: "lint-biome" node_version: type: string default: "22" description: "Node.js major version (matches node:<v>-bookworm image tag)" install_command: type: string default: "corepack enable && pnpm config set store-dir .pnpm-store && pnpm install --frozen-lockfile" lint_command: type: string default: "pnpm lint" allow_failure: type: boolean default: false---"$[[ inputs.job_name ]]": stage: $[[ inputs.stage ]] image: node:$[[ inputs.node_version ]]-bookworm cache: key: files: - pnpm-lock.yaml paths: # Only the pnpm store: node_modules is hardlinks into the store, and # zip archiving breaks hardlinks, so caching both stored every package # twice on the runner. `pnpm install --frozen-lockfile` re-links from # the warm store in seconds. - .pnpm-store before_script: - sh -c "$[[ inputs.install_command ]]" script: - sh -c "$[[ inputs.lint_command ]]" rules: - if: '$CI_COMMIT_TAG' when: never - when: on_success allow_failure: $[[ inputs.allow_failure ]]Notice the cache comment. I learned that lesson the slow way—caching
node_modules alongside the pnpm store, wondering why the cache was
enormous—and now the lesson lives in the component, permanently, for each
project that includes it. That comment is the recipe card fixed at the source
instead of five photocopies fixed by hand. Also notice the default
lint_command is just pnpm lint: in my framework repos that script is really
a hybrid—Biome mangled .svelte markup the one time I let it try, and I don’t
trust its experimental .astro support yet—so ESLint and Prettier keep those
lanes. That split got its own writeup.
Designing Good Components
Having a shared home doesn’t automatically make the components good. A few heuristics I try to hold myself to:
- Sensible defaults, few required inputs. The common case should be a
two-line include. Each input in
lint-biomehas a default, for example; most consumers pass justnode_version, and plenty pass no inputs at all. - One job, one purpose. Compose pipelines from small pieces instead of shipping one mega-component with a dozen toggles. My website’s pipeline is lint → build → test → audit → deploy, and each arrow is its own component.
- Treat the input list as the public API. Document each input, and think
before adding one, because removing it later breaks any consumer that
passes it. My Go components are the best evidence I have for this: they each
carry
working_dir,job_name_suffix, andgoflagsinputs, which is exactly enough generalization for one component to serve both single-module repos andgo.workmonorepos. If your modules live in private repos, bake the authentication (GOPRIVATE plus aCI_JOB_TOKENgit rewrite) directly into the job, so consumers get private module access with no per-repo setup. The consumer shouldn’t have to know how the battery is wired inside. - Tag releases; let consumers upgrade deliberately. Each component project
auto-publishes a release when I push a
X.Y.Ztag. Consumers move their pin when they’re ready, not when I’m ready.
Small pieces also compose in ways I didn’t plan for. This very website includes
deploy-cloudflare-pages@1.0.0 twice—once inside the shared pipeline template
for the production deploy, and once directly with job_name: deploy-preview and
branch: $CI_COMMIT_BRANCH, which ships each feature branch to a Cloudflare
Pages preview URL before it merges. Same component, two jobs, different inputs.
That’s the parameterization paying rent.
One correction to how I originally understood all this: I assumed a component
had to be published to GitLab’s CI/CD Catalog—the browsable storefront at
/explore/catalog—to be includable. It doesn’t. A component: include resolves
by git tag whether or not the project is a catalog resource; I know because my
auto-tag component worked fine in includes for months before I ever touched
the catalog. What the catalog does add, however, is discoverability: a browse
page, auto-rendered input documentation, and the @~latest shortcut. I flipped
all of my component projects to catalog resources anyway (via the
catalogResourcesCreate GraphQL mutation—I never found a UI toggle for it),
mostly for that rendered input documentation. One snag you’ll hit if you do the
same: once a project is a catalog resource, publishing a release will fail
with a 422 until the project has a description. GitLab insists your storefront
items have labels. Fair enough.
A Word on Tidying While You’re In There
There’s a rule I try to follow, and writing it down is what got me to follow it: whenever you touch CI config, scan the neighboring entries for stale ones before you close the file.
Migrating a job to a shared component means you’ll be looking at parts of the
project settings you haven’t seen in months—the CI/CD variables page, the old
job definitions, the leftover include: lines. Those pages are where the
fossils live: a variable holding a token for a service I retired last year, a
deploy job pointing at a registry that moved, a masked secret I can no longer
identify. Individually each one is harmless clutter. Collectively they’re why CI
config gets scary to touch—each stale entry is one more thing a future you has
to rule out while debugging.
You’re already in there. The context is already loaded in your head. Deleting the fossil now costs thirty seconds; rediscovering what it was eight months from now costs an afternoon, so tidy in the same change.
Conclusion
The recap, for the folks who scrolled: extract your repeated CI jobs into small, versioned, parameterized components; give them a shared home; include instead of copy-paste; and check that shared home before writing anything new. Pin versions so upgrades are decisions, not surprises.
The payoff is big, and it compounds. A fix goes in once and will ship everywhere
on the next pin bump. And a new repo gets a correct pipeline essentially for
free—this website’s entire .gitlab-ci.yml is a thin include of a shared
static-site template (lint → build → unit and e2e tests → Lighthouse audit →
sourcemap upload → Cloudflare Pages deploy) plus that one extra preview-deploy
include and some project-specific variables. The day I created the repo, it had
a better pipeline than any project I’d hand-rolled. That stung a little.
The bigger principle is the one from the top: infrastructure config is still
code. DRY, semantic versioning, a documented public API, small composable
units—none of that stops applying just because the file ends in .yml. Buy into
the battery platform. Stop hand-wiring a power supply into each new tool.