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
The default runtime: the panel builds an OTP release (mix release) and keeps
it alive with the app's base systemd unit behind Caddy. Each app runs as its own
unprivileged system account (cleat-<slug>) with write access limited to its
data dir (/var/lib/<release_path basename>, exported as CLEAT_DATA_DIR), and
RELEASE_TMP points inside it. The unit enables NoNewPrivileges,
PrivateTmp, ProtectSystem=full and ProtectHome; the root-only env file is
read by systemd before privileges are dropped.
Existing apps move to the dedicated account on their next deploy: the panel
creates the account, hands the data dir over before the restart and again after
the migrations (which run as root). Point any app that writes outside
CLEAT_DATA_DIR at it before deploying.
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: "golang" (or put "runtime": "golang" in .cleat_deploy/deploy.json).
A repo with go.mod is detected automatically. The panel installs Go 1.26.4,
builds bin/<name> from ./cmd/<name> (binaries defaults to ["server"];
extra names become <unit>-<name>), publishes bin/ plus web/static to
/opt/<slug>/current, creates /opt/<slug>/data, and keeps each binary alive
with a systemd unit behind Caddy. MemoryMax defaults to 256M.
If the repo has package.json, Node 22 is installed and npm ci + npm run build
run before go build (frontend assets). The HTTP unit (server) sources the
panel env file and injects CLEAT_DATA_DIR, HOST=127.0.0.1 and
PORT=:<app.port> (colon-prefixed for Amarra-cais parseListenPort). Workers
do not get PORT. Panel env can override those keys. Typical extras:
ENV=production,
DB_PATH=/opt/<slug>/data/app.db,
STATIC_DIR=/opt/<slug>/current/web/static and TRUSTED_PROXIES=127.0.0.1.
The HTTP unit is not considered ready until it answers on the app port with
NRestarts=0.
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: prune with the lockfile package manager
(pnpm/yarn/bun --prod/--production --ignore-scripts, or
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.
Apps should handle SIGTERM (close the server, stop accepting connections) so
deploys and restarts shut them down gracefully. The unit waits up to 30s before
systemd escalates to SIGKILL; when a stop needs the hard kill the deploy log
prints a warning, since a SIGKILLed process with SQLite/Litestream can leave WAL
state behind.
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
