<?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: Baryo Dev</title>
    <description>The latest articles on DEV Community by Baryo Dev (@baryo_dev).</description>
    <link>https://dev.to/baryo_dev</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%2F1968688%2Ff160c549-0050-48e5-9cf9-387e251c897e.png</url>
      <title>DEV Community: Baryo Dev</title>
      <link>https://dev.to/baryo_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/baryo_dev"/>
    <language>en</language>
    <item>
      <title>I built an agentless deploy CLI in Go because every PaaS wanted to own my server</title>
      <dc:creator>Baryo Dev</dc:creator>
      <pubDate>Tue, 25 Aug 2026 03:58:11 +0000</pubDate>
      <link>https://dev.to/baryo_dev/i-built-an-agentless-deploy-cli-in-go-because-every-paas-wanted-to-own-my-server-3da</link>
      <guid>https://dev.to/baryo_dev/i-built-an-agentless-deploy-cli-in-go-because-every-paas-wanted-to-own-my-server-3da</guid>
      <description>&lt;p&gt;Everything I run in production lives on one inexpensive ARM VM: a headless CMS, a club management app, two Umbraco package demos, a job portal, analytics, and the databases under all of it. Nineteen containers across seven projects.&lt;/p&gt;

&lt;p&gt;None of it earns enough to justify a platform subscription, and all of it needs the things a platform gives you: deploys that do not lose data, backups that exist, and a way back when a release goes wrong.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/BaryoDev/BaryoVM" rel="noopener noreferrer"&gt;BaryoVM&lt;/a&gt;, a small Go CLI that registers VMs you already have and drives the compose stacks on them over plain SSH. Nothing is installed on the far side except Docker. No agent, no daemon, no control plane.&lt;/p&gt;

&lt;p&gt;This post is the parts that were not obvious when I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# register a VM you already have&lt;/span&gt;
baryovm vm add oracle &lt;span class="nt"&gt;--host&lt;/span&gt; &amp;lt;ip&amp;gt; &lt;span class="nt"&gt;--user&lt;/span&gt; opc &lt;span class="nt"&gt;--key&lt;/span&gt; ~/.ssh/id_ed25519
baryovm vm ping oracle
baryovm vm bootstrap oracle     &lt;span class="c"&gt;# installs Docker if missing&lt;/span&gt;

&lt;span class="c"&gt;# register a stack: a compose project dir on that VM&lt;/span&gt;
baryovm stack add app &lt;span class="nt"&gt;--vm&lt;/span&gt; oracle &lt;span class="nt"&gt;--path&lt;/span&gt; /home/deploy/app/deploy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-container&lt;/span&gt; app-postgres-1 &lt;span class="nt"&gt;--db-name&lt;/span&gt; app &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env

&lt;span class="c"&gt;# deploy: sync -&amp;gt; build on the VM -&amp;gt; backup -&amp;gt; compose up&lt;/span&gt;
baryovm stack release app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State is one file, &lt;code&gt;~/.baryovm/fleet.json&lt;/code&gt;, written &lt;code&gt;0600&lt;/code&gt;. It holds hostnames, users and &lt;strong&gt;paths to SSH keys, never key material&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Kamal, Coolify, Dokku, Ansible
&lt;/h2&gt;

&lt;p&gt;I did not build this because those are bad. I built it because each wants to own something I could not give it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coolify, CapRover and Dokku want the server.&lt;/strong&gt; You install a platform on the box and the box becomes theirs. Coolify's installation docs are upfront: use a fresh server, to avoid conflicts with existing applications. Dokku describes itself as running on a single server of your choice. Both are the right call for a machine you are starting from scratch, and the wrong call for a client machine already running three compose projects and an nginx config somebody tuned two years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kamal wants the app.&lt;/strong&gt; Config lives in the app repo as &lt;code&gt;config/deploy.yml&lt;/code&gt;, one app at a time. That is exactly right with one app; five clients means five repos. It also leaves &lt;code&gt;kamal-proxy&lt;/code&gt; on the far side (that is what &lt;code&gt;kamal remove&lt;/code&gt; cleans up), and its command list has no backup or restore verb.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ansible wants nothing&lt;/strong&gt;, which is both the point and the cost. It will do all of this after you write every bit of it.&lt;/p&gt;

&lt;p&gt;The case none of them target is the one I actually live in: &lt;strong&gt;maintaining machines you did not build.&lt;/strong&gt; Registering what is already running, without asking the machine to become a platform first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I got wrong first
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. rsync --delete will eat your secrets
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stack release&lt;/code&gt; is config-driven. A JSON manifest says what to sync and what to build, so the CLI holds no app-specific logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"localRoot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/repos/app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"remoteRoot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/home/deploy/app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sync"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dockerfile"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exclude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"obj"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node_modules"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".git"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compose directory, the one holding &lt;code&gt;.env&lt;/code&gt;, is &lt;strong&gt;deliberately never syncable&lt;/strong&gt;. Sync runs with &lt;code&gt;--delete&lt;/code&gt;. If the compose dir were in that list, one release would wipe the production secrets file. That is not a warning in the docs, it is a structural exclusion, because a rule you have to remember is a rule you will forget at 11pm.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A backup after the change is not a backup
&lt;/h3&gt;

&lt;p&gt;The release takes the database dump &lt;strong&gt;before&lt;/strong&gt; anything changes. Obvious in hindsight, and I had it the other way round at first, because taking it after felt like capturing the new state. It captures the wrong state: if the deploy broke something, the only dump you have contains the break.&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="nv"&gt;$BK&lt;/span&gt;/db-20260825-031200.dump      &lt;span class="c"&gt;# pg_dump -Fc&lt;/span&gt;
&lt;span class="nv"&gt;$BK&lt;/span&gt;/env-20260825-031200          &lt;span class="c"&gt;# the config file, mode 600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourteen retained, older ones pruned.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Unattended code should refuse more than it accepts
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stack update --auto&lt;/code&gt; pulls new images, recreates, health-checks, and rolls back to the recorded images if the new containers do not come up. Because it runs with nobody watching, most of the logic is refusals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;refuses a stack not explicitly marked &lt;code&gt;autoUpdate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;refuses a stack with no &lt;code&gt;healthUrl&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;refuses &lt;code&gt;--auto&lt;/code&gt; combined with &lt;code&gt;--no-backup&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reasoning in the code is one line: an unattended update keeps a way back. And the rollback path does not just restore the old images, it re-checks health afterwards, so the report says service came back rather than saying it tried.&lt;/p&gt;

&lt;p&gt;Writing this taught me the general version: &lt;strong&gt;in unattended code, the refusals are the feature.&lt;/strong&gt; Everything else is the happy path anyone can write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything speaks JSON, which is not the same as a contract
&lt;/h2&gt;

&lt;p&gt;Every command takes &lt;code&gt;-o json&lt;/code&gt;, so a UI, a CI job or an MCP server can drive the same surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;baryovm stack release app &lt;span class="nt"&gt;-o&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I audited my own output, that promise turned out to be softer than advertised. A failing command emitted &lt;strong&gt;two&lt;/strong&gt; JSON documents on stdout. Pre-flight errors named the wrong action. Flag-parse errors emitted no JSON at all. &lt;code&gt;-o json&lt;/code&gt; did not suppress cobra's help text.&lt;/p&gt;

&lt;p&gt;None of that breaks a human reading a terminal, and all of it breaks a program. So "make the JSON a contract" is now four issues rather than a checkbox: exactly one document per invocation, a declared shape per command's &lt;code&gt;data&lt;/code&gt;, stable error codes instead of prose, and an &lt;code&gt;ok:false&lt;/code&gt; that actually means something.&lt;/p&gt;

&lt;p&gt;If you are building a CLI with an eye on agents consuming it, audit that output early. Mine looked fine until I piped it to &lt;code&gt;jq&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I embarrass myself
&lt;/h2&gt;

&lt;p&gt;While writing this, I audited the VM that runs everything above. It had &lt;strong&gt;446 pending packages&lt;/strong&gt;, including the kernel and OpenSSL, sitting there for five months, and no automatic security updates.&lt;/p&gt;

&lt;p&gt;The tool that held an SSH connection to that machine the entire time could have told me at any point. It did not, because I never wrote that command.&lt;/p&gt;

&lt;p&gt;It is now an issue: &lt;code&gt;vm health&lt;/code&gt;, reporting disk, pending security updates, reboot required, and containers stuck restarting. The questions an owner should ask a machine monthly, in one command.&lt;/p&gt;

&lt;p&gt;The tracker names the other ugly parts too. The ugliest: host key verification is not implemented, so the SSH layer currently trusts whatever answers on the recorded address. For a tool that can stream a &lt;code&gt;pg_dump&lt;/code&gt; over that connection, it is the first thing to fix, and it is filed as such rather than quietly known.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take one
&lt;/h2&gt;

&lt;p&gt;Version 0.1.0, MPL-2.0. The backlog is public and written so a stranger can pick something up cold: the evidence, the proposed shape, and the decisions still open. Comment &lt;code&gt;/take&lt;/code&gt; on an issue and it is yours. A few are tagged good first issue and genuinely are.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/BaryoDev/BaryoVM" rel="noopener noreferrer"&gt;github.com/BaryoDev/BaryoVM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run your projects on machines you own, I would like to know where this stops matching your reality. That gap is where the next issue comes from.&lt;/p&gt;

&lt;p&gt;We are just getting started anyway.&lt;/p&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>selfhosting</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
