Self-hosted PaaS control panel. Deploy Phoenix/Elixir, Go, Node (Next.js / TanStack Start), Ruby on Rails, Rust (Loco) and static sites to Hetzner Cloud (CX33) or AWS Lightsail over SSH, with GitHub push-to-deploy.
Formerly Phoenix PaaS. OTP app: cleat_deploy.
Register VMs, link GitHub repos, trigger manual deploys or push-to-deploy, and watch builds in a live terminal. Pair it with the cleat CLI.
- Servers — register Hetzner Cloud or Lightsail VMs (IP, location, SSH user)
- Runtimes — Phoenix OTP releases, Go/Cais binaries, Node servers (Next.js / TanStack Start), Rails/Puma, Rust/Loco binaries, and static sites. Auto-detected from the repo or set via
runtimein.cleat_deploy/deploy.json(manifest reference) - Release phase —
release_commandruns after publishing and before the restart; a failure keeps the previous release serving - Multi-process apps —
processes(web + worker) as one systemd unit each; onlywebbinds the port and goes to sleep when idle - Managed addons —
addons: ["postgres:pgvector", "redis"]provisions the datastores on the server and injectsDATABASE_URL/REDIS_URL - Deployments — queued → running → success/failed, with build logs and paginated history
- GitHub webhooks — HMAC-verified
POST /webhooks/github - Oban queue — background deploy worker with Mox-tested runner behaviour
- SSH deploys — clone, build on the VM, migrate, restart systemd
- CLI —
cleatfor servers, apps, env vars, deploys and log streaming
- Elixir 1.15+ / OTP 26+
- SQLite (dev/test) — no external DB needed to get started
git clone https://github.com/cleat-cloud/cleat-deploy.git
cd cleat-deploy
mix setup
mix phx.serverOpen http://localhost:4000.
CX33 is 4 shared vCPU / 8 GB RAM / 80 GB NVMe / 20 TB traffic. It is not sold in Ashburn; the default location is Falkenstein (fsn1).
A shared host runs at most two builds at a time (the third waits until a slot frees). The Oban :deploys queue is wider than that; the cap lives in claim_running/1. Apps that set "solo_server": true in .cleat_deploy/deploy.json must deploy to a dedicated server.
export HCLOUD_TOKEN=... # Hetzner Cloud API token
# optional: reuse the Lightsail PEM so the panel can SSH with the existing key
./scripts/deploy/provision-hetzner-cx33.sh
export HETZNER_SERVER_IP=x.x.x.x
./scripts/deploy/bootstrap-hetzner-server.sh
./scripts/deploy/migrate-lightsail-to-hetzner.shThen point Hostinger A records at the new IP and register the server in the panel:
HETZNER_SERVER_IP=x.x.x.x bin/cleat_deploy rpc 'Code.eval_file("priv/scripts/setup_hetzner.exs")'Leave Lightsail running until HTTPS on Hetzner is healthy.
By default dev uses FakeRunner (simulated logs). For real deploys:
export DEPLOY_RUNNER=ssh
export GITHUB_TOKEN=ghp_... # required for private repos
mix phx.server- Register a server with its SSH private key (PEM)
- Register an app (Trip Planner defaults:
trip_planner_ia,/opt/trip_planner_ia) - Click Deploy now — clones repo, builds on the VM, migrates, restarts systemd
Set runtime: "static" (or put "runtime": "static" in .cleat_deploy/deploy.json).
The panel runs an optional npm ci && npm run build, then publishes the output
directory (dist, build, public, _site, out, or build_dir from the
manifest; if none exist, the repo root when it has an index.html) to
/var/www/<slug>/current. Caddy serves it with file_server and
an SPA fallback (try_files {path} /index.html); there is no systemd unit.
Plain HTML/CSS/JS folders need no build and work as-is. They can also be
published without git via cleat drop, which uploads a folder to
POST /api/v1/apps/:app/drops and publishes it the same way.
Set runtime: "node" (or "runtime": "node" in .cleat_deploy/deploy.json).
The panel installs Node, runs npm ci and npm run build, publishes the project
to /opt/<slug>/current, and keeps it alive with a node-<slug> systemd unit
behind the Caddy reverse proxy. Before publishing it shrinks the release so
node_modules does not fill the disk: npm prune --omit=dev for regular Node
apps, or no node_modules at all for TanStack Start / Nitro (the .output
bundle is self-contained). The start command is resolved at build time:
start_command from the manifest, else npm run start, else
node .output/server/index.mjs (TanStack Start / Nitro), else
npm exec -- next start (Next.js). Override the toolchain with node_version
(e.g. "20"; defaults to the latest 22.x), build_command, and build_dir for
monorepos.
Set runtime: "rails" (or "runtime": "rails" in .cleat_deploy/deploy.json).
The panel installs Ruby via mise (version from ruby_version, .ruby-version
or the Gemfile ruby directive), runs bundle install, assets:precompile and
db:prepare, publishes the project to /opt/<slug>/current, and keeps Puma
alive with a rails-<slug> systemd unit behind the Caddy reverse proxy.
DATABASE_URL, SECRET_KEY_BASE and RAILS_MASTER_KEY are read from the panel
env vars. Start command: start_command → bundle exec puma -C config/puma.rb
→ bundle exec puma -b tcp://0.0.0.0:$PORT.
Set runtime: "rust" (or put "runtime": "rust" in .cleat_deploy/deploy.json).
A repo with Cargo.toml is detected automatically. The panel installs rustup,
runs cargo build --release (the target dir is cached across deploys), copies
the binary as bin/server plus config/, assets/ and frontend/, and keeps
it alive with a rust-<slug> systemd unit behind Caddy.
Loco apps (loco-rs in Cargo.toml or config/production.yaml) start with
./bin/server start, run ./bin/server db migrate before the restart, and
get LOCO_ENV=production, PORT, BINDING=127.0.0.1 and
HOST=https://<app host>. Set DATABASE_URL and JWT_SECRET as panel env
vars. A frontend/package.json is built with npm. Plain Rust binaries (Axum
and friends) run as ./bin/server. Override with build_command and
start_command.
Set runtime: "gleam" (or put "runtime": "gleam" in .cleat_deploy/deploy.json).
A repo with gleam.toml is detected automatically. The panel installs Erlang,
Gleam and rebar3 via mise, exports the project as an Erlang shipment
(gleam export erlang-shipment), publishes the shipment whole, and keeps
./entrypoint.sh run alive with a gleam-<slug> systemd unit behind Caddy.
Only the Erlang target is supported: a gleam.toml with
target = "javascript" fails the build with a clear message. Erlang is pinned
to 28.4.1 for the build and for the service (a shipment does not embed ERTS,
so erl is linked into /usr/local/bin for the root unit), and the app reads
PORT from the panel. gleam_version overrides the Gleam version (default
1.18.1). build_command replaces the export step — it must produce
build/erlang-shipment/ — and runs with the persistent
<release_path>/data created and exported as CLEAT_DATA_DIR, which is where
an app that migrates from its source tree should do it: the shipment does not
carry src/.
Rails, Node, Rust and Gleam apps can also declare a release phase,
multiple processes (web + worker) and managed addons (Postgres with
pgvector, Redis) in .cleat_deploy/deploy.json — full reference in
docs/deploy-json.md.
One-off test script (no UI):
DEPLOY_RUNNER=ssh mix run --no-start priv/scripts/run_deploy_test.exsRequires git, ssh, scp, and tar on the machine running the panel.
Panel routes require login. Each user belongs to a tenant; servers and apps are isolated by tenant_id.
Seed the owner account and Trip Planner deploy test:
SEED_USER_PASSWORD='your-secure-password' mix run priv/repo/seeds.exsThis creates matheus.puppe@gmail.com as owner of the Gestão Bem tenant with the Trip Planner app pre-linked.
| Variable | Description |
|---|---|
DEPLOY_RUNNER |
fake (default) or ssh for real deploys |
HCLOUD_TOKEN / HETZNER_API_TOKEN |
Hetzner Cloud API token (sync specs / resize) |
HETZNER_SERVER_IP |
Public IPv4 of the CX33 |
GITHUB_TOKEN |
GitHub PAT for cloning private repos |
PHX_SERVER |
Set to start HTTP server (e.g. in production) |
PORT |
HTTP port (default 4000) |
SECRET_KEY_BASE |
Required in production |
CLOAK_KEY |
32-byte base64 key for SSH private key encryption (production) |
TURSO_DATABASE_URL |
libsql://... Turso database URL (production) |
TURSO_AUTH_TOKEN |
Turso auth token (production) |
DATABASE_PATH |
Fallback SQLite path when Turso URL is not set |
SEED_USER_PASSWORD |
Password for the seeded matheus.puppe@gmail.com account |
SEED_SSH_KEY_PATH |
Optional path to Lightsail PEM for seed server (default: ~/.ssh/lightsail-default-key-us-east-1.pem) |
mix test
mix precommitMIT
