Self-host a CI/CD tool long enough and you'll hit a philosophical fork: where should the pipeline definition actually live?
One camp is the canvas crowd: open a web page, drag a few nodes, draw a few edges — build → test → deploy, laid out visually, editable with a few clicks. Old-school Jenkins config and a lot of homegrown DevOps platforms work this way.
The other camp is the code crowd: the pipeline is just a YAML file in your repo — .github/workflows/*.yml, .gitlab-ci.yml, .pipewright.yml — committed, reviewed, and rolled back alongside your code. GitHub Actions made this camp mainstream.
Both have real upsides and real fatal flaws. When I built my own deploy tool I started with just the canvas, used it for a while, and hit a wall the canvas can't get around. Then I added YAML too — and promptly fell into the "how do two definitions coexist" pit. This post lays out the tradeoffs of each paradigm, and the one mechanism that's easiest to get wrong when you support both.
1. The canvas: the good and the painful
The visual canvas is genuinely nice:
- Zero learning curve. No YAML fields to memorize. Drag and drop. New people are productive fast.
- What you see is what you get. Stage dependencies (what runs after what, what runs in parallel) drawn as a graph are far more legible than a wall of indented YAML.
- Change and run immediately. Tweak a parameter in the browser, hit run. No commit, no push.
But the canvas has two problems it can't escape:
-
The config isn't in version control. You changed the pipeline in the web UI — who changed it, what, and why? No record. Something breaks and you want "last week's version that worked"? Sorry, the canvas has no
git log. - Config and code live apart. Code is in the repo; the pipeline is in a database. They live separate lives. Want a feature branch to temporarily tweak a build step? The canvas can't — it's a project-level config that doesn't follow branches. Change the canvas on main and your feature branches inherit it too.
Point 2 is the killer. Half the reason GitHub Actions is so pleasant is exactly this: the workflow file follows the branch. Change CI on a feature branch and it only affects that branch; main is untouched until you merge. The canvas can't give you that.
2. The code camp's answer: put the pipeline in .pipewright.yml
The code camp's fix is direct — the pipeline is a YAML file at the repo root. In my tool it looks like this:
version: 1
stages:
- id: stg_src
name: fetch-source
kind: source
jobs:
- { id: job_src, name: source, type: git_source }
- id: stg_build
name: build
kind: build
needs: [stg_src] # DAG dependency: build after source
jobs:
- id: job_compile
name: compile
type: script
script:
image: golang:1.24
commands:
- go build ./...
- id: stg_deploy
name: deploy
kind: deploy_ssh
needs: [stg_build]
gate: true # manual approval gate before deploy
when:
branches: [main] # only deploy on main
One thing to draw a hard line around — a lot of people hear "YAML + git" and immediately think docker-compose. But these are different layers:
- docker-compose.yml describes what the deployed artifact looks like: which services, ports, volumes, networks.
-
.pipewright.ymldescribes how the pipeline is orchestrated: fetch source, then build, then deploy — which stage depends on which, which needs manual approval, which only runs on main.
The former is the end state, the latter is the process. A deploy_ssh step inside .pipewright.yml can run docker-compose up, but that's one of its actions, not the same layer of thing. Don't conflate them.
Once the pipeline is in YAML, both canvas pains are cured: config is in version control (has git log, reviewable, rollback-able), and config follows the branch (a feature branch editing the YAML only affects itself).
3. The real problem: how do two paradigms coexist
Here, the lazy move is "pick one" — force users to choose all-canvas or all-YAML. But reality is: old projects already drew their pipeline on the canvas, and a new requirement wants YAML — you can't make people start over.
So the real question is: when a single project has both a canvas config and a repo YAML, which does one run actually obey?
My approach is a per-project switch (default off):
-
Switch off (default): 100% canvas config. Even if a
.pipewright.ymlexists in the repo, it's ignored — never read. Old projects behave identically. -
Switch on: prefer the repo YAML. At run time, it reads
.pipewright.ymlfrom the repo root on the branch this run is for, parses it, and drives this run with it.
Three points, each worth spelling out:
1. This is "pick one," not "merge." A run's pipeline definition comes from exactly one source — either the repo YAML (repo) or the canvas config (stored). There's no blending the two. Blending is a disaster: you'd never be able to say what actually ran. Pick one, cleanly.
2. It reads from the run's branch — the YAML camp's biggest win. With the switch on, edit .pipewright.yml on a feature branch, push it, and that branch's runs use the new YAML, main unaffected. That's exactly the "config follows the branch" property the canvas couldn't give you.
3. On failure it silently falls back — the run never breaks. YAML file missing? Malformed? A binary blob? — it automatically falls back to the canvas config and the run proceeds. It won't wedge your pipeline just because the YAML has a typo. This is deliberate: pipeline-as-code is an enhancement, it shouldn't become a new single point of failure.
4. The fallback is a safety net — and a trap
"Silent fallback" sounds considerate, but it has a flip side: silent — it falls back without saying a word.
Picture this: you think the run is using the repo YAML, but the YAML has an indentation error, and the system quietly fell back to the old canvas config. The run goes green, and you believe the new YAML took effect — when it never even ran.
So I added a preview endpoint: before actually running, you can have the system pull .pipewright.yml from the repo, parse it, and tell you "found it or not (found) / is it valid (valid) / which stages it would generate." Edit the YAML, preview it, confirm it actually parses, then rely on it. Don't let "silent fallback" hide your config errors along with everything else.
The run detail view also carries a source badge: whether this run used "repo main · .pipewright.yml" or "web config," visible at a glance. If it says "web config" when you expected YAML, a fallback happened — go check your YAML.
5. Being honest: the boundaries
When you roll your own, you owe people the boundaries, so they don't use it on wrong assumptions:
- No bidirectional sync. Editing the canvas doesn't generate YAML; editing YAML doesn't write back to the canvas. It's strictly one-way: switch on → repo YAML drives the run. Want to "export" the canvas as YAML? No such feature — hand-write it (or have AI draft it).
- YAML changes require a push to take effect. It's "push code, run time reads it," not "save the YAML and hot-reload." To make new YAML take effect, trigger a new run.
- YAML expresses a subset of the canvas's fields. The advanced "variables & cache" / "trigger config" tabs on the canvas aren't all covered by YAML yet. Core stages/jobs/dependencies/approval-gates/matrix/conditions are there, but don't expect 100% parity with the canvas.
-
Fixed file location: repo root, filename
.pipewright.yml. No subdirectories or renames. - Source is a "record," not a "force." The badge tells you which source a run used, but you can't say "force this one run to use the canvas" — switching is per-project only, no run-level override.
These aren't bugs, they're "good-enough-first" tradeoffs. Spelling them out is how you know when to reach for the canvas and when for YAML.
6. So which should you pick?
My experience:
- Canvas fits — just starting out, the pipeline is still churning, someone on the team doesn't write YAML, you want to click around and see results fast.
- YAML fits — the pipeline has stabilized, you want it in version control and code review, you need "config follows the branch," you want changing the pipeline itself to go through PR review.
And the point of "support both, one switch per project" is: you don't have to pick a side up front. Get the flow working on the canvas first, then once it stabilizes, freeze it into .pipewright.yml, commit it, flip the switch, and migrate over smoothly — keeping the canvas config around as a fallback safety net. That's probably the least painful path for self-hosting.
7. I built all of this into a tool
Everything above — visual canvas orchestration, declarative .pipewright.yml, a per-project switch, reading YAML from the run's branch, silent fallback on error, a preview endpoint to validate, and the source badge in run detail — I packed it, along with the CI builds, multi-host deploy, automatic Caddy HTTPS, per-PR preview environments, and zero-downtime deploy + rollback from earlier posts, into my Go single-binary deploy tool Pipewright:
-
Both paradigms: web canvas drag-and-drop, or declarative repo
.pipewright.yml, switchable per project; - Reads YAML from the run's branch, so a feature branch editing the pipeline only affects itself — config truly follows the branch;
- Missing/invalid YAML falls back to canvas config automatically, so runs never break; a preview endpoint to validate before you rely on it;
- A source badge in run detail so whether a run used repo YAML or web config is obvious at a glance.
Open source, MIT. Single binary, no runtime dependencies (frontend baked in via embed.FS, SQLite by default). Aimed at individual developers and small teams self-hosting.
Repo: https://github.com/huangchengsir/pipewright
But even if you never touch it, the core takeaway holds: canvas vs YAML isn't "which is more advanced," it's "which for which phase." And if you truly want both, the key is deciding that "one run obeys exactly one source," then using a single switch to migrate smoothly between them.
Issues and teardowns welcome.
Top comments (0)