DEV Community

Cover image for I built a self-hosted deploy tool that looks like tmux (and can be driven by an AI agent)
Roman
Roman

Posted on

I built a self-hosted deploy tool that looks like tmux (and can be driven by an AI agent)

I deploy a handful of projects to a handful of my own servers. Not enough to justify Kubernetes, too many to keep doing ssh, git pull, docker compose up -d and hoping. I didn't want a PaaS, a cloud control plane, or an agent running on every server.

So I built deploy.me: a local, Dockerized admin app. You run it on your own laptop (macOS, Linux, or Windows with WSL2) at https://deployme.localhost. It reads your servers from ~/.ssh/config, imports your projects from GitHub or a local folder, and deploys with nothing on the other end but ssh, rsync and docker compose.

It's MIT licensed, and I built it together with Claude. Here's what I think is worth telling about.

What a deploy does

A deploy is a pipeline of recorded steps, each with its own log:

  1. Pre-flight: is the server reachable, is the folder locked by another run, is another Compose project already running from it?
  2. Deliver: an ordered list of strategies (GHCR image for the commit, push an archive, rsync, or let the server fetch from git). The first one that applies wins, and the trace tells you why.
  3. Release: releases/<timestamp> hard-linked against the live one, shared folders and env files symlinked in, then an atomic switch of current.
  4. Docker Compose: compose config first, so a broken file stops the deploy before anything is built. Then build or pull, pg_dump, migrations in a one-off container, and up -d.
  5. Health gate: the services are healthy, the health URL answers, and your own command succeeds.

If the gate fails after the switch, it switches back by itself. With blue/green on, the edge-facing services start beside the running ones, and the old ones are removed only once the new colour is healthy, so a bad deploy costs no downtime.

A run whose health check failed and was switched back automatically

The UI is a tmux session

Early on I asked for a redesign, and ended up with something more committed than a dark mode: the whole app can be a tmux session. Panes you split with Ctrl+b % and Ctrl+b ", a status bar with windows 0:ops 1:runs 2:servers…, F1–F6 to jump between them, and a shell pane where the real work happens:

deploy@local:~$ status
   TARGET                 LIVE     HEALTH            LAST RUN
 ● atelier@edge-fra       94115db  ||||XX|||||||||   #19 ✓
 ● warehouse@edge-fra     9fabf06  ||||||||||||||||  #20 ✓
 4 targets · 0 running · 0 failing
deploy@local:~$ deploy warehouse edge-fra
Enter fullscreen mode Exit fullscreen mode

deploy is a small wizard: pick the commit, see the plan (what changes, which strategy and why, whether a commit brings a migration), then it asks y/N. A flag is never taken as the yes. It's a theme, not a fork: the same Livewire components sit behind a Classic UI with cards and tables, and you switch in Settings.

The Console theme: a shell, the latest run's log, htop for a server, git log for a project

The first page loads the frame and the shell immediately; the panes that need data (tail, htop, git) load right after, each in its own request.

Letting an AI agent in, carefully

php artisan deployme:mcp is an MCP server over stdio. Point Claude, Cursor or VS Code at it and the agent can look around, explain why a run failed, and plan a deploy. It's read-only by default. The tools that change anything (deploy, roll back, cancel) appear only with --allow-deploys, and your MCP client asks you before each call. Answers never carry a token, an env file's contents, or the APP_KEY.

Security decisions I'd make again

  • Secrets never go in argv or logs. Remote scripts get them over stdin, and every log line is redacted before it is stored or broadcast.
  • Passkey or password sign-in, one account. Revealing an env file, trusting a new host key, or changing where alerts go asks for a fresh confirmation.
  • Host keys are trusted on first use and then pinned.
  • An append-only audit log. A database trigger refuses UPDATE, and DELETE only inside the nightly prune. Every remote command is in it.
  • A server you can't reach directly goes through a jump host, a SOCKS5/HTTP proxy or your VPN, and that route is checked before every probe and deploy.

Building it with an agent

A good part of this was written with Claude Code, and the most useful thing I did was not asking for features. I pointed an agent at the codebase with one rule: every suspected bug needs a failing test before it gets a fix. It came back with dozens of fixed bugs, each one proven: a deploy that could run in two workers at once, secrets leaking into a failed step's error text, a health alert that fired on every check instead of on change. A rule that makes the agent show its work turned out to matter more than which model was running.

Try it

curl -fsSL https://raw.githubusercontent.com/KielD-01/deploy.me/main/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

That clones the repo, configures it for your OS, builds, and prints a one-time link to set up a passkey or password. The PHP base image is prebuilt for amd64 and arm64 on Docker Hub, so the first build takes minutes, not a coffee break. bin/update keeps it current (backup, rebuild, doctor, and back out if it fails), and can do the patch releases by itself at night if you switch that on.

I'd like to hear what's missing for the way you deploy. Issues and PRs are welcome.

Top comments (0)