Pipeline as code

Also known as: pipeline-as-code, workflow as code, declarative pipeline, YAML pipeline, configuration as code for CI/CD

Updated 2026-06-284 questions

Pipeline as code is the practice of defining a CI/CD pipeline in a version-controlled file (typically YAML or a DSL stored alongside the application code), so the build, test, and deployment workflow is reviewable, repeatable, and rolls forward and back with the rest of the repository - instead of being clicked together in a web UI that nobody can diff.

How does pipeline as code work?

Pipeline as code works by storing the entire CI/CD workflow - triggers, stages, jobs, commands, conditions, secrets-by-reference - in a plain text file inside the project repository, and letting the CI runner read that file on every push to decide what to do. The pipeline stops being state on a server and becomes content in your repo, with all the leverage that change brings.

In practice the model has a handful of moving parts:

  • A definition file in a known location. Every major tool agrees on this convention: Jenkinsfile, .github/workflows/<name>.yml, .gitlab-ci.yml, .circleci/config.yml, azure-pipelines.yml, bitbucket-pipelines.yml. The runner looks at a fixed path; if the file is there, that branch has a pipeline.
  • A declarative (sometimes scripted) syntax. Most ecosystems use YAML for the structure - stages, jobs, steps - with a small expression language for conditionals and variables. A few (Jenkins declarative/scripted, Dagger, Earthly) use a real DSL or programming language so you can compose pipelines like functions instead of pasting YAML.
  • Reusable building blocks. Actions (GitHub), templates (GitLab, Azure), orbs (CircleCI), shared libraries (Jenkins), reusable workflows (most of the above) let teams factor common steps - "checkout, install Node, restore cache, run tests" - into one definition every project imports. The pipeline file becomes a thin recipe over an internal library.
  • The runner reads the same commit it builds. This is the property that makes the whole thing safe to evolve: on a push, the runner checks out the commit, finds the pipeline file at that commit, and uses it. A broken edit on a branch only breaks that branch. A bisect that walks back through history walks back through pipelines that actually built that code, not whatever the UI looks like today.
  • Secrets stay out. The file references credentials by name ($AWS_ACCESS_KEY_ID, ${{ secrets.NPM_TOKEN }}); the runner injects the value at execution time from a vault, OIDC token exchange, or an encrypted store. The file itself stays safe to commit, share, and publish.

The end result is a workflow that behaves like the rest of your codebase: a pull request can change the build, two people review it, CI runs the new pipeline against itself, and the change lands in main (or doesn't) with a complete audit trail.

Why does pipeline as code matter?

The shift from "click-to-configure" to "commit-to-configure" looks cosmetic and turns out to be load-bearing for almost everything else a delivery team wants.

  • Reviewable changes. Build, test, and deploy steps go through the same pull-request review as application code. A new deploy stage is no longer something one engineer added on a Friday; it is a diff with comments and an approver.
  • Reproducible history. Older commits build with the pipeline that existed when they were written. Reverting a release reverts the workflow that produced it. Bisecting a regression doesn't drift because the build steps shifted underneath you.
  • Portability between environments and runners. When the pipeline is text, spinning up a second runner, a fork, a disaster-recovery account, or a self-hosted mirror is "copy the file and provide the secrets" rather than "click through 40 jobs and pray." Migrating between CI vendors becomes a translation exercise instead of an archaeology project.
  • Drift goes away. No more "production builds with extra steps that staging doesn't." If staging builds a different artifact than production, you can git diff the two pipeline definitions and find out why in seconds.
  • Onboarding shrinks. New engineers read the pipeline file in their IDE, with syntax highlighting and search, the same way they read the rest of the project. They don't need access to a CI admin console to understand how the code ships.

The real trade-offs are honest ones. YAML grows tentacles past a certain size - large monorepos routinely end up with thousands of lines of pipeline configuration split across dozens of files, and the lack of a real type system bites. Debugging a failing pipeline often means pushing commits to trigger re-runs (sometimes nicknamed "YAML-driven development"), which is slower than poking at a UI. And the "single file in the repo" shape assumes one team owns the pipeline; in a large organisation with shared compliance steps, you need a story for templates, central includes, or policy-as-code on top - otherwise everyone copy-pastes the same 200 lines and they all drift independently.

Pipeline as code vs. UI-configured pipelines

The clearest way to feel the difference is to put the two operational models side by side.

  • UI-configured. Steps live in the CI server's database. Editing requires login and permissions. Two environments can drift silently because the "same" pipeline is actually two records. There is no diff, no history, no review. Backing up the CI server is the only backup of the pipeline. Migrating vendors is essentially a rewrite.
  • Pipeline as code. Steps live in the repo. Editing is a pull request. Two environments either share the file or they have two files you can diff. Every change has an author, a reviewer, and a reason. Backing up git backs up the pipeline. Migrating vendors is mostly find-and-replace plus testing.

The catch: a UI is genuinely easier when you are setting up your first pipeline and don't yet know the shape of the workflow. Many teams legitimately start clicking and then export to YAML once the steps stabilise - that's a healthy progression, not a failure mode.

How do popular CI/CD tools handle pipeline as code?

The whole industry has converged on this pattern over the last decade, but each tool brings real strengths and real rough edges.

  • Jenkins pioneered pipeline as code with the Jenkinsfile (declarative and scripted Groovy variants). Its shared-library mechanism is still one of the most powerful ways to factor pipelines, and the plugin ecosystem - 1,800+ plugins - can do nearly anything. The honest trade-off is operational weight: Groovy is more expressive than YAML but also harder to onboard, and you own the controller and agents. For very large enterprises with bespoke build needs and the engineers to operate a controller, Jenkins is still the most flexible option full stop - that is the honest concession here. See the Jenkins term for more detail.
  • GitHub Actions has the best ergonomics for projects already on GitHub: workflows live under .github/workflows/, the marketplace of reusable actions is enormous, and OIDC-based cloud auth is first-class. The pattern of composite/reusable workflows lets internal platform teams ship golden paths as importable units. Where it strains is bigger matrices and complex fan-in/fan-out logic, which often end up needing a homegrown orchestrator on top.
  • GitLab CI ships pipeline as code (.gitlab-ci.yml) in the same product as the SCM, registry, and issue tracker, which makes the audit story tight. Its include: directive and parent-child pipelines scale well into monorepos, and DAG support is mature. The trade-off is the all-in-one footprint - it does a lot, and you usually pay for the parts you don't use.
  • CircleCI's .circleci/config.yml plus orbs hit a nice sweet spot for medium teams: fast caching, good Docker support, and reusable orbs that feel lighter than GitHub composite actions. Less of a fit if you need self-hosted runners at the heart of your topology.
  • Argo Workflows / Tekton push pipeline as code into Kubernetes itself - pipelines are CRDs, jobs are pods, the cluster is the runner. If you are already a Kubernetes shop and want your build infrastructure to look like the rest of your platform, these are the better fit; nothing else slots into a K8s control plane as naturally.
  • Buddy is one of the options we'd recommend when the value you want from pipeline as code is fewer custom YAML stanzas to maintain, not more. The .buddy/buddy.yml file uses the same declarative idea but the action catalog is high-level (SFTP, KUBERNETES_APPLY, HTTP_REQUEST, BUDDY_CLI, S3, LAMBDA_DEPLOY, dozens more) so a typical deploy pipeline is a few short, named actions instead of a screen of shell. The GUI editor reads and writes the same file - you can prototype visually, commit the YAML, and the next push is fully version-controlled. Buddy is not pretending to be Jenkins for a 2,000-engineer monorepo, and it is not Argo if your runtime is Kubernetes; for the long tail of teams shipping web apps, services and statics where you want pipeline-as-code without writing a thousand lines of YAML to get there, it is a pragmatic choice.

The honest summary: most teams should pick the tool that lives closest to their source-of-truth repo (GitHub Actions if you're on GitHub, GitLab CI if you're on GitLab) and only reach for a heavier option when its specific strengths (Jenkins's plugins, Argo's Kubernetes-nativeness, Buddy's action catalog) match a real pain you have.

Example

The snippet below is a Buddy pipeline-as-code definition - a single YAML file checked into the repo at .buddy/buddy.yml. It declares a build-and-deploy workflow that runs on every push to main, installs dependencies with a cached node_modules, runs tests, publishes a versioned artifact, and deploys it through a distribution route. The pipeline file itself is reviewed in pull requests alongside the application code; rolling the workflow back is git revert.

# .buddy/buddy.yml - the entire CI/CD workflow as one reviewable file
- pipeline: "build-and-deploy"
  events:
    - type: "PUSH"
      refs:
        - "refs/heads/main"
  variables:
    - key: "NODE_VERSION"
      value: "20"
    - key: "ARTIFACT_NAME"
      value: "marketing-site"

  actions:
    - action: "Install dependencies"
      type: "BUILD"
      docker_image_name: "node"
      docker_image_tag: "$NODE_VERSION"
      commands: |-
        npm ci --prefer-offline
      cached_dirs:
        - "node_modules"

    - action: "Run tests"
      type: "BUILD"
      docker_image_name: "node"
      docker_image_tag: "$NODE_VERSION"
      commands: |-
        npm test -- --reporter=junit

    - action: "Build production bundle"
      type: "BUILD"
      docker_image_name: "node"
      docker_image_tag: "$NODE_VERSION"
      commands: |-
        npm run build

    - action: "Publish artifact"
      type: "BUILD"
      docker_image_name: "ubuntu"
      docker_image_tag: "22.04"
      commands: |-
        bdy artifact publish $ARTIFACT_NAME:$BUDDY_EXECUTION_REVISION ./dist --create

    - action: "Route production traffic to the new version"
      type: "BUILD"
      docker_image_name: "ubuntu"
      docker_image_tag: "22.04"
      commands: |-
        bdy distro route update prod-distro \
          --domain=example.com \
          --target=artifact=$ARTIFACT_NAME:$BUDDY_EXECUTION_REVISION

    - action: "Smoke check"
      type: "HTTP"
      method: "GET"
      notification_url: "https://example.com/healthz"
      retry_count: 6
      retry_interval: 10

Three properties of this file are worth noticing. First, anyone with read access to the repo can answer "how does this project ship?" by opening one file - no admin console required. Second, changing the workflow (adding a new test stage, a new environment, a new smoke check) is a pull request the team reviews together; the diff is the proposal. Third, the file pins itself to a specific Node version, a specific artifact name, and a specific deploy target - so building an old commit reproduces the same workflow it had when it was written, which is exactly the reproducibility property that makes rollbacks sane.

Frequently asked questions

How is pipeline as code different from a regular CI/CD pipeline?

The pipeline itself is the same idea - build, test, deploy in stages. The difference is where the definition lives. A "regular" pipeline can be configured through a server's web UI, where its steps are stored in a database and only the admin who clicked them remembers why. Pipeline as code moves that definition into a file (`Jenkinsfile`, `.github/workflows/ci.yml`, `.gitlab-ci.yml`, `azure-pipelines.yml`) inside the project repo, so it is reviewed in pull requests, diffed in `git log`, and travels with whichever branch or tag uses it. Same pipeline; very different operational properties.

Is pipeline as code the same as infrastructure as code?

They are siblings, not synonyms. Infrastructure as code declares the *target* of a deploy - servers, clusters, networks, queues - usually in Terraform, Pulumi, CloudFormation or Kubernetes manifests. Pipeline as code declares the *process* that gets your application onto that infrastructure - the build matrix, the test stages, the approval gates, the deploy steps. Both apply the same discipline (declarative, versioned, reviewable, reproducible) to different layers of the delivery stack, and they reinforce each other: a pipeline-as-code workflow is often what runs the infrastructure-as-code apply in the first place.

What goes in the pipeline file and what stays outside?

The file should hold the *shape* of the workflow - triggers, stages, jobs, dependencies, the commands each step runs, retry and timeout policy. It should not hold secrets, environment-specific endpoints, or one-off values that change without a code change; those belong in the CI system's secret store or in per-environment variable bindings the file references by name. A good test: if someone forks the repo, the pipeline file should describe *what happens* clearly enough to read, while still being safe to publish because every sensitive value is injected at run time.

How do you test changes to a pipeline file safely?

Treat the pipeline like any other code. Open a branch, change the file, and let the CI system run *that branch's* version of the pipeline against itself - most modern runners read the pipeline definition from the same commit they are building, so a broken edit only breaks the branch, not `main`. Pair that with a linter (`actionlint`, `gitlab-ci-lint`, `jenkins-cli declarative-linter`, or your runner's built-in `validate` sub-command) in a pre-commit hook to catch syntax mistakes before they reach the server, and gate any change to the pipeline file behind a code review the same way you would a migration.

Missing a term? Spotted a mistake?

Suggest a new word or an edit to an existing one. Every submission is reviewed before it goes live.