Apps & Deployment
The platform's core story: create an app from a template, push code to the
built-in Git remote, request a deploy, and it's live at
{app}.{org}.kapable.run. The full lifecycle — environments,
env vars, deployment lanes, logs, pause/resume — is part of the
auth SDK module today (the methods live on client.auth).
App lifecycle endpoints are permission-gated: authenticate with a
kses_ session token (org role owner/member).
A bare sk_live_ API key passes authentication but is rejected by the
permission layer on app mutations.
The deploy flow
- Create an app from a template with
createAppChoice(use theelysiastarter — see the template note below). - Authenticate & push your code to the app's Forgejo remote (see "Pushing your code" below).
- Deploy with
requestDeploy. The CI pipeline builds and starts the app. - Your app is live at
https://{app}.{org}.kapable.run.
deployToLane targets an existing lane and returns 404 on a freshly
created app that has never deployed. The verified first-deploy path is
requestDeploy; lanes come into play once the app exists in an environment.
The elysia starter builds and deploys (~90s). The ts-hono
template is listed in GET /v1/templates but its build image is currently
missing (Image "kapable-tpl-ts-hono" not found, surfaces as a "checkout
failed" deploy error) — use elysia until that's fixed.
Pushing your code
The app's repo lives at git.kapable.dev. Mint a scoped bot token for it,
then push over HTTPS with the bot username embedded in the URL:
# 1. Mint a per-app Forgejo PAT (token shown once)
curl -X POST -H "Authorization: Bearer kses_..." \
https://api.kapable.ai/v1/apps/{app_id}/bot-pat
# → { "token": "...", "bot_username": "kbot-{app}" } (bot_username is also on the app detail)
# 2. Push using bot_username:token@ (repo_url is returned when the app is created)
git remote add kapable https://kbot-myapp:PAT@git.kapable.dev/{org}/{repo}.git
git push kapable mainWatching a deploy
requestDeploy returns 202 {"status":"requested"}. Poll
GET /v1/apps/{app_id} (getAppDetail) to watch progress:
current_step/step_index/total_steps— live build progress.deployment.statuspasses throughrequested → building → live_pending_tls. On failure,build_log_tail+failed_steptell you why.- Heads-up: the app's top-level
statuscan staycreatedeven while the deployment is serving. Confirm liveness by requesting thepublic_urldirectly (it returns 200 over TLS once live).
In the console UI, the same states surface on the app card and detail page: Pending (repository provisioning) → Created (repo ready, awaiting first deploy) → Building → Live, with Failed (check the logs) and Paused (manually stopped) as the off-ramps. Prefer clicking? The browser walkthrough covers the create-and-deploy loop without touching the API.
Apps & templates
| Method | Path | SDK (client.auth) |
|---|---|---|
| GET | /v1/templates | listTemplates |
| POST | /v1/app-choices | createAppChoice |
| GET | /v1/app-choices/{id} | getAppChoice |
| POST | /v1/app-choices/fork | forkAppChoice |
| POST | /v1/app-choices/{id}/publish-as-template | publishAppAsTemplate |
| GET | /v1/orgs/{org_id}/apps | listOrgApps |
| GET | /v1/apps/{app_id} | getAppDetail |
| PATCH | /v1/apps/{app_id} | renameApp |
| DELETE | /v1/apps/{app_id} | deleteApp |
SDK Examples
// 1. Create an app from the elysia starter template
const choice = await client.auth.createAppChoice({
user_id: me.user.id,
org_id: me.org.id,
org_slug: 'acme',
template_slug: 'elysia',
app_name: 'my-api',
});
// 2. Push code to the app's Forgejo remote, then:
// 3. Request the first deploy
await client.auth.requestDeploy(choice.id);
// 4. Live at https://my-api.acme.kapable.run// Same flow in Rust (methods on client.auth())
let choice = client.auth().create_app_choice(&CreateAppChoiceRequest {
user_id: me.user.id,
org_id: me.org.id,
org_slug: "acme".into(),
template_slug: "elysia".into(),
app_name: "my-api".into(),
}).await?;
client.auth().request_deploy(choice.id).await?;Deployments & runtime control
| Method | Path | SDK (client.auth) |
|---|---|---|
| POST | /v1/apps/{app_id}/deploy | requestDeploy |
| POST | /v1/apps/{app_id}/rebuild | rebuildApp |
| POST | /v1/apps/{app_id}/deployments/{deployment_id}/retry | retryDeployment |
| POST | /v1/apps/{app_id}/pause | pauseApp |
| POST | /v1/apps/{app_id}/resume | resumeApp |
| POST | /v1/apps/{app_id}/abort | abortPendingApp |
| GET | /v1/apps/{app_id}/logs | appLogs |
Environments & env vars
Apps have named environments (e.g. production), each with its own
env-var set and deployment lanes. A simpler app-level env API
(listAppEnv / upsertAppEnv / deleteAppEnv)
also exists at /v1/apps/{app_id}/env. Values are encrypted at
rest, and changes take effect on the next deployment — see
Environment Variables for
built-ins and best practices.
| Method | Path | SDK (client.auth) |
|---|---|---|
| GET | /v1/apps/{app_id}/environments | listAppEnvironments |
| POST | /v1/apps/{app_id}/environments | createAppEnvironment |
| GET | /v1/apps/{app_id}/environments/{env} | getAppEnvironment |
| DELETE | /v1/apps/{app_id}/environments/{env} | deleteAppEnvironment |
| GET | /v1/apps/{app_id}/environments/{env}/env_vars | listAppEnvVars |
| POST | /v1/apps/{app_id}/environments/{env}/env_vars | createAppEnvVar |
| PUT | /v1/apps/{app_id}/environments/{env}/env_vars/{name} | updateAppEnvVar |
| DELETE | /v1/apps/{app_id}/environments/{env}/env_vars/{name} | deleteAppEnvVar |
Deployment lanes
Lanes are parallel deployment tracks within an environment (blue/green, canary). Promote a lane to make it the live one; drain it to stop routing traffic.
| Method | Path | SDK (client.auth) |
|---|---|---|
| GET | /v1/apps/{app_id}/environments/{env}/lanes | listLanes |
| POST | /v1/apps/{app_id}/environments/{env}/lanes | createLane |
| GET | /v1/apps/{app_id}/environments/{env}/lanes/{lane} | getLane |
| PATCH | /v1/apps/{app_id}/environments/{env}/lanes/{lane} | updateLane |
| DELETE | /v1/apps/{app_id}/environments/{env}/lanes/{lane} | deleteLane |
| POST | /v1/apps/{app_id}/environments/{env}/lanes/{lane}/deploy | deployToLane |
| POST | /v1/apps/{app_id}/environments/{env}/lanes/{lane}/promote | promoteLane |
| POST | /v1/apps/{app_id}/environments/{env}/lanes/{lane}/drain | drainLane |
SDK Examples
// Set an env var on production, then deploy to a canary lane
await client.auth.createAppEnvVar(appId, 'production', {
name: 'FEATURE_FLAG_X',
value: 'on',
});
await client.auth.deployToLane(appId, 'production', 'canary');
// Happy with the canary? Promote it.
await client.auth.promoteLane(appId, 'production', 'canary');
// Tail recent logs
const logs = await client.auth.appLogs(appId, { lines: 200 });client.auth().upsert_app_env(app_id, "FEATURE_FLAG_X",
&EnvVarUpsertRequest { value: "on".into() }).await?;
client.auth().promote_lane(app_id, "production", "canary").await?;
let logs = client.auth().app_logs(app_id, Some(200)).await?;
The app surface is part of the auth module today because apps are
org-owned resources managed by kapable-auth. A first-class
client.apps namespace (same methods, better discoverability)
is on the SDK roadmap.
Custom domains
Any live app can serve on your own domain: add the domain in the app's
settings, point a CNAME at the app's .kapable.run address, and
the TLS certificate is provisioned automatically. Full flow, DNS details,
and troubleshooting: Custom Domains.
Health checks
Every app should serve GET /health returning any
2xx — fast and dependency-free (no DB calls). The platform's
lane prober uses it for granular health detection, and the deploy pipeline
health-gates new containers on it.
- 2xx — healthy; the lane routes traffic.
- 404 — treated as alive but unmonitored: your app keeps serving, it just loses granular health detection. You will never be taken down for lacking the route.
- Other non-2xx — the app is considered alive but unhealthy; the router routes away from the lane until it recovers.
Public /health on {app}.{org}.kapable.run proxies straight
through to your app like any other path. New scaffolds and the seed templates
ship the route by default. For React Router, it's a resource route:
// app/routes/health.ts
export function loader() {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
route("health", "routes/health.ts"),
index("routes/home.tsx"),
] satisfies RouteConfig;
Static sites (the blank stack) can commit a plain file named
health at the repo root — it's served at /health with a
200. Other stacks: any one-line GET /health handler works.
WebSockets
Your app can accept WebSocket connections at its normal address:
wss://{app}.{org}.kapable.run/your-path. Nothing needs turning on.
The platform passes the connection straight through to your app, so your
framework's own WebSocket support (Bun, ws, Socket.IO, and others)
works as it does locally. Subprotocols and compression are agreed between the
browser and your app.
- Idle limit: a connection with no traffic either way for 5 minutes is closed. Send a ping every 30 seconds or so to keep a quiet connection open. Most client libraries do this already.
- Maximum length: any connection is closed after 4 hours.
- Deploys: a new deploy replaces your app's container, so open connections close. Clients must reconnect, ideally with a short random delay.
- Limits: up to 256 open connections per app and 1,024 per organisation. Past that, a new connection is refused with
429and a message naming the limit. - Sleeping apps: an open connection counts as activity, so an app is not put to sleep under it. A connection to a sleeping app wakes it first.
Which pages may connect. Only pages on your app's own addresses: its
kapable.run address and its custom domains. A page anywhere else,
including another app on kapable.run or your app's staging
environment, is refused with 403. Browsers treat all of
kapable.run as one site, so without this rule another page could open
a connection that carries your visitors' cookies. Clients that send no
Origin header, such as servers and command-line tools, are allowed.
A website elsewhere cannot connect to your app's WebSocket from the browser yet.
If the app uses Kapable sign-in, a connection is only
accepted when the visitor is signed in. A signed-out connection gets 401, not a
redirect, because a WebSocket client cannot follow one: sign in with a normal page
load first.