DevOps in Depth · Scaling CI/CD Across Teams

Scaling CI/CD Across Teams

A pipeline written for one team is a Tuesday-afternoon project: clone a starter repo, wire up a build and test job, add a deploy step, done. The same pipeline written for fifty teams is a distribution problem, a governance problem, and — if you get it wrong — a queue. This page is about what actually changes once repo count crosses that threshold: how pipeline logic stops living copy-pasted inside every repo's YAML and starts living in a versioned library any team can call; how the choice between one repository and many reshapes what your build tooling has to do; and how the team that owns all of this either builds a self-service catalog or quietly becomes the exact ticket-and-wait bottleneck DevOps was invented to remove.

☺ Explain it like I'm 10

One team's pipeline is a recipe card taped above a stove. Fifty teams each taping up their own copy is how you end up with fifty kitchens making the same dish fifty slightly different ways — one still uses an ingredient the health inspector banned last year, because nobody told that kitchen. The fix isn't a stricter inspector standing at every stove. It's one printed cookbook, with edition numbers, that every kitchen owns a copy of — the head chef fixes recipe #4 once, bumps it to edition 3, and every kitchen that's on edition 3 gets the fix without anyone driving across town to check.

🦫🐿️Your hosts for this topic: Benny the Beaver & Nutty the Squirrel — Benny is the builder who wrote the pipeline that worked great for one team; Nutty is the collector who turns Benny's pipeline into a catalogued, versioned library forty-nine other teams can borrow from without filing a single ticket.

What breaks at fifty teams that didn't break at one

☺ Like you're 10: One copy of a recipe is easy to keep correct. Fifty copies, each retyped by hand, drift apart the moment anyone changes one and forgets the other forty-nine.

A single team's CI/CD pipeline is one file, owned by the people who read it every day. It can be a little ugly and still work fine, because the cost of that ugliness is paid entirely by the people who wrote it. Copy that file into a second team's repo and you've made a decision, whether anyone noticed or not: from this moment, keeping those two pipelines equivalent is a manual, unenforced, ongoing act of discipline across two different codebases owned by two different sets of engineers with two different sets of priorities. Copy it into fifty repos and that discipline doesn't get fifty times harder — it becomes structurally impossible. Nobody is going to open fifty pull requests by hand every time the security-scan step needs a new flag.

Four specific failure modes show up, in order, as an organization crosses roughly a dozen teams sharing one CI system:

Before: fifty full copies team-a/ci.yml — scan v1.2 team-b/ci.yml — scan v1.4 team-c/ci.yml — no scan step one fix = fifty pull requests After: one template, thin callers pipeline-templates repo build-and-test @ v3 team-a: v3 team-b: v3 team-c: v2 migrating one fix, every pinned caller gets it on its own schedule

Shared pipeline templates & reusable workflow libraries

☺ Like you're 10: Instead of each team writing its own copy of the pipeline steps, one team writes them once, gives that copy a version number, and everyone else just points at it.

The fix for drift is the same fix that closes every other flavor of copy-paste sprawl: stop copying, start referencing. Every major CI system now has a first-class mechanism for a pipeline to call logic defined somewhere else instead of inlining it, and while the exact syntax differs, the shape is identical everywhere — a callable unit with typed inputs, a version identifier, and a caller that supplies its own parameters and secrets.

GitHub Actions — reusable workflows

A workflow that declares on: workflow_call becomes callable from any other workflow in the org (subject to the visibility you set on the source repo). It declares typed inputs and, separately, which secrets it needs — the caller passes both explicitly, or forwards every secret in scope with secrets: inherit.

# platform/pipeline-templates/.github/workflows/build-test.yml — the callee
name: build-and-test
on:
  workflow_call:
    inputs:
      node-version: { type: string, default: "20" }
      run-e2e:      { type: boolean, default: false }
    secrets:
      NPM_TOKEN: { required: true }
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "${{ inputs.node-version }}" }
      - run: npm ci
        env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
      - run: npm test
      - if: inputs.run-e2e
        run: npm run test:e2e

# team-checkout/.github/workflows/ci.yml — the caller, pinned to a tag
jobs:
  build:
    uses: acme-platform/pipeline-templates/.github/workflows/build-test.yml@v3
    with: { node-version: "20", run-e2e: true }
    secrets: inherit

Two constraints are worth planning around rather than discovering mid-migration: a caller can chain through a handful of nested reusable-workflow levels but not indefinitely, and there's a cap on how many distinct reusable workflows a single workflow file can call — both limits move as GitHub ships changes, so check the current numbers in GitHub's own documentation before you design a template hierarchy that's more than two or three levels deep. Pin the caller to a tag (@v3) rather than a floating branch (@main): a floating reference means the platform team can silently change what every caller's next run does, which is exactly the kind of surprise a shared template is supposed to prevent, not reintroduce.

GitLab CI/CD — includes and the Components Catalog

GitLab's original mechanism is include: pull a .yml file from another project into the current pipeline, optionally pinned to a ref. GitLab layered a more structured, versioned packaging format on top of this — CI/CD Components, published to a project's Components Catalog with semantic-version releases — which is the closer analogue to a GitHub Actions reusable workflow; check GitLab's current docs for exactly which tier and version this requires, since the Components Catalog moved out of beta relatively recently and packaging details have shifted release to release.

# the older, still-common pattern: include a file from another project, pinned by ref
include:
  - project: 'platform/pipeline-templates'
    ref: 'v3'
    file: '/templates/build-test.yml'

# the newer, versioned-component pattern
include:
  - component: "$CI_SERVER_FQDN/platform/pipeline-templates/build-test@3.1.0"
    inputs:
      node_version: "20"
      run_e2e: true

GitLab also gives platform teams a stronger lever than "please include our template": compliance pipelines, attached at the group level, run alongside — or in place of — whatever pipeline a project defines, so a mandated scan step can execute even for a repo whose own .gitlab-ci.yml never mentions it. That's a genuinely different enforcement model from GitHub's include-by-choice reusable workflows, worth knowing about specifically because it changes who can quietly opt out.

Jenkins — shared libraries

Jenkins predates both of the above and solved the same problem with shared libraries: a separate git repository containing Groovy code in two conventional folders — vars/ for global variables that act like simplified, callable pipeline steps, and src/ for full Groovy classes when the logic gets complex enough to need real structure. A library is registered once (globally, in Jenkins' own configuration, or per-Jenkinsfile) and referenced with an @Library annotation that can itself pin a version.

// platform/pipeline-templates/vars/buildAndTest.groovy — the callee
def call(Map config = [:]) {
  node {
    stage('Checkout') { checkout scm }
    stage('Test') {
      sh "npm ci && npm test"
      if (config.runE2E) { sh "npm run test:e2e" }
    }
  }
}

// team-checkout/Jenkinsfile — the caller, pinned to a tag
@Library('pipeline-templates@v3') _
buildAndTest(runE2E: true)

CircleCI's version of the same idea is an orb — a versioned, publishable package of commands, jobs, and executors pulled from the Orb Registry (orbs: { node: circleci/node@5.1.0 }) — and Azure DevOps goes a step further than "include" with extends templates: a team's YAML doesn't just include the platform's steps, it extends a template that owns the pipeline's entire stage structure, supplying only the parameters the template exposes. That's a meaningfully stronger guardrail than an include-style mechanism, because a team literally cannot delete a stage the extends-template defines — only the platform team, editing the template, can.

CI systemReuse mechanismPinned byCan a team opt out of a step?
GitHub ActionsReusable workflows (workflow_call)Tag, branch, or commit SHAYes, unless paired with org rulesets / required workflows
GitLab CI/CDinclude / CI/CD Components CatalogRef or semver releaseYes for includes; no for group-level compliance pipelines
JenkinsShared libraries (vars/, src/)Branch or tag via @LibraryYes, unless the library enforces a mandatory step internally
CircleCIOrbs (commands/jobs/executors)Semver releaseYes, orb usage is opt-in per config
Azure DevOpsextends templatesRef in a linked template repoNo — the template owns the stage structure

Versioning the template without freezing it in place

☺ Like you're 10: Give the recipe a printed edition number. Kitchens on edition 3 keep working exactly as before until they choose to switch to edition 4 — nobody's dinner changes underneath them without warning.

A reusable template only pays off if teams can trust that pulling it in today won't silently change tomorrow. That trust comes from treating the template repo like any other published dependency: semantic versioning, a changelog, and a deprecation window before an old major version stops being supported — not "the pipeline is whatever's on main right now." Pin callers to a specific tag or, for anything security-sensitive, a commit SHA; reserve floating references for the platform team's own canary rollout of a new version, never for a consuming team's default.

The harder design question is what the template should actually own versus leave open, and the useful split is golden path vs. guardrail. A golden path is the recommended default a team can override if they have a real reason to — the base container image, the default test command, the linter config. A guardrail is a non-negotiable the template enforces regardless of what a team wants — the dependency-vulnerability scan actually running, the artifact actually getting signed, a required approval before a production deploy. Conflating the two is how templates fail in both directions at once: make everything overridable and the security scan quietly becomes optional again, exactly the drift the template was built to prevent; make everything rigid and teams start forking the template just to change a lint rule, which recreates copy-paste sprawl with extra steps.

◆ Key idea

A shared pipeline template is a contract, not a suggestion. Version it like one — semver, changelog, deprecation window — and design it in two explicit layers: a golden path teams can reasonably override, and a small set of guardrails they can't. Skip that split and you'll eventually discover which steps were "really" mandatory the same way you discover a missing backup — during the incident that needed it.

Monorepo vs. polyrepo: what actually changes for build tooling

☺ Like you're 10: One giant shared toybox means everyone can grab the same blocks instantly — but somebody has to organize it so cleaning up doesn't mean sorting every single block every time.

Shared templates solve pipeline-definition sprawl. They don't touch a second, independent question that scales with team count just as hard: does each team's code live in its own repository (polyrepo), or does everyone commit into one enormous repository (monorepo)? Neither answer is free, and the tradeoff shows up specifically in what your build tooling is required to do.

The polyrepo default — and where it strains

Polyrepo is what you get by default, with no extra tooling investment: each team's repo has its own pipeline (now, hopefully, calling the shared template), and CI blast radius is naturally scoped — a broken build in team-checkout cannot fail team-billing's pipeline, because they're different runs entirely. The cost shows up the moment code needs to be shared rather than merely built alongside. A library used by twelve teams has to be versioned and published to an internal package registry (npm, Maven, a private PyPI index), and every consumer has to separately pick up the new version. In practice that produces dependency lag: fifty teams end up on some spread of the last dozen releases of that library, a security fix in the library doesn't reach anyone until they individually bump a version number, and a genuinely cross-cutting change — rename a field used by fifteen services — needs fifteen coordinated pull requests merged in some workable order, because there is no single commit that can touch all fifteen repos atomically.

The monorepo alternative — and what it demands back

A monorepo inverts that tradeoff. One repository, one dependency graph the build tool can see in full, means a cross-cutting change — that same field rename — is one commit touching the library and all fifteen callers, verified by a single CI run before it merges. Nothing is ever "on an old version" of internal code, because there's only one version: whatever's on the branch. That is a real, structural win for the fifty-team problem this page is about.

It comes due immediately in CI cost. "Run every test on every commit" is fine for one team's repo; run against fifty teams' combined codebase and a one-line change to a leaf service triggers a full-repo test suite that takes longer every quarter as more teams join. A monorepo at scale is therefore not optional about investing in a build tool that understands the dependency graph well enough to compute exactly what a change can possibly affect, and build or test only that:

# Nx (JS/TS-oriented, also polyglot via plugins): only what changed since main
nx affected --target=test --base=main --head=HEAD

# Bazel: reverse-dependency query — what depends on this library, transitively
bazel query 'rdeps(//..., //libs/checkout-core:lib)'
bazel test $(bazel query 'rdeps(//..., //libs/checkout-core:lib)' --output=label)

# Turborepo: filter the task graph to packages changed since a git ref
turbo run test --filter=...[origin/main]

Google's internal monorepo (the origin of Bazel) and Meta's are the extreme end of this pattern — hundreds of millions of lines and tens of thousands of engineers in one repository, made workable only by exactly this kind of affected-graph build plus aggressive remote caching (a test result for an unchanged target is fetched from a shared cache instead of recomputed) and distributed task execution. You don't need Google's scale to feel the requirement, though — the moment a fifth team joins a monorepo, "just run everything" starts costing real CI minutes and real time-to-feedback, and the fix is architectural (an affected-graph tool), not procedural.

Ownership at that scale also needs its own mechanism, since a monorepo has no repo-level ACL to lean on. A CODEOWNERS file mapping paths to required reviewers is the usual answer — /services/checkout/ @team-checkout — enforced by branch protection so a change under a path automatically requires sign-off from the team that owns it, which is a path-based approximation of the repo boundary polyrepo gives you for free.

AxisPolyrepoMonorepo
Cross-cutting changeN coordinated PRs across N repos, in some working orderOne commit, one CI run, atomic
Internal dependency versioningPublish + bump — consumers lag on old versionsNone needed — always on the current version
CI blast radius / speed at scaleNaturally scoped per repo; scales by adding reposRequires an affected-graph tool + remote caching, or it degrades
Ownership boundaryEnforced by the repo itself (access control)Enforced by convention: CODEOWNERS + path-based review rules
Tooling investment requiredLow to start; grows with the number of shared internal librariesHigh up front — Bazel/Nx/Turborepo/Pants, caching infrastructure
Failure isolationStrong — one repo's broken build can't block another's mergeWeaker unless CI is sharded — a flaky top-level test can block everyone

Neither choice is "more DevOps" than the other — CALMS doesn't have an opinion on repo topology. What matters is picking deliberately and then actually funding the tooling investment the choice requires: a monorepo without an affected-graph build tool degrades into everyone waiting on everyone else's tests, and a polyrepo without disciplined internal-package versioning degrades into fifty repos quietly running fifty different versions of the same library, which is the exact drift problem shared templates exist to prevent, just moved from pipeline definitions into application code.

Self-service pipeline platforms: escaping the ticket queue

☺ Like you're 10: A vending machine that hands you exactly what you picked, right now, beats a counter where you fill out a form and wait for someone to bring it to you tomorrow — even if the counter is very polite about it.

What is DevOps? names this failure mode directly, as Myth 1: a well-meaning "Platform" or "DevOps" team forms, and within a year every other team routes deploys and infrastructure requests through it — recreating the exact ticket-and-wait handoff DevOps was invented to remove, just with a friendlier name on the door. Shared templates make that outcome more likely, not less, unless you pair them with a deliberate self-service layer. A perfectly designed, perfectly versioned pipeline template is still a ticket queue if the only way to actually adopt it is filing a request and waiting for someone on the platform team to wire it up by hand.

Before: request & wait Team ticket ticket ticket Platform team queue days of wait, one engineer at a time After: self-service catalog Team Self-service scaffolder repo wired to template + guardrails minutes, no queue, no ticket

The concrete mechanism most organizations converge on is an internal developer portal with a scaffolder: Spotify's open-source Backstage is the reference implementation, though the same idea works as a much smaller in-house CLI. A Backstage Software Template is a declarative sequence of actions — fetch skeleton files, substitute parameters, publish a new repo, register it in the catalog — that a developer runs by filling out a short form, not by opening a ticket:

# template.yaml — a simplified Backstage Software Template
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata: { name: node-service, title: "New Node.js service" }
spec:
  parameters:
    - properties:
        name: { type: string, title: "Service name" }
        team: { type: string, title: "Owning team (CODEOWNERS)" }
  steps:
    - id: fetch
      action: fetch:template
      input: { url: ./skeleton, values: { name: "${{ parameters.name }}" } }
    - id: publish
      action: publish:github
      input:
        repoUrl: "github.com?repo=${{ parameters.name }}&owner=acme-org"
    - id: register
      action: catalog:register
      input: { repoContentsUrl: "${{ steps.publish.output.repoContentsUrl }}" }
# The skeleton already contains ci.yml calling pipeline-templates/build-test.yml@v3,
# branch protection requests, and a CODEOWNERS entry — nobody hand-wires any of it.

What actually distinguishes self-service from a well-organized ticket queue is not the portal's existence — a nice UI in front of a manual approval step is still a ticket queue. It's whether the request completes without a human on the platform team touching it. If "click the button" still routes to someone's inbox for manual repo creation, you've built a nicer form for the same bottleneck. The metric that actually reveals which one you have is time-to-first-deploy for a brand-new service: minutes to low single-digit hours means genuine self-service; anything measured in days almost always means a human is still in the loop somewhere, whatever the tooling looks like on the surface. That number is a close cousin of the DORA lead-time metric, just measured at the moment a service is born instead of at every subsequent deploy.

⚠ Watch out

Self-service that still requires a platform-team approval gate for every instantiation is Myth 1 wearing a portal instead of a ticketing tool. The scaffolder in the diagram above only counts as self-service if the guardrails are enforced inside the template — a mandatory scan step, a required CODEOWNERS entry, branch protection baked into what gets published — not if a human still has to eyeball and approve each request before it runs. Move the judgment into the template once, at design time; don't leave it in a person's inbox, repeated, forever.

Guardrails as code: governance that doesn't gatekeep

☺ Like you're 10: A fence around the whole yard beats a person standing at the gate checking everyone by hand — the fence works at 3 a.m. and never gets tired of saying no.

The tension self-service creates is real: if teams can spin up their own pipelines without asking permission, how does the platform team guarantee the security scan, the license check, the required approval actually stay in place? The answer isn't a manual review gate reinserted after the fact — that's the ticket queue again, just moved one step later in the process. It's encoding the requirement as a machine-checkable policy that runs automatically, independent of whether any individual team's pipeline remembered to include it.

Two layers do this in practice. The first is inside the template itself, as covered above — a guardrail step a caller can't remove. The second is an external check that verifies compliance even for pipelines the template didn't originate, using a policy-as-code tool like Open Policy Agent and its conftest CLI, evaluating a Rego policy against every pipeline definition in CI:

# policies/required-scan.rego
package pipeline.guardrails

deny[msg] {
  not input.jobs["security-scan"]
  msg := "pipeline is missing the required security-scan job from the platform template"
}

deny[msg] {
  input.jobs["deploy-prod"].environment != "production-approved"
  msg := "deploy-prod must target the production-approved environment, which enforces required reviewers"
}

# run in CI: conftest test .github/workflows/ci.yml -p policies/
# a deny match fails the check — the same enforcement mechanism as a required
# status check, but the rule lives in one policy repo, not fifty pipeline files

Platform-specific mechanisms reinforce the same idea at the platform layer instead of the policy-engine layer: GitHub's organization rulesets can require that a specific status check or workflow run on every repository regardless of what that repository's own workflow file says, and GitLab's group-level compliance pipelines run alongside a project's own .gitlab-ci.yml for the same reason — verify current capabilities and licensing tier for either, since this is exactly the kind of feature that moves between plan tiers over time. Security & Compliance covers the identity-and-policy side of this same idea — permission boundaries, resource policies — for teams running on AWS specifically; this section is the pipeline-governance version of the same principle: make the safe path the only path, enforced by something that runs the same way at 3 a.m. as it does during a code review.

Rolling it out: migrating fifty teams without a flag day

☺ Like you're 10: Don't switch every kitchen's recipe card on the same Tuesday. Get one kitchen cooking well off the new cookbook first, fix what's wrong with it there, then let the rest switch over on their own schedule.

Everything above describes the destination. Getting fifty already-drifted, already-working pipelines there without a disruptive flag-day cutover is its own project, and it follows the same small-batch instinct that runs through the rest of this course — Lean's small batch sizes, applied to a migration instead of a feature release.

None of this works if the platform team treats the template repo as theirs alone to edit. The healthiest version of this setup accepts pull requests from any consuming team — a team that needs one more input exposed, or finds a bug in the guardrail step, sends a PR against the template instead of forking it or filing a ticket and waiting. That single habit is what keeps the shared template from becoming, itself, a queue with the platform team standing at the front of it.

🎬 At the Ship-It Guild
🦫

Benny the Beaver: My pipeline works great. Everyone should just copy my ci.yml!

👺

Gizmo the Gremlin: Copy-paste, my favorite. Forty-nine repos, forty-nine slightly different versions of "correct" — nobody will ever notice. 🤑

🐿️

Nutty the Squirrel: Or — hear me out — I catalogue Benny's pipeline once, give it a version number, and every team just calls it. I fix a bug in one place and it reaches all forty-nine on their own schedule.

🦊

Foxy: Fine, but does every team file a ticket with you to get wired up to it?

🐿️

Nutty the Squirrel: That's the part I almost got wrong. No — new repos get it automatically from the scaffolder. The moment I'm approving each one by hand, I'm just a ticket queue with a nicer sign on the door.

🐢

Timmy the Turtle: And the security-scan step doesn't live in Nutty's inbox either — it's baked into the template itself. A team can't quietly ship without it just because nobody was watching that day.

🦉

Professor Owl: Which is Myth 1 again, from a different angle — the fix was never "hire someone to guard the gate." It's building the gate so it doesn't need a guard.

Scaling CI/CD across teams is really the same move this course keeps making at a different layer: replace a manual, person-dependent handoff with something versioned, automated, and self-service — the same shift infrastructure as code makes for servers and configuration management makes for running systems, applied here to the pipeline definition itself. Go deeper on the specific mechanics that make self-service safe in The Inner Loop & Developer Experience and on where this whole discipline is heading in From DevOps to Platform Engineering; practice the template-and-guardrail pattern hands-on in Drill — Write a Reusable IaC Module and the full pipeline build in Capstone Part 1 — Pipeline Foundation; and if you want the tool-specific detail behind any one mechanism here, see GitHub Actions, GitLab CI/CD, Jenkins, and CircleCI.

✓ Checkpoint

1. Name the four failure modes that show up as a shared pipeline scales from one team to many, and explain how a versioned template fixes each. 2. Walk through the golden-path vs. guardrail split — why does conflating the two cause templates to fail in both directions? 3. Give two concrete things a monorepo makes atomic that a polyrepo can't, and two costs a monorepo takes on in exchange. 4. Explain, in your own words, how a self-service scaffolder with a manual approval step is still Myth 1 in disguise. 5. Why does policy-as-code (like the Rego example) matter even when the shared template already includes a guardrail step?

Check your answers
  1. Config drift (fifty pipelines slowly diverge) — a shared template means there's only one definition to diverge from. Upgrade fatigue (a fix needs fifty PRs) — a version bump on the template reaches every pinned caller without fifty separate edits. Inconsistent quality gates (no single answer to "does this block a merge") — the template defines the gate once for everyone using it. The platform team becomes the pipeline (everything routes through a few experts) — a well-documented, self-service template means teams call it themselves instead of asking the platform team to hand-wire each one.
  2. A golden path is an overridable default (base image, lint config); a guardrail is a non-negotiable (security scan, required approval, artifact signing). Make everything overridable and guardrails quietly become optional again — the exact drift the template was meant to prevent. Make everything rigid and teams fork the template just to change a lint rule, recreating copy-paste sprawl with extra steps.
  3. Atomic: a cross-cutting change to a shared internal library and all its callers in one commit/PR/CI run; a repo-wide rename or refactor verified before merge instead of coordinated across N separate PRs. Costs taken on: CI has to be pointed at an affected-graph build tool (Bazel/Nx/Turborepo/Pants) plus remote caching or it slows down as more teams join; ownership has to be enforced by convention (CODEOWNERS, path-based review rules) instead of a hard repo boundary.
  4. A portal or CLI in front of the request doesn't change the underlying bottleneck if a human on the platform team still has to manually approve or wire up each instantiation — it's a nicer-looking ticket queue, not self-service. Real self-service means the guardrails are enforced inside the template at design time, so the request completes with zero humans in the loop, the same way removing a manual sign-off gate removes a wall-of-confusion handoff elsewhere in this course.
  5. A guardrail baked into the template only protects pipelines that actually call that version of the template. Policy-as-code checks every pipeline definition in the org independent of which template (or version, or hand-written exception) it started from — it catches the repo that's still on an old pinned version, the one a team forked and modified, or the one that predates the template entirely, which an in-template guardrail alone can't reach.