cleat is the command-line client for Cleat,
a self-hosted PaaS that deploys Phoenix/Elixir, Go, Node (Next.js / TanStack
Start), Ruby on Rails, Rust (Loco) and static apps to Hetzner Cloud and AWS
Lightsail over SSH.
It talks to the panel's JSON API (/api/v1) to list servers and apps, manage env
vars, trigger deploys, and watch build and runtime logs.
Requires Elixir 1.17+ / OTP 27+. CI also runs Elixir 1.19 / OTP 28.
git clone https://github.com/cleat-cloud/cleat-cli.git
cd cleat-cli
mix deps.get
mix installmix install builds the escript and copies it to ~/.local/bin (override with
mix install /usr/local/bin or CLEAT_INSTALL_DIR). Make sure the destination
is on your PATH, then check cleat version.
The panel is the source of truth: cleat-deploy priv/api_contract.json
(CleatDeployWeb.Api.Contract). This repo vendors a copy at
test/fixtures/api_contract.json.
CI checks the fixture against cleat-cloud/cleat-deploy main. Locally:
# sibling checkout of the panel
mix test test/cleat/contract_test.exs
scripts/check_panel_contract.sh
# or point at a specific file
CLEAT_DEPLOY_CONTRACT=/path/to/cleat-web/priv/api_contract.json mix testWhen the panel adds a resource or key (compatible change): merge the panel PR
first, then copy priv/api_contract.json into the fixture and list the new
resource in test/cleat/contract_test.exs @used.
When the panel renames or drops a key the CLI reads (breaking change): update
the CLI in the same window as the panel merge. mix test fails on missing
keys even before the full-file diff.
# Point at your panel and authenticate. Credentials can be passed as flags.
cleat login --panel https://panel.example.com --email you@example.com
# Inspect what you have access to.
cleat whoami
cleat servers list
cleat apps list
# Trigger a deploy and watch it build.
cleat deploy my-app --watch
# Follow a build log.
cleat logs 42 --followcleat login exchanges your email/password for a personal access token and
stores it in ~/.config/cleat/config.json. The token is shown only once by the
panel and is stored as a hash server-side.
| Command | Description |
|---|---|
cleat login |
Authenticate and store a token |
cleat logout |
Revoke the token and clear local credentials |
cleat whoami |
Show the authenticated user and tenant |
cleat init |
Write .cleat_deploy/deploy.json in the current project |
cleat config |
Show or edit CLI configuration |
cleat servers list |
List servers |
cleat servers show ID |
Show one server |
cleat apps list |
List apps |
cleat apps show APP |
Show one app (by id or slug) |
cleat apps create |
Create an app |
cleat apps update APP |
Edit repo / branch / auto-deploy / host / port / runtime |
cleat apps logs APP |
App journal, with --tail, --since, --grep, --follow |
cleat servers logs ID |
Host journal, with --unit, --tail, --since, --grep, --follow |
cleat events [APP] |
Search collected log events, with --server, --unit, --query, --severity, --min-severity, --since, --until, --limit |
cleat env list APP |
List env vars (secrets masked) |
cleat env set APP K=V |
Upsert one or more env vars |
cleat env unset APP KEY |
Delete an env var |
cleat deploy APP |
Trigger a deploy (by id or slug) |
cleat deploy --repo O/R |
Register if needed, then deploy |
cleat drop [DIR|FILE] |
Publish a local folder or file, no git (Netlify-Drop style) |
cleat cancel APP |
Cancel the active deploy |
cleat status APP |
List recent deployments for an app |
cleat logs DEPLOYMENT_ID |
Print a deployment's build log |
cleat mcp |
Run as a Model Context Protocol server on stdio |
Run cleat help for the full option list.
cleat can run as a Model Context Protocol server on stdio, so coding agents
(Claude Code, Codex, Grok) can deploy and inspect apps as native tools instead
of shelling out:
claude mcp add cleat -- cleat mcp
codex mcp add cleat -- cleat mcp
grok mcp add cleat -- cleat mcpTools: whoami, servers_list, server_logs, logs_search, apps_list,
apps_show, apps_create, apps_update, apps_logs, env_list, env_set,
env_unset, deploy, deploy_status, deploy_logs, cancel_deploy, drop,
init_project.
deploy queues and returns a deployment_id; follow it with deploy_status
and deploy_logs. Credentials come from cleat login (or CLEAT_PANEL_URL /
CLEAT_TOKEN).
cleat init writes .cleat_deploy/deploy.json, the same manifest the panel
reads to decide how to build an app:
{
"runtime": "phoenix",
"release_name": "my_app",
"memory_max_mb": 400
}For Go projects it detects binaries under cmd/*/main.go:
{
"runtime": "golang",
"binaries": ["server", "worker"]
}For static sites, cleat init detects the absence of mix.exs/go.mod plus an
index.html or package.json, or you can force it with cleat init --runtime static:
{
"runtime": "static"
}The panel optionally runs npm ci && npm run build and serves the output
directory (dist, build, public, _site, out, or build_dir) through
Caddy with an SPA fallback — no runtime process. Plain HTML/CSS/JS folders with
an index.html at the repo root are published as-is, no build step.
For server-rendered JS (Next.js, TanStack Start), cleat init detects next or
a @tanstack/*-start dependency and picks the node runtime. The panel installs
Node, runs npm ci && npm run build, and keeps the app alive with a systemd unit
behind Caddy (reverse_proxy). devDependencies are pruned after the build
(npm prune --omit=dev), and TanStack Start / Nitro releases ship no
node_modules at all since the .output bundle is self-contained. The start
command is resolved at build time:
start_command, if set in the manifestnpm run start, if the project declares astartscriptnode .output/server/index.mjsfor TanStack Start / Nitro outputnpm exec -- next startfor Next.js output
{
"runtime": "node",
"build_command": "npm run build",
"start_command": "npm start",
"node_version": "22"
}Only runtime is required; build_command, start_command, and node_version
(uses the latest 22.x when omitted) are optional overrides. Point build_dir at
a subdirectory for monorepos ({"runtime": "node", "build_dir": "web"}).
For Ruby on Rails, cleat init detects a Gemfile plus config/application.rb
(or a rails gem) and picks the rails runtime. The panel installs Ruby via
mise, runs bundle install, assets:precompile and db:prepare, then serves
the app with Puma behind Caddy. DATABASE_URL, SECRET_KEY_BASE and
RAILS_MASTER_KEY come from the panel env vars.
{
"runtime": "rails",
"ruby_version": "3.3.6",
"start_command": "bundle exec puma -C config/puma.rb"
}Ruby version resolution order: ruby_version → .ruby-version → the Gemfile
ruby directive → 3.3.6. Start command: start_command → bundle exec puma -C config/puma.rb → bundle exec puma -b tcp://0.0.0.0:$PORT.
For Rust / Loco, cleat init detects a Cargo.toml and picks the rust
runtime. The panel installs rustup, runs cargo build --release, publishes
bin/server plus config//assets//frontend/, and starts a systemd unit.
Loco apps (loco-rs or config/production.yaml) use ./bin/server start and
./bin/server db migrate. Set DATABASE_URL and JWT_SECRET as panel env
vars.
{
"runtime": "rust",
"start_command": "./bin/server start"
}Commit the manifest so the panel picks it up on the next deploy.
apps logs reads an app's systemd journal; servers logs reads the host
journal (all units, or one with --unit):
cleat apps logs lumina --tail 500 --since 1h --grep error
cleat servers logs 5 --unit caddy --since 30m--since accepts 30m, 2h, 1d or an ISO date (2026-09-21,
2026-09-21 14:30). --grep is a case-sensitive substring filter. --tail
defaults to 200 (max 5000). --follow keeps polling and prints new lines.
apps logs and servers logs read the live journal. cleat events searches
the panel's collected log store instead: events are enriched with tenant, app,
deploy, server and unit by the panel's collector, so they can be filtered by
app, severity and time window without touching the VM.
# errors and worse for one app in the last hour
cleat events my-app --min-severity err --since 1h
# free-text search across the tenant, newest first
cleat events --query "timeout" --limit 50
# exact level, specific unit, machine-readable
cleat events --app landing --severity warning --unit caddy --json
# filter by release sha and cluster similar errors
cleat events my-app --release abc123 --groupLevels: emerg, alert, crit, err, warning, notice, info, debug.
--min-severity includes that level and above. The collector is opt-in on the
panel (LOG_COLLECTOR_ENABLED=true); if it is off, cleat events returns no
rows.
Env vars are stored encrypted by the panel and written to the server
(/etc/<app>/env) during a deploy. They are masked by default:
cleat env list my-app # secrets shown as •••
cleat env list my-app --reveal # show values in the clear
cleat env set my-app DATABASE_URL=libsql://... SECRET_KEY_BASE=...
cleat env set my-app FOO=bar --deploy # apply immediately (one deploy)
cleat env unset my-app OLD_KEY --deployKeys must be UPPER_SNAKE_CASE. A value with = (KEY=a=b) is supported.
Without --deploy, run cleat deploy APP to apply the changes.
Deploy an app that is already registered:
cleat deploy my-app --watch
cleat deploy my-app --ref deploy-cleat # override the branchRegister and deploy in one shot. If the repo is unknown, pass --server and
--host; the app is created with sane defaults (slug from the repo name). The
runtime is detected from the local project the same way cleat init does, so a
Next.js / TanStack Start repo registers as node; override it with --runtime
(phoenix, golang, node, rails, rust or static):
cleat deploy --repo owner/repo --server 3 --host repo.example.com --watch
cleat deploy --repo owner/lumina --server 3 --subdomain lumina --runtime nodeEdit an existing app (including its runtime) and cancel a stuck deploy:
cleat apps update my-app --branch develop --no-auto-deploy
cleat apps update my-app --runtime node
cleat cancel my-appcleat drop packages a local folder or a single file and publishes it as a
static site — no repository needed. It works exactly like Netlify Drop:
# against an existing static app
cleat drop ./dist --app landing --watch
# create the static app on the fly (server required; host optional for plain static)
cleat drop ./site --server 3 --host landing.example.com --watch
# a single file: uploaded as index.html (or its own name when not HTML)
cleat drop ./almanaque.html --server 3 --host almanaque.example.com --watchPlain static sites get a host automatically. When the target is a single
.html/.htm file, or a directory with an index.html and no build manifest
(package.json, mix.exs, go.mod), you can publish without --host:
cleat config set sites_base_domain sites.example.com # once
cleat drop ./minha-loja --server 3 --watch
# → minha-loja.sites.example.com
cleat deploy --repo owner/minha-loja --server 3 # same detectionRe-running a drop against the same slug updates the existing static app instead
of creating a new one. Pass --host/--subdomain to override, or
--sites-base-domain for a one-off. A non-static target without a host still
asks for one.
.git, node_modules and .DS_Store are excluded from the upload. The panel
enforces a size limit (default 50 MB, CLEAT_DROP_MAX_BYTES to override) and
only accepts drops for apps with runtime: "static".
Resolution order for both the panel URL and the token:
--panel/--tokenflagsCLEAT_PANEL_URL/CLEAT_TOKENenvironment variables~/.config/cleat/config.jsonwritten bycleat login
Set CLEAT_CONFIG to use a different config file path.
Add --json to any read command for machine-readable output.
Create one DNS wildcard (*.sites.example.com → your server) and let the CLI
build the host for you. Set the base domain once:
cleat config set base_domain sites.example.comThen --subdomain expands to <name>.<base_domain>:
cleat drop ./site --server 3 --subdomain exemplo --watch
# → exemplo.sites.example.com
cleat deploy --repo owner/repo --server 3 --subdomain exemplo --runtime static --watch--base-domain overrides the stored value per command, and CLEAT_BASE_DOMAIN
sets it via the environment. Use --host for a full domain. The panel issues a
Let's Encrypt certificate per host automatically; it does not need to know the
base domain.
For static sites, set sites_base_domain to give plain static drops and deploys
a base domain when they omit --host:
cleat config set sites_base_domain sites.example.comOverride it per command with --sites-base-domain, or via
CLEAT_SITES_BASE_DOMAIN.
mix test
mix precommitMIT