<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: huangchengsir</title>
    <description>The latest articles on DEV Community by huangchengsir (@huangchengsir).</description>
    <link>https://dev.to/huangchengsir</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3987670%2Fa22d0c53-2e74-433b-9def-c3fb51c0f353.png</url>
      <title>DEV Community: huangchengsir</title>
      <link>https://dev.to/huangchengsir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/huangchengsir"/>
    <language>en</language>
    <item>
      <title>Should your pipeline live on a canvas, or in your repo? Two paradigms for self-hosted CI/CD</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:54:48 +0000</pubDate>
      <link>https://dev.to/huangchengsir/should-your-pipeline-live-on-a-canvas-or-in-your-repo-two-paradigms-for-self-hosted-cicd-3oka</link>
      <guid>https://dev.to/huangchengsir/should-your-pipeline-live-on-a-canvas-or-in-your-repo-two-paradigms-for-self-hosted-cicd-3oka</guid>
      <description>&lt;p&gt;Self-host a CI/CD tool long enough and you'll hit a philosophical fork: &lt;strong&gt;where should the pipeline definition actually live?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One camp is the &lt;em&gt;canvas&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The other camp is the &lt;em&gt;code&lt;/em&gt; crowd: the pipeline is just a YAML file in your repo — &lt;code&gt;.github/workflows/*.yml&lt;/code&gt;, &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;, &lt;code&gt;.pipewright.yml&lt;/code&gt; — committed, reviewed, and rolled back alongside your code. GitHub Actions made this camp mainstream.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;both&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The canvas: the good and the painful
&lt;/h2&gt;

&lt;p&gt;The visual canvas is genuinely nice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero learning curve.&lt;/strong&gt; No YAML fields to memorize. Drag and drop. New people are productive fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you see is what you get.&lt;/strong&gt; Stage dependencies (what runs after what, what runs in parallel) drawn as a graph are far more legible than a wall of indented YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change and run immediately.&lt;/strong&gt; Tweak a parameter in the browser, hit run. No commit, no push.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the canvas has two problems it can't escape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The config isn't in version control.&lt;/strong&gt; 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 &lt;code&gt;git log&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config and code live apart.&lt;/strong&gt; 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 &lt;strong&gt;project-level&lt;/strong&gt; config that doesn't follow branches. Change the canvas on main and your feature branches inherit it too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 2 is the killer. Half the reason GitHub Actions is so pleasant is exactly this: &lt;strong&gt;the workflow file follows the branch.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The code camp's answer: put the pipeline in &lt;code&gt;.pipewright.yml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The code camp's fix is direct — the pipeline is a YAML file at the repo root. In my tool it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stg_src&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fetch-source&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;job_src&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;git_source&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stg_build&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;stg_src&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# DAG dependency: build after source&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_compile&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compile&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;script&lt;/span&gt;
        &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang:1.24&lt;/span&gt;
          &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;go build ./...&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stg_deploy&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy_ssh&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;stg_build&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;gate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                &lt;span class="c1"&gt;# manual approval gate before deploy&lt;/span&gt;
    &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# only deploy on main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;docker-compose.yml&lt;/strong&gt; describes &lt;strong&gt;what the deployed artifact looks like&lt;/strong&gt;: which services, ports, volumes, networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.pipewright.yml&lt;/code&gt;&lt;/strong&gt; describes &lt;strong&gt;how the pipeline is orchestrated&lt;/strong&gt;: fetch source, then build, then deploy — which stage depends on which, which needs manual approval, which only runs on main.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The former is the &lt;em&gt;end state&lt;/em&gt;, the latter is the &lt;em&gt;process&lt;/em&gt;. A &lt;code&gt;deploy_ssh&lt;/code&gt; step inside &lt;code&gt;.pipewright.yml&lt;/code&gt; &lt;strong&gt;can&lt;/strong&gt; run &lt;code&gt;docker-compose up&lt;/code&gt;, but that's one of its actions, not the same layer of thing. Don't conflate them.&lt;/p&gt;

&lt;p&gt;Once the pipeline is in YAML, both canvas pains are cured: config is in version control (has &lt;code&gt;git log&lt;/code&gt;, reviewable, rollback-able), and config follows the branch (a feature branch editing the YAML only affects itself).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The real problem: how do two paradigms coexist
&lt;/h2&gt;

&lt;p&gt;Here, the lazy move is "pick one" — force users to choose all-canvas or all-YAML. But reality is: &lt;strong&gt;old projects already drew their pipeline on the canvas, and a new requirement wants YAML — you can't make people start over.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the real question is: &lt;strong&gt;when a single project has both a canvas config and a repo YAML, which does one run actually obey?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My approach is a &lt;strong&gt;per-project switch&lt;/strong&gt; (default off):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch off&lt;/strong&gt; (default): 100% canvas config. &lt;strong&gt;Even if a &lt;code&gt;.pipewright.yml&lt;/code&gt; exists in the repo, it's ignored&lt;/strong&gt; — never read. Old projects behave identically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch on&lt;/strong&gt;: prefer the repo YAML. At run time, it reads &lt;code&gt;.pipewright.yml&lt;/code&gt; from the repo root &lt;strong&gt;on the branch this run is for&lt;/strong&gt;, parses it, and drives this run with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three points, each worth spelling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. This is "pick one," not "merge."&lt;/strong&gt; A run's pipeline definition comes from &lt;strong&gt;exactly one source&lt;/strong&gt; — either the repo YAML (&lt;code&gt;repo&lt;/code&gt;) or the canvas config (&lt;code&gt;stored&lt;/code&gt;). There's no blending the two. Blending is a disaster: you'd never be able to say what actually ran. Pick one, cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It reads from the run's branch — the YAML camp's biggest win.&lt;/strong&gt; With the switch on, edit &lt;code&gt;.pipewright.yml&lt;/code&gt; on a feature branch, push it, and &lt;strong&gt;that branch's runs use the new YAML&lt;/strong&gt;, main unaffected. That's exactly the "config follows the branch" property the canvas couldn't give you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. On failure it silently falls back — the run never breaks.&lt;/strong&gt; YAML file missing? Malformed? A binary blob? — it &lt;strong&gt;automatically falls back to the canvas config&lt;/strong&gt; 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 &lt;em&gt;enhancement&lt;/em&gt;, it shouldn't become a &lt;em&gt;new single point of failure&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The fallback is a safety net — and a trap
&lt;/h2&gt;

&lt;p&gt;"Silent fallback" sounds considerate, but it has a flip side: &lt;strong&gt;silent&lt;/strong&gt; — it falls back without saying a word.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So I added a &lt;strong&gt;preview endpoint&lt;/strong&gt;: before actually running, you can have the system pull &lt;code&gt;.pipewright.yml&lt;/code&gt; 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, &lt;em&gt;then&lt;/em&gt; rely on it. &lt;strong&gt;Don't let "silent fallback" hide your config errors along with everything else.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The run detail view also carries a &lt;strong&gt;source badge&lt;/strong&gt;: whether this run used "repo &lt;code&gt;main&lt;/code&gt; · .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.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Being honest: the boundaries
&lt;/h2&gt;

&lt;p&gt;When you roll your own, you owe people the boundaries, so they don't use it on wrong assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No bidirectional sync.&lt;/strong&gt; Editing the canvas doesn't generate YAML; editing YAML doesn't write back to the canvas. It's strictly &lt;strong&gt;one-way&lt;/strong&gt;: 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YAML changes require a push to take effect.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YAML expresses a subset of the canvas's fields.&lt;/strong&gt; The advanced "variables &amp;amp; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed file location:&lt;/strong&gt; repo root, filename &lt;code&gt;.pipewright.yml&lt;/code&gt;. No subdirectories or renames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source is a "record," not a "force."&lt;/strong&gt; The badge tells you which source a run &lt;em&gt;used&lt;/em&gt;, but you can't say "force this one run to use the canvas" — switching is per-project only, no run-level override.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. So which should you pick?
&lt;/h2&gt;

&lt;p&gt;My experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Canvas&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YAML&lt;/strong&gt; fits — the pipeline has stabilized, you want it in version control and code review, you need "config follows the branch," you want &lt;em&gt;changing the pipeline itself&lt;/em&gt; to go through PR review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the point of "support both, one switch per project" is: &lt;strong&gt;you don't have to pick a side up front.&lt;/strong&gt; Get the flow working on the canvas first, then once it stabilizes, freeze it into &lt;code&gt;.pipewright.yml&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. I built all of this into a tool
&lt;/h2&gt;

&lt;p&gt;Everything above — visual canvas orchestration, declarative &lt;code&gt;.pipewright.yml&lt;/code&gt;, 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 &lt;strong&gt;Pipewright&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both paradigms:&lt;/strong&gt; web canvas drag-and-drop, or declarative repo &lt;code&gt;.pipewright.yml&lt;/code&gt;, switchable per project;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads YAML from the run's branch&lt;/strong&gt;, so a feature branch editing the pipeline only affects itself — config truly follows the branch;&lt;/li&gt;
&lt;li&gt;Missing/invalid YAML &lt;strong&gt;falls back&lt;/strong&gt; to canvas config automatically, so runs never break; a &lt;strong&gt;preview endpoint&lt;/strong&gt; to validate before you rely on it;&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;source badge&lt;/strong&gt; in run detail so whether a run used repo YAML or web config is obvious at a glance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open source, MIT. Single binary, no runtime dependencies (frontend baked in via &lt;code&gt;embed.FS&lt;/code&gt;, SQLite by default). Aimed at individual developers and small teams self-hosting.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But even if you never touch it, the core takeaway holds: &lt;strong&gt;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.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Issues and teardowns welcome.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>cicd</category>
      <category>gitops</category>
    </item>
    <item>
      <title>Zero-downtime deploys and one-click rollback for self-hosted apps — no Kubernetes</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Wed, 01 Jul 2026 14:53:55 +0000</pubDate>
      <link>https://dev.to/huangchengsir/zero-downtime-deploys-and-one-click-rollback-for-self-hosted-apps-no-kubernetes-1em0</link>
      <guid>https://dev.to/huangchengsir/zero-downtime-deploys-and-one-click-rollback-for-self-hosted-apps-no-kubernetes-1em0</guid>
      <description>&lt;p&gt;The most awkward moment in self-hosted deployment is the couple of seconds after you run &lt;code&gt;docker stop old &amp;amp;&amp;amp; docker run new&lt;/code&gt; — to the outside world, your service is a string of 502s. A user who clicks in during that window sees a big red error page.&lt;/p&gt;

&lt;p&gt;Rollback is even messier. Something breaks in prod, and now you're frantically digging for "which image tag was the last good one?" or squinting at a list of SHAs in &lt;code&gt;git log&lt;/code&gt; trying to guess which one was fine.&lt;/p&gt;

&lt;p&gt;Small teams, homelabs, internal services — most don't have Kubernetes' rolling updates and one-click rollback. Are you just stuck tolerating that window? I used to think so. Then I actually took it apart, and the conclusion is: &lt;strong&gt;zero-downtime deployment isn't about K8s-grade scheduling at all. It's three small things — stage the new version first, flip to it with an atomic operation, and flip straight back if it's wrong.&lt;/strong&gt; Here's how the pieces work.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The essence: compress "the switch" into one atomic action
&lt;/h2&gt;

&lt;p&gt;First, see clearly &lt;em&gt;why&lt;/em&gt; &lt;code&gt;stop old → run new&lt;/code&gt; returns 502s: between those two commands there's a vacuum where the old one is gone and the new one isn't up yet. No matter how short the window, any request that lands in it fails.&lt;/p&gt;

&lt;p&gt;The key mental shift is to split a deploy into &lt;strong&gt;two phases: "stage" and "switch"&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage phase&lt;/strong&gt;: put the complete new version &lt;em&gt;next to&lt;/em&gt; the old one (a new directory / a new image). During this, &lt;strong&gt;the old version keeps serving, untouched&lt;/strong&gt; — zero impact. It can be slow; nobody's affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch phase&lt;/strong&gt;: point from old to new. This step must be as &lt;strong&gt;atomic&lt;/strong&gt; as possible — ideally compressed into a single kernel-guaranteed operation, so the "vacuum" shrinks to nonexistent or a single instant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you internalize that, the rest is just finding the "atomic switch action" for each artifact type.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. File artifacts (jar / dist / static site): atomic symlink is the standard answer
&lt;/h2&gt;

&lt;p&gt;If you deploy a jar, a frontend &lt;code&gt;dist&lt;/code&gt;, or a static site, the standard solution is an &lt;strong&gt;atomic symlink switch&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;releases/
  20260701-a1b2c3/   &amp;lt;- this deploy's new version
  20260630-f9e8d7/   &amp;lt;- previous version
current -&amp;gt; releases/20260701-a1b2c3   &amp;lt;- one symlink; the app only knows "current"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On deploy, you first upload the new version into &lt;code&gt;releases/&amp;lt;new&amp;gt;/&lt;/code&gt; while leaving the &lt;code&gt;current&lt;/code&gt; symlink &lt;strong&gt;completely untouched&lt;/strong&gt;. Once the new version is fully staged, you flip the symlink. There's an easy-to-miss detail in &lt;em&gt;how&lt;/em&gt; you flip it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ❌ Has a window: ln -sfn is unlink + symlink, two steps; for one instant current doesn't exist&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; releases/new current

&lt;span class="c"&gt;# ✅ Atomic: create a temp symlink, then mv -T does a single rename(2) overwrite&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; releases/new current.tmp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; current.tmp current
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mv -T&lt;/code&gt; is a single &lt;code&gt;rename(2)&lt;/code&gt; syscall underneath, which the kernel guarantees is atomic — &lt;strong&gt;there is never an instant where &lt;code&gt;current&lt;/code&gt; points at nothing&lt;/strong&gt;. At any moment, &lt;code&gt;current&lt;/code&gt; points either at the complete old version or the complete new one. No in-between state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An honest but important caveat&lt;/strong&gt;: an atomic symlink doesn't mean zero process restart. If your app is a long-running process (say a jar that loads into memory and stays there), after flipping the symlink you still have to send it a reload signal so it re-reads &lt;code&gt;current&lt;/code&gt;. The symlink guarantees "&lt;code&gt;current&lt;/code&gt; always points at a complete version" — it does &lt;strong&gt;not&lt;/strong&gt; guarantee "the process never restarts." Don't conflate the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Containers: pull first, then swap — shrink the window to seconds
&lt;/h2&gt;

&lt;p&gt;Containers don't have the symlink layer, but the idea is identical — &lt;strong&gt;stage first, then switch&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;docker pull &amp;lt;new image&amp;gt;&lt;/code&gt;: pull the new image locally. During this, &lt;strong&gt;the old container keeps running&lt;/strong&gt; — zero impact. While you're at it, &lt;code&gt;docker inspect&lt;/code&gt; the old container's current image and record it as your &lt;strong&gt;rollback anchor&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker rm -f &amp;lt;old&amp;gt; &amp;amp;&amp;amp; docker run -d &amp;lt;new&amp;gt;&lt;/code&gt;: remove old, start new, two commands back-to-back to keep the window minimal.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The honest trade-off&lt;/strong&gt;: between &lt;code&gt;rm&lt;/code&gt; and the new container being fully ready, there's a brief "no container" window — usually seconds. For the vast majority of self-hosted scenarios, a few seconds is entirely acceptable. But &lt;strong&gt;don't pretend it's zero-window&lt;/strong&gt;. To truly get zero-window you need &lt;strong&gt;blue-green&lt;/strong&gt;: start the new container, health-check it, flip the reverse-proxy upstream from old to new, &lt;em&gt;then&lt;/em&gt; remove the old one. The cost is running two copies during the switch, plus reverse-proxy coordination. Rolling (seconds-long window) vs. blue-green (zero window, double the resources) — pick by how critical the service is. Don't reflexively reach for the most complex option.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Where the health check goes: after the switch, roll back on failure
&lt;/h2&gt;

&lt;p&gt;The new version is live — how do you know it didn't crash? &lt;strong&gt;Probe a health URI immediately after the switch&lt;/strong&gt;, and roll back to the previous version the moment it's unhealthy.&lt;/p&gt;

&lt;p&gt;One &lt;strong&gt;directional point&lt;/strong&gt; to be clear about: this is &lt;strong&gt;"verify after switching,"&lt;/strong&gt; not "canary before switching." The new version is already serving the instant you switch; the health check exists to "catch a broken deploy and back it out fast," not to "gate release until it's verified good." If you want "gate until verified good," what you need is blue-green (verify health on the side, flip traffic only once it passes).&lt;/p&gt;

&lt;p&gt;For a small team, "seconds-fast rollback after switch" is usually a better deal than "canary before switch" — far simpler to implement, and the only cost is a broken version being exposed for a few seconds. Your reverse proxy (e.g. Caddy) can add a second layer of &lt;strong&gt;passive health checking&lt;/strong&gt; (&lt;code&gt;health_uri&lt;/code&gt; + &lt;code&gt;health_interval&lt;/code&gt;) that automatically ejects unhealthy upstreams from the load balancer — one more safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. One-click rollback: don't dig for image tags — have the tool "re-ship the last success"
&lt;/h2&gt;

&lt;p&gt;The easiest way to get rollback wrong is to rely on &lt;strong&gt;human memory&lt;/strong&gt; — "was the last good image tag &lt;code&gt;v1.3.2&lt;/code&gt; or &lt;code&gt;v1.3.1&lt;/code&gt;?" People are least reliable exactly when something's on fire.&lt;/p&gt;

&lt;p&gt;The right approach: the system already stores &lt;strong&gt;the artifact + the target-server list&lt;/strong&gt; for every deploy. Rollback = find the &lt;strong&gt;"last deploy that fully succeeded across all servers"&lt;/strong&gt; and &lt;strong&gt;re-ship its artifact&lt;/strong&gt; to the same targets. The whole thing reuses the normal deploy path, so rollback itself runs the health check and can roll back again — it's not a special side-channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest trade-offs&lt;/strong&gt; you need to accept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One-click rollback = redeploying a historical version&lt;/strong&gt;, so its time and cost ≈ a fresh deploy (re-pull the image / re-upload the files). It is &lt;strong&gt;not&lt;/strong&gt; "instantly switch back to the old container." If you truly want a seconds-fast switch-back, you'd keep the old container around undeleted — a different trade-off (it sits there consuming resources).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It depends on history&lt;/strong&gt;: you can only roll back to the "last success" among the recent N deploys. If you've deleted the old tag from your image registry, &lt;code&gt;pull&lt;/code&gt; can't get it back and rollback fails. Don't rush to prune old images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One real gotcha&lt;/strong&gt; worth logging: to save display space, deploy records store the &lt;strong&gt;7-char short SHA&lt;/strong&gt;. But go-git's &lt;code&gt;CommitObject&lt;/code&gt; API &lt;strong&gt;only accepts the 40-char full SHA&lt;/strong&gt; — hand it a short SHA to look up a commit and you get "commit unreachable," and the feature silently degrades. The fix is to &lt;code&gt;ResolveRevision&lt;/code&gt; first (turning a short SHA / branch name / tag into a full hash), &lt;em&gt;then&lt;/em&gt; fetch the commit object. This "stored an abbreviation to save space, have to expand it at use time" trap is exactly the kind of thing you fall into when hand-rolling your own deploy tool. Note it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A few things that will bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Atomic symlink is only atomic for "the switch step"&lt;/strong&gt;; whether the app needs a restart to pick up the new version is a separate concern — wire up a reload for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container &lt;code&gt;rm&lt;/code&gt;+&lt;code&gt;run&lt;/code&gt; has a seconds-long window&lt;/strong&gt; — don't advertise it as zero-downtime. For true zero-window, go blue-green and eat the double-resource cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The health check is post-switch verification&lt;/strong&gt; — a failure means the broken version was already exposed for a moment. Fine? Use rolling. Not fine? Go blue-green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first-ever deploy has no "previous version" to roll back to&lt;/strong&gt; — a health failure is a hard failure that needs manual intervention. Don't expect the first ship to auto-recover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback cost ≈ a fresh deploy&lt;/strong&gt;, not a free instant switch. Treat it as "one automated redeploy," not an "undo button."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. I ended up building this into a tool
&lt;/h2&gt;

&lt;p&gt;None of the pieces are complex on their own, but you have to wire every one yourself: &lt;code&gt;mv -T&lt;/code&gt; for atomic symlinks, pull-then-swap for containers, choosing blue-green vs. rolling, post-switch health checks, auto-rollback on failure, one-click re-ship of the last success, &lt;code&gt;ResolveRevision&lt;/code&gt; for short SHAs... I packed all of this — along with the CI builds, multi-server deploys, Caddy auto-HTTPS, and Per-PR preview environments from earlier posts — into my single-binary Go deployment tool &lt;strong&gt;Pipewright&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File artifacts&lt;/strong&gt; switch via atomic symlink; &lt;strong&gt;containers&lt;/strong&gt; pull-then-swap with a recorded rollback anchor; &lt;strong&gt;blue-green / rolling&lt;/strong&gt; both available as strategies;&lt;/li&gt;
&lt;li&gt;runs a &lt;strong&gt;health check&lt;/strong&gt; automatically after the switch and &lt;strong&gt;auto-rolls-back&lt;/strong&gt; to the previous version if unhealthy;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;one-click rollback&lt;/strong&gt;: click once in the panel, it locates the last all-servers-success deploy and re-ships it — no memorizing image tags;&lt;/li&gt;
&lt;li&gt;short SHAs in deploy records and code diffs are auto-resolved via &lt;code&gt;ResolveRevision&lt;/code&gt;, so no "commit unreachable."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open source, MIT, single binary, no runtime dependencies (frontend baked in via &lt;code&gt;embed.FS&lt;/code&gt;, SQLite by default). Aimed at individual developers and small teams self-hosting.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But even if you never use it, the core takeaway holds: &lt;strong&gt;zero-downtime deploy = stage first + atomic switch + roll back after; one-click rollback = re-ship the last successful version.&lt;/strong&gt; No Kubernetes required — self-hosting can have this experience too. It really isn't magic.&lt;/p&gt;

&lt;p&gt;Issues and pushback welcome.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>docker</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Per-PR preview environments for self-hosted apps aren't actually magic</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Fri, 26 Jun 2026 15:08:34 +0000</pubDate>
      <link>https://dev.to/huangchengsir/per-pr-preview-environments-for-self-hosted-apps-arent-actually-magic-4i8c</link>
      <guid>https://dev.to/huangchengsir/per-pr-preview-environments-for-self-hosted-apps-arent-actually-magic-4i8c</guid>
      <description>&lt;p&gt;Anyone who's used Vercel or Netlify has been spoiled by one feature: open a PR, get a unique preview link automatically. Click it and you see exactly what that branch renders. Reviewers don't pull the code or spin up anything locally — they glance at a link and know whether the change is right. Merge or close the PR, and the environment quietly disappears.&lt;/p&gt;

&lt;p&gt;Then you go back to the self-hosted world — your own backend, internal services, a small team's homegrown tooling — and that experience is suddenly gone. Want to review a frontend change? Ask for a screenshot, or &lt;code&gt;git fetch&lt;/code&gt; it and run it yourself.&lt;/p&gt;

&lt;p&gt;I used to think Per-PR preview environments were Vercel-grade platform magic that self-hosting just couldn't reach. Then I actually took one apart. The conclusion: &lt;strong&gt;it's really just three things — a dynamic subdomain, a temporary reverse-proxy route, and automatic cleanup.&lt;/strong&gt; Here's how the pieces fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Strip the magic: what a preview environment actually is
&lt;/h2&gt;

&lt;p&gt;Peel off the "platform magic" wrapper and a Per-PR preview environment only has to answer three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where does this PR's app run?&lt;/strong&gt; Your CI already builds and deploys a container (or a port) for that branch. The preview doesn't need to spin up a &lt;em&gt;second&lt;/em&gt; copy — reuse the artifact this deployment already produced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How does it get its own entrypoint?&lt;/strong&gt; Give it a &lt;strong&gt;dedicated subdomain&lt;/strong&gt;, e.g. &lt;code&gt;pr-128.preview.example.com&lt;/code&gt;, and point a reverse proxy at the container above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who cleans up after the PR is gone?&lt;/strong&gt; Once the PR merges or closes, something has to reclaim that route and subdomain — otherwise after a few months you've accumulated a pile of zombie environments.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you see those three points, you'll notice everything from my earlier posts applies directly: the &lt;strong&gt;dynamic subdomain&lt;/strong&gt; rides on a &lt;strong&gt;wildcard certificate&lt;/strong&gt; (one cert for &lt;code&gt;*.preview.example.com&lt;/code&gt; covers every PR subdomain — no per-PR issuance), and the &lt;strong&gt;route&lt;/strong&gt; is just a single &lt;code&gt;reverse_proxy&lt;/code&gt;. The rest is wiring them into an automatic flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Dynamic subdomains: the wildcard cert is the prerequisite
&lt;/h2&gt;

&lt;p&gt;Without a wildcard cert, issuing a fresh certificate for &lt;code&gt;pr-128.preview.example.com&lt;/code&gt; on every PR will instantly hit Let's Encrypt's rate limit (50 certs per registered domain per week). Open a few dozen PRs and you've burned through it.&lt;/p&gt;

&lt;p&gt;The right move is to issue &lt;strong&gt;one&lt;/strong&gt; wildcard cert for &lt;code&gt;*.preview.example.com&lt;/code&gt; (via DNS-01 validation — covered in the last post), and every &lt;code&gt;pr-N.preview.example.com&lt;/code&gt; reuses it with zero extra issuance. The Caddy config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*.preview.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    # dynamic routing table: pr-128 -&amp;gt; 10.0.0.5:auto, written by your program
    reverse_proxy {
        to {http.reverse_proxy.upstream}
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real setup that upstream isn't hardcoded — your deploy program &lt;strong&gt;writes a route dynamically&lt;/strong&gt; the moment a PR deployment succeeds: &lt;code&gt;pr-128.preview.example.com&lt;/code&gt; → the container IP:port this deploy produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Wire it to the "deploy succeeded" event
&lt;/h2&gt;

&lt;p&gt;The most natural trigger for a preview environment isn't "PR opened" — it's &lt;strong&gt;"this PR's code deployed successfully."&lt;/strong&gt; The reasoning is practical: when a PR is first opened the code might not even compile, so spinning up an environment is wasted. Once CI actually builds and deploys that branch, the artifact is already running — allocating a subdomain that points at it is nearly free.&lt;/p&gt;

&lt;p&gt;So the full chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR pushed → CI builds → deploy succeeds (container is running)
                              │
                              ▼
              terminal hook: does this run belong to a PR?
                  ├─ parse PR number from branch name (pr-128 / pull/128 / bare digits)
                  ├─ is preview enabled for this project?
                  └─ find the container/port this deploy produced
                              │
                              ▼
              write a route pr-128.preview.example.com → container
                  record a preview-env row (PR number, subdomain, status=active)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's an &lt;strong&gt;honest but important&lt;/strong&gt; design tradeoff here: a run (one deployment) usually &lt;strong&gt;doesn't carry the PR number directly&lt;/strong&gt;. The cleanest engineering move isn't to rip through the whole deploy pipeline stuffing in a PR field — it's to &lt;strong&gt;parse it from the branch name by convention&lt;/strong&gt;: &lt;code&gt;pr-128&lt;/code&gt;, &lt;code&gt;pull/128&lt;/code&gt;, even a bare-digit branch &lt;code&gt;128&lt;/code&gt;, all parse best-effort; if it doesn't parse, silently skip, and &lt;strong&gt;never disrupt a normal deploy&lt;/strong&gt;. Likewise, the upstream container reuses the project's &lt;strong&gt;existing outbound route&lt;/strong&gt; on the target host rather than inventing a new one. Reuse existing information instead of fabricating data — that's the key to staying out of trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Auto-cleanup: the real cure for zombie environments
&lt;/h2&gt;

&lt;p&gt;Spinning up is easy; the hard part is tearing down. Manual cleanup &lt;em&gt;will&lt;/em&gt; be forgotten, and a few weeks later your reverse proxy is full of dead routes from long-merged PRs.&lt;/p&gt;

&lt;p&gt;The reliable approach is a &lt;strong&gt;periodic sweeper&lt;/strong&gt;: walk all &lt;code&gt;active&lt;/code&gt; preview environments on an interval and re-check the corresponding PR's status —&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PR merged / closed → delete the route, mark the env &lt;code&gt;reclaimed&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;PR still open → leave it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make reclamation a &lt;strong&gt;reconciliation-style periodic check&lt;/strong&gt; rather than relying on a precise "PR closed" event, because webhooks get dropped and arrive out of order, while periodic reconciliation is &lt;strong&gt;idempotent&lt;/strong&gt;: miss it this round, catch it next round, never pile up. This is one of the most robust patterns in self-hosted land — &lt;strong&gt;prefer periodic reconciliation over betting on a one-shot event.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A few things that'll bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain depth&lt;/strong&gt;: &lt;code&gt;*.preview.example.com&lt;/code&gt; only covers one level. Don't use &lt;code&gt;pr-128.app.preview.example.com&lt;/code&gt; (two levels) — the wildcard cert won't cover it. Flatten preview subdomains to one level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data isolation&lt;/strong&gt;: previews run real containers. If one points at your &lt;strong&gt;production database&lt;/strong&gt;, a reviewer clicking around in the preview could mutate prod data. Previews should hit a separate test DB, or be explicitly read-only. This matters more than the technical implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't let previews eat all your resources&lt;/strong&gt;: one container per PR means memory blows up as PRs pile on. Add a cap (e.g. at most N concurrent previews) or a per-preview quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reclamation must be idempotent&lt;/strong&gt;: confirm a route still exists before deleting it; don't abort the whole sweeper because "it was already deleted."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. I eventually built this into a tool
&lt;/h2&gt;

&lt;p&gt;None of the pieces is complex on its own, but you have to wire every link yourself: the wildcard cert, the dynamic route writes, PR-number parsing, the terminal hook, the cleanup sweeper — and you have to guarantee that "allocating a preview" &lt;strong&gt;can never drag down a normal deploy&lt;/strong&gt; (any step failing has to degrade gracefully and skip silently). I packed all of this, alongside the CI builds, multi-host deploys, and Caddy auto-HTTPS from earlier posts, into my Go single-binary deploy tool &lt;strong&gt;Pipewright&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A per-project &lt;strong&gt;preview toggle&lt;/strong&gt;; once on, a successful PR deploy auto-allocates a &lt;code&gt;pr-&amp;lt;n&amp;gt;&lt;/code&gt; subdomain — no hand-written routes;&lt;/li&gt;
&lt;li&gt;Fully &lt;strong&gt;best-effort&lt;/strong&gt;: can't parse a PR number, no DNS configured, no container found → silently skip, &lt;strong&gt;never block the deploy&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;preview-env panel&lt;/strong&gt; showing which PRs have live previews, their subdomains, and status at a glance;&lt;/li&gt;
&lt;li&gt;A periodic sweeper that reclaims by real PR status, so merged/closed environments need no manual cleanup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's essentially the "dynamic subdomain + temporary reverse proxy + auto cleanup" from this post, productized to skip the hand-rolling. MIT-licensed, single binary, no runtime deps (frontend baked in via &lt;code&gt;embed.FS&lt;/code&gt;, SQLite by default), aimed at individual devs / small teams self-hosting.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But even if you never touch it, the takeaway stands: &lt;strong&gt;Per-PR preview environments aren't out-of-reach platform magic for self-hosting — they're a wildcard cert + a dynamic reverse-proxy route + a periodic reclaimer.&lt;/strong&gt; Wire those three together and your self-hosted setup gets the "one link per PR" experience too.&lt;/p&gt;

&lt;p&gt;Issues and pushback welcome.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>docker</category>
      <category>caddy</category>
    </item>
    <item>
      <title>Stop hand-rolling nginx + certbot for your self-hosted HTTPS</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:56:32 +0000</pubDate>
      <link>https://dev.to/huangchengsir/stop-hand-rolling-nginx-certbot-for-your-self-hosted-https-1g47</link>
      <guid>https://dev.to/huangchengsir/stop-hand-rolling-nginx-certbot-for-your-self-hosted-https-1g47</guid>
      <description>&lt;p&gt;If you've done any self-hosting - a homelab, internal services, a small team's own tooling - you've probably been through this ritual:&lt;/p&gt;

&lt;p&gt;A service is running, you want to put a domain and HTTPS in front of it. So you install nginx, write a reverse-proxy config, install certbot, request a Let's Encrypt cert, and wire up a cron job to renew it. Three months later, the day the cert expires, you discover the cron path was wrong, or certbot's webroot mode is fighting your nginx config, and the site is a wall of red.&lt;/p&gt;

&lt;p&gt;The third time I went through this, I decided to replace the whole thing. The short version: &lt;strong&gt;for self-hosted HTTPS, Caddy + DNS-01 gets you to "set it once, never touch it again."&lt;/strong&gt; Here's the part that's actually worth knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What Caddy's automatic HTTPS actually saves you
&lt;/h2&gt;

&lt;p&gt;Caddy's most underrated feature is that the entire ACME flow is built in. No separate certbot, no renewal cron. A whole Caddyfile can be this short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.example.com {
    reverse_proxy localhost:8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. On startup Caddy will, on its own:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request a cert for &lt;code&gt;app.example.com&lt;/code&gt; from Let's Encrypt;&lt;/li&gt;
&lt;li&gt;Store it locally and renew it automatically before expiry;&lt;/li&gt;
&lt;li&gt;Redirect port 80 to 443 with sane modern TLS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compared to nginx + certbot, what you delete is: the cert-request script, the renewal cron, the redirect rules, the TLS tuning. nginx &lt;em&gt;can&lt;/em&gt; do all of this - it just makes you assemble every piece by hand, while Caddy ships "the correct defaults" as factory settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The real killer: DNS-01 gives certs to internal services too
&lt;/h2&gt;

&lt;p&gt;The above is HTTP-01 validation: Let's Encrypt calls back to your port 80 to confirm you own the domain. The problem - &lt;strong&gt;what if your service is on a private network?&lt;/strong&gt; The public internet can't reach your port 80, so HTTP-01 is simply out. This is the most common wall in self-hosting: a NAS, an internal admin panel, a homelab box - you want HTTPS but you're stuck at validation.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;DNS-01 validation&lt;/strong&gt;: instead of checking a port, it checks whether you can edit the domain's DNS records. Caddy uses your DNS provider's API (Cloudflare, Alibaba DNS, DNSPod, etc.) to write a TXT record automatically and complete validation. No inbound port ever has to be exposed to the public internet.&lt;/p&gt;

&lt;p&gt;Better still, DNS-01 supports &lt;strong&gt;wildcard certs&lt;/strong&gt; (&lt;code&gt;*.example.com&lt;/code&gt;). Get one wildcard cert and every internal subdomain - &lt;code&gt;grafana.lab.example.com&lt;/code&gt;, &lt;code&gt;nas.lab.example.com&lt;/code&gt;, &lt;code&gt;pad.lab.example.com&lt;/code&gt; - is covered by a single cert; new services don't need a fresh request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*.lab.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    @grafana host grafana.lab.example.com
    handle @grafana {
        reverse_proxy localhost:3000
    }
    @nas host nas.lab.example.com
    handle @nas {
        reverse_proxy localhost:5000
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the official Caddy binary &lt;strong&gt;does not ship DNS plugins&lt;/strong&gt; - you have to rebuild Caddy with the plugin for your provider (&lt;code&gt;xcaddy build --with github.com/caddy-dns/cloudflare&lt;/code&gt;). This is where a lot of people get stuck: the official image throws "provider not found" on DNS-01, and you need to &lt;code&gt;xcaddy&lt;/code&gt; your own build or use an image that bundles the plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The things that will bite you
&lt;/h2&gt;

&lt;p&gt;Once it's running, a few things you only learn by stepping on them:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Wildcard cert scope
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;*.example.com&lt;/code&gt; only covers &lt;strong&gt;one level&lt;/strong&gt; of subdomain, not &lt;code&gt;a.b.example.com&lt;/code&gt; (two levels). For two levels you need a separate &lt;code&gt;*.b.example.com&lt;/code&gt;. Plan your subdomain hierarchy with this in mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. DNS API token scope
&lt;/h3&gt;

&lt;p&gt;The DNS token you hand Caddy should be scoped to &lt;strong&gt;edit TXT records on that one zone only&lt;/strong&gt; - don't take the lazy path and give it an account-wide token. That token is partial control of your domain; leaked, it can be used to sign certs for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Let's Encrypt rate limits
&lt;/h3&gt;

&lt;p&gt;50 certs per registered domain per week. Normal use won't hit it, but if you script container rebuilds that re-request a cert every time, you can easily get throttled until next week. Always persist the cert directory (mount a volume) so it isn't re-requested on every restart.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Don't forget WebSocket / gRPC
&lt;/h3&gt;

&lt;p&gt;Reverse-proxying plain HTTP is fine, but if you later put a WebSocket service behind it (lots of admin panels stream live logs over WS), make sure the proxy forwards the &lt;code&gt;Upgrade&lt;/code&gt; header correctly. Caddy's &lt;code&gt;reverse_proxy&lt;/code&gt; does this by default, but it's a classic faceplant if you're hand-writing nginx config.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. And then I packaged this too
&lt;/h2&gt;

&lt;p&gt;I understood all of it, but rebuilding a plugin-enabled Caddy, writing a Caddyfile, configuring a DNS token, and tracking which subdomain points to which backend - for every new machine and every new service - still got old. So I folded it, along with the CI builds, multi-host deploys, and container management from before, into my single-binary Go deploy tool, &lt;strong&gt;Pipewright&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bundled &lt;strong&gt;self-built Caddy image&lt;/strong&gt; (with the major DNS plugins), so DNS-01 works out of the box;&lt;/li&gt;
&lt;li&gt;Bind a domain, set up a wildcard cert, configure path routing - all from a &lt;strong&gt;few clicks&lt;/strong&gt; in the web panel, no hand-written Caddyfile;&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;certificate overview&lt;/strong&gt; panel: every domain's cert status and expiry at a glance, no more &lt;code&gt;openssl s_client&lt;/code&gt; one at a time;&lt;/li&gt;
&lt;li&gt;And the feature I personally wanted most: &lt;strong&gt;per-PR preview environments&lt;/strong&gt; - every PR spins up its own environment on a dedicated subdomain, torn down automatically when it's merged or closed. The "every PR gets a preview link" Vercel experience, self-hosted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's basically the "Caddy auto-HTTPS + DNS-01 wildcard" from this post, productized to skip the hand-rolling. MIT-licensed, single binary, no runtime deps (frontend baked in with &lt;code&gt;embed.FS&lt;/code&gt;, SQLite by default), aimed at individual developers and small teams.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But even if you never touch it, the core of this post stands: &lt;strong&gt;stop hand-rolling nginx + certbot for self-hosted HTTPS.&lt;/strong&gt; Caddy + DNS-01 + a wildcard cert - configure once and forget it exists.&lt;/p&gt;

&lt;p&gt;Issues and pushback welcome.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>docker</category>
      <category>caddy</category>
    </item>
    <item>
      <title>You probably don't need ArgoCD - good-enough GitOps with git and docker compose</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Thu, 18 Jun 2026 13:31:20 +0000</pubDate>
      <link>https://dev.to/huangchengsir/you-probably-dont-need-argocd-good-enough-gitops-with-git-and-docker-compose-4jk3</link>
      <guid>https://dev.to/huangchengsir/you-probably-dont-need-argocd-good-enough-gitops-with-git-and-docker-compose-4jk3</guid>
      <description>&lt;p&gt;Every time someone starts self-hosting - a homelab, a few internal services for a small team - they tend to fall down the same rabbit hole: should I run K8s? And if I run K8s, do I need ArgoCD or Flux for GitOps? Two weeks later they've read a pile of Helm charts and CRDs and still haven't deployed a single service.&lt;/p&gt;

&lt;p&gt;Let me say the quiet part out loud: &lt;strong&gt;for a single host, or three-to-five machines, you do not need ArgoCD.&lt;/strong&gt; The part of GitOps you actually want - "the git repo is the source of truth, changes deploy themselves, I can roll back, and there's a record" - you can get 90% of it with git + docker compose and about twenty lines of script, with no control plane to babysit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good-enough GitOps" actually is
&lt;/h2&gt;

&lt;p&gt;GitOps boils down to two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Declarative&lt;/strong&gt;: the desired state lives in git (for self-hosting, that's your &lt;code&gt;docker-compose.yml&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull-based&lt;/strong&gt;: the machine pulls changes and reconciles itself, instead of you SSHing in to type commands.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On one docker host, the minimal version looks like this: a webhook (or a 60-second &lt;code&gt;git fetch&lt;/code&gt;) notices the tracked branch moved, and runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull &lt;span class="nt"&gt;--ff-only&lt;/span&gt;
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--remove-orphans&lt;/code&gt; matters: services you delete from the compose file actually get stopped, instead of lingering forever. That's it - you now have a working GitOps loop: edit compose -&amp;gt; push -&amp;gt; the machine reconciles itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things that will actually bite you
&lt;/h2&gt;

&lt;p&gt;The good-enough version runs, but there are two traps anyone who's done this knows:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Zero-downtime
&lt;/h3&gt;

&lt;p&gt;Plain &lt;code&gt;docker compose up -d&lt;/code&gt; &lt;strong&gt;recreates&lt;/strong&gt; the container - old one stops, new one starts, with a few seconds of gap in between. Fine for background jobs, but for anything user-facing those few seconds are a 502.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;compose up --wait&lt;/code&gt; (v2.17+) at least &lt;strong&gt;waits for the healthcheck&lt;/strong&gt; before calling the deploy a success, which catches the "starts then crashes" case;&lt;/li&gt;
&lt;li&gt;for true zero-downtime you end up running two service names (blue/green) behind a reverse proxy, flipping traffic once the new one is healthy, then stopping the old.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't expect compose to do graceful rolling updates for you. It doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Rollback
&lt;/h3&gt;

&lt;p&gt;This is the one most people skip and the one that hurts most. If your image tag is &lt;code&gt;:latest&lt;/code&gt;, then "rollback" means "edit the file and pray" - you have no idea which version last worked.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;pin image tags to the git commit SHA&lt;/strong&gt; (&lt;code&gt;myapp:9f8322f&lt;/code&gt;, not &lt;code&gt;myapp:latest&lt;/code&gt;), and &lt;strong&gt;record which SHA is currently live&lt;/strong&gt;. Now rollback degrades to "re-deploy the previous SHA" - deterministic and repeatable. Without it, your GitOps quietly turns into "git pull and hope".&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should reach for a heavier tool
&lt;/h2&gt;

&lt;p&gt;The boundary is clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One machine&lt;/strong&gt;: the twenty-line script is genuinely fine. Don't overthink it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3+ machines, or you need an audit trail of who deployed what, when&lt;/strong&gt;: this is where hand-rolled scripts start accruing debt - you need concurrent multi-host deploys, unified rollback, a record of who triggered it. That's when a thicker tool starts paying for itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But note: that threshold is much later than most people assume. For the vast majority of self-hosting, you're still on the "twenty-line script is fine" side.&lt;/p&gt;

&lt;h2&gt;
  
  
  I eventually packaged this up
&lt;/h2&gt;

&lt;p&gt;I understood all of the above, and still got tired of re-typing the webhook + pull + compose + SHA-pinning + rollback-record dance every time I set up a new machine. So I bundled it - along with the CI build step before it, agentless multi-host SSH deploys, and container management - into a single Go binary called &lt;strong&gt;Pipewright&lt;/strong&gt;: scp one file to a server, run it, open the browser, and you get a visual pipeline + one-click deploy + a container panel, with no other runtime dependencies (the Vue frontend is compiled into the binary via &lt;code&gt;embed.FS&lt;/code&gt;, SQLite by default).&lt;/p&gt;

&lt;p&gt;It's basically the "good-enough GitOps" above, productized, with the zero-downtime and SHA-based rollback gaps filled in. MIT licensed, aimed at solo devs and small teams - not trying to replace GitLab for a 200-engineer org.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But even if you never touch it, the takeaway stands: &lt;strong&gt;for self-hosted GitOps, you probably don't need ArgoCD.&lt;/strong&gt; Start with git + compose, and only add weight when you actually hit the wall.&lt;/p&gt;

&lt;p&gt;Issues and pushback welcome.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>go</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>I built a self-hosted CI/CD + deploy + ops platform that fits in one Go binary</title>
      <dc:creator>huangchengsir</dc:creator>
      <pubDate>Tue, 16 Jun 2026 15:51:21 +0000</pubDate>
      <link>https://dev.to/huangchengsir/i-built-a-self-hosted-cicd-deploy-ops-platform-that-fits-in-one-go-binary-5g8</link>
      <guid>https://dev.to/huangchengsir/i-built-a-self-hosted-cicd-deploy-ops-platform-that-fits-in-one-go-binary-5g8</guid>
      <description>&lt;p&gt;I run a handful of small VPS boxes for side projects. For a while my "deploy setup" was the usual trio: a CI runner (Jenkins, then Drone), something to push releases to servers (Ansible / Kamal scripts), and Portainer to actually &lt;em&gt;see&lt;/em&gt; what was running and poke at containers.&lt;/p&gt;

&lt;p&gt;On a 2GB box, wiring and babysitting three separate tools was more overhead than the apps I was shipping. So a few weeks ago I started building the thing I actually wanted: &lt;strong&gt;CI/CD + multi-server deployment + basic ops, in a single Go binary with zero runtime dependencies.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's called &lt;strong&gt;Pipewright&lt;/strong&gt;. You scp one file to a server, run it, open the browser — that's the whole install.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual pipeline builder&lt;/strong&gt; — a two-level (stage → job) DAG canvas. Horizontal links run serially, vertical ones run in true parallel. It round-trips with &lt;code&gt;.pipewright.yml&lt;/code&gt;, so you can click &lt;em&gt;or&lt;/em&gt; commit your pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated builds&lt;/strong&gt; — version-pinned, container-isolated build steps with dependency caching; artifacts (images / JARs / dist bundles) you can push to a private registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentless deploys over SSH&lt;/strong&gt; — zero-downtime switchover + rollback on failure, fan-out to multiple servers with partial-failure visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server &amp;amp; container ops&lt;/strong&gt; — manage containers / images / stacks / volumes / networks across hosts, live stats, logs, and an in-browser terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can update itself&lt;/strong&gt; — checks GitHub for the latest release and does a download → checksum verify → atomic replace → restart, from the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc54p20ufl8b9tzum1nqv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc54p20ufl8b9tzum1nqv.png" alt="Visual pipeline canvas" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Go backend, Vue 3 frontend, everything embedded into the one binary. SQLite by default (zero setup), MySQL if you want it. Auth is argon2id + CSRF, credentials live in an encrypted vault (never echoed back in plaintext), and there's an append-only audit log. ~50MB binary, runs comfortably on a tiny box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb27mw4kbpmzp160dm6m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb27mw4kbpmzp160dm6m.png" alt="Run detail with live logs" width="800" height="656"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedding the whole frontend into the Go binary&lt;/strong&gt; (&lt;code&gt;embed.FS&lt;/code&gt;) is underrated. One artifact, no nginx, no separate static host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-update is a great forcing function.&lt;/strong&gt; The moment your tool updates itself in production, you stop being sloppy about release artifacts, checksums, and atomic file replacement. I've been dogfooding Pipewright to ship its own releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"One binary for all of it" is a real design tension.&lt;/strong&gt; It's tempting to keep adding surface area. I keep reminding myself the goal is the &lt;em&gt;opposite&lt;/em&gt; of Jenkins-at-scale: the lightest thing that still does the full build → deploy → watch loop for a few servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it's NOT
&lt;/h2&gt;

&lt;p&gt;It's not trying to replace GitLab/Jenkins for a 200-engineer org. No multi-tenant RBAC, no massive plugin ecosystem. If you're running a big org, use the big tools. If you're a solo dev or small team who just wants build → deploy → ops without standing up three services, that's exactly who I built it for.&lt;/p&gt;

&lt;p&gt;It's early (a few weeks old) and I'm actively building. I'd genuinely love feedback — especially on whether the "all-in-one binary" framing makes sense to you, or whether it's doing too much.&lt;/p&gt;

&lt;p&gt;Repo (MIT, screenshots + one-command quickstart): &lt;strong&gt;&lt;a href="https://github.com/huangchengsir/pipewright" rel="noopener noreferrer"&gt;https://github.com/huangchengsir/pipewright&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading 🙏&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>devops</category>
      <category>go</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
