Status: design frozen for Phase 1. Open questions marked ❓.
Self-hostable deployment platform. A single Docker Compose brings up the control plane; the same app pushes user code to a fleet of worker nodes (targets: bare metal, Hetzner, your laptop). Comparable in scope to Coolify/Dokploy, simpler than Vercel.
Two surfaces:
- Dashboard — Inertia/React SPA served by the same Rails app, cookie session.
- API — JSON over HTTP, token auth, used by CLI and worker agents.
┌──────────────────────────────────────────────┐
│ kaelix (control plane) │
Browser ───► │ Rails 8.1 │
(Inertia) │ ├─ Web (Puma) — Inertia + JSON API │
│ ├─ Worker (Solid Queue) — build/deploy jobs │
CLI ───────► │ └─ Solid Queue supervisor / cron │
(API token) └─────┬───────────────────────────┬──────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Postgres │ │ Target nodes │
│ (one) │ │ (ssh agents) │
└──────────┘ └──────────────┘
One Postgres, three logical schemas (one DB, separate schema_search_path):
public— app tables (users, orgs, projects, deployments, …).solid_queue_*— Solid Queue tables (managed by gem migrations).solid_cache_*— Solid Cache tables.solid_cable_*— Solid Cable tables.
No Redis in Phase 1. All state is in Postgres. If/when we need Redis (ActionCable at scale, rate limiting, ephemeral pubsub), add it then.
| Concern | Choice | Why |
|---|---|---|
| App framework | Rails 8.1.3 | Already set up. |
| Frontend | Inertia.js + React 19 + Tailwind v4 | Already set up. |
| JS toolchain | Bun (package manager + Vite runtime) | Already set up. |
| Database | PostgreSQL 16 | JSONB, partial indexes, FTS. |
| Background jobs | Solid Queue | Postgres-only. No Redis. |
| Cache | Solid Cache | Postgres-only. |
| WebSockets | Solid Cable | Postgres-only. |
| Auth (web) | Devise (session cookies) | Battle-tested. |
| Auth (API) | ApiKey (Bearer token) | Opaque, scoped, revocable. |
| Authorization | Pundit | De-facto Rails RBAC. |
| Object storage | S3-compatible (Active Storage) | Deferred — Phase 2. |
| Object map | pg |
Native. |
User
id, email, encrypted_password, …
has_many :memberships
has_many :organizations, through: :memberships
has_many :api_keys
Organization ◀── tenant boundary
id, name, slug, owner_id
has_many :memberships
has_many :users, through: :memberships
has_many :projects
has_many :api_keys
Membership ◀── RBAC junction
id, user_id, organization_id, role
role ∈ {owner, admin, developer, viewer}
unique [user_id, organization_id]
ApiKey
id, organization_id, name, token_digest, scopes[], last_used_at, expires_at
scope ∈ {read, write, deploy, admin}
Project ─ Phase 1 CRUD; deployments/servers in Phase 2
id, organization_id, name, slug, repository_url, branch,
framework, build_command, output_directory, status
Deferred to later phases (tables NOT created yet, but reserved slugs):
Server — registered target nodes (hetzner, aws, local)
Deployment — one row per push, links project → server → logs
Domain — custom domains + TLS provisioning
EnvVar — per-project secrets (encrypted)
Template — "Next.js + Postgres" one-click recipes
Volume — persistent storage requests on a server
Log — append-only, partitioned by deployment
Webhook — github/gitlab push events
Notification — slack/email/webhook
memberships.role (in app/policies/*.rb):
| Action | owner | admin | developer | viewer |
|---|---|---|---|---|
| Manage org settings | ✓ | ✓ | ||
| Invite/remove members | ✓ | ✓ | ||
| Create/rotate API keys | ✓ | ✓ | ||
| Create/edit projects | ✓ | ✓ | ✓ | |
| Trigger deployment | ✓ | ✓ | ✓ | |
| View projects/deploys | ✓ | ✓ | ✓ | ✓ |
| Delete org | ✓ |
API keys have an independent scope set on the key itself, additive to
membership. e.g. a deploy-scoped token can deploy but not edit projects.
POST /auth/login { email, password } → { token, user }
POST /auth/logout → 204
GET /me → { user, current_org }
GET /organizations
POST /organizations { name }
GET /organizations/:id
PATCH /organizations/:id { name }
DELETE /organizations/:id
GET /organizations/:id/memberships
POST /organizations/:id/memberships { email, role }
PATCH /organizations/:id/memberships/:mid { role }
DELETE /organizations/:id/memberships/:mid
GET /organizations/:id/projects
POST /organizations/:id/projects { name, repository_url, … }
GET /projects/:id
PATCH /projects/:id
DELETE /projects/:id
GET /organizations/:id/api_keys
POST /organizations/:id/api_keys { name, scopes[] } → { token: "…shown once…" }
DELETE /organizations/:id/api_keys/:id
Auth: Authorization: Bearer <token>. Errors: { error: { code, message } }.
git push
└─► webhook (Phase 2)
└─► Deployments::CreateJob
├─ clone repo
├─ detect framework
├─ upload build context to target server
├─ run build (on target or build node)
├─ stream logs → Log rows + ActionCable
└─ finalize: swap traffic, set DNS
Phase 1 only ships the entities + RBAC + API plumbing. The job above is a
stub Deployments::CreateJob that logs "would deploy" so the pipeline is
exercised end-to-end.
docker compose up
# postgres, web, worker, vite come up
# web: http://localhost:3000 (dashboard + API)
# vite: http://localhost:3036 (HMR, proxied by web)
# queue: Solid Queue dashboard at /admin/queue (Phase 2)
- ❓ Multi-region / HA for the control plane? Out of scope for Phase 1.
- ❓ Build on control plane vs on target? Default to target, allow override per project.
- ❓ Pricing / metering? Not in this repo — keep
Planout of the schema.