Skip to content

About

Self-hosted PaaS: deploy Phoenix/Elixir, Go, Node (Next.js/TanStack Start), Rails and static apps to your own VPS over SSH, with GitHub push-to-deploy

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Cleat

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.

Cleat dashboard — Trip Planner deployed on Lightsail

Features

  • 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 runtime in .cleat_deploy/deploy.json (manifest reference)
  • Release phase — release_command runs after publishing and before the restart; a failure keeps the previous release serving
  • Multi-process apps — processes (web + worker) as one systemd unit each; only web binds the port and goes to sleep when idle
  • Managed addons — addons: ["postgres:pgvector", "redis"] provisions the datastores on the server and injects DATABASE_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 — cleat for servers, apps, env vars, deploys and log streaming

Requirements

  • Elixir 1.15+ / OTP 26+
  • SQLite (dev/test) — no external DB needed to get started

Quick start

git clone https://github.com/cleat-cloud/cleat-deploy.git
cd cleat-deploy
mix setup
mix phx.server

Open http://localhost:4000.

Hetzner CX33 (shared Phoenix + Go host)

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

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

Real deploys (SSH)

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
  1. Register a server with its SSH private key (PEM)
  2. Register an app (Trip Planner defaults: trip_planner_ia, /opt/trip_planner_ia)
  3. Click Deploy now — clones repo, builds on the VM, migrates, restarts systemd

Phoenix / Elixir apps

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.

Static sites

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.

Go / Cais apps

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.

Node / SSR apps (Next.js, TanStack Start)

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.

Ruby on Rails apps

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.

Rust / Loco apps

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.

Gleam apps

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

Requires git, ssh, scp, and tar on the machine running the panel.

Authentication & multi-tenant

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

This creates matheus.puppe@gmail.com as owner of the Gestão Bem tenant with the Trip Planner app pre-linked.

Environment

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)

Tests

mix test
mix precommit

License

MIT

About

Self-hosted PaaS: deploy Phoenix/Elixir, Go, Node (Next.js/TanStack Start), Rails and static apps to your own VPS over SSH, with GitHub push-to-deploy

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages