DEV Community

Cover image for I built an agentless deploy CLI in Go because every PaaS wanted to own my server
Baryo Dev
Baryo Dev

Posted on

I built an agentless deploy CLI in Go because every PaaS wanted to own my server

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.

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.

So I wrote BaryoVM, 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.

This post is the parts that were not obvious when I started.

The shape

# register a VM you already have
baryovm vm add oracle --host <ip> --user opc --key ~/.ssh/id_ed25519
baryovm vm ping oracle
baryovm vm bootstrap oracle     # installs Docker if missing

# register a stack: a compose project dir on that VM
baryovm stack add app --vm oracle --path /home/deploy/app/deploy \
  --db-container app-postgres-1 --db-name app --env-file .env

# deploy: sync -> build on the VM -> backup -> compose up
baryovm stack release app
Enter fullscreen mode Exit fullscreen mode

State is one file, ~/.baryovm/fleet.json, written 0600. It holds hostnames, users and paths to SSH keys, never key material.

Why not Kamal, Coolify, Dokku, Ansible

I did not build this because those are bad. I built it because each wants to own something I could not give it.

Coolify, CapRover and Dokku want the server. 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.

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

Ansible wants nothing, which is both the point and the cost. It will do all of this after you write every bit of it.

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

Three things I got wrong first

1. rsync --delete will eat your secrets

stack release is config-driven. A JSON manifest says what to sync and what to build, so the CLI holds no app-specific logic:

{
  "localRoot": "~/repos/app",
  "remoteRoot": "/home/deploy/app",
  "sync": ["src/", "Dockerfile"],
  "exclude": ["bin", "obj", "node_modules", ".git"]
}
Enter fullscreen mode Exit fullscreen mode

The compose directory, the one holding .env, is deliberately never syncable. Sync runs with --delete. 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.

2. A backup after the change is not a backup

The release takes the database dump before 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.

$BK/db-20260825-031200.dump      # pg_dump -Fc
$BK/env-20260825-031200          # the config file, mode 600
Enter fullscreen mode Exit fullscreen mode

Fourteen retained, older ones pruned.

3. Unattended code should refuse more than it accepts

stack update --auto 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:

  • refuses a stack not explicitly marked autoUpdate
  • refuses a stack with no healthUrl
  • refuses --auto combined with --no-backup

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.

Writing this taught me the general version: in unattended code, the refusals are the feature. Everything else is the happy path anyone can write.

Everything speaks JSON, which is not the same as a contract

Every command takes -o json, so a UI, a CI job or an MCP server can drive the same surface:

baryovm stack release app -o json
Enter fullscreen mode Exit fullscreen mode

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

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 data, stable error codes instead of prose, and an ok:false that actually means something.

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 jq.

The part where I embarrass myself

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

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.

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

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 pg_dump over that connection, it is the first thing to fix, and it is filed as such rather than quietly known.

Take one

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 /take on an issue and it is yours. A few are tagged good first issue and genuinely are.

github.com/BaryoDev/BaryoVM

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.

We are just getting started anyway.

Top comments (0)