Skip to content
Console →
Website →
Asking an AI? Paste this URL https://keelson.dev/llms.txt

All Documentation

Introduction


Documentation Guide

This documentation explains how to deploy and operate applications with Keelson.

Create an account or sign in to the Keelson console to get started.

For an overview of the Keelson service, pricing, and security, see the Keelson website.

Keelson’s supported runtimes and constraints are collected on one page in the Keelson Deploy Spec. Give the following URL to an AI agent to have it check whether an application you created can be deployed to Keelson:

https://keelson.dev/docs/reference/deploy-spec.txt

Quickstart

This guide walks you through deploying a sample web app to Keelson, opening it in a browser, and confirming that login protection is on by default.

You will need:

  • A supported terminal — macOS or Linux with curl and openssl, or Windows 10/11 or Windows Server 2016+ with Windows PowerShell 5.1 or later. Windows PowerShell 5.1 and its CNG cryptography support are included with supported Windows versions.
  • A Keelson account — sign up at console.keelson.dev if you do not have one.
  • An AI agent that can use Agent Skills, such as Claude Code or Codex. The agent itself may require Node.js; the Keelson CLI does not.

On macOS or Linux, run:

Terminal window
curl -fsSL https://keelson.dev/install.sh | sh

On Windows, open PowerShell and run:

Terminal window
irm https://keelson.dev/install.ps1 | iex

Both installers verify the release signature (ECDSA P-256) and the sha256 of the binary before installing it. If verification fails, the existing CLI is left unchanged. The default destination is ~/.keelson/bin/keelson on macOS and Linux, or %USERPROFILE%\.keelson\bin\keelson.exe on Windows. The Windows installer uses the operating system’s CNG cryptography and does not require OpenSSL.

The installer does not modify your PATH. On macOS or Linux, if keelson is not found, have the CLI append it to your shell config:

Terminal window
~/.keelson/bin/keelson doctor --fix-path

This only rewrites the config file, so your current shell is not affected yet. Restart the shell, or run the line doctor prints:

Terminal window
export PATH="$HOME/.keelson/bin:$PATH"

If you would rather not touch your shell config, run just the export — it applies for the current shell only.

On Windows, add %USERPROFILE%\.keelson\bin to your user Path in Edit environment variables for your account, then open a new PowerShell window. To use it only in the current PowerShell session, run:

Terminal window
$env:Path = "$env:USERPROFILE\.keelson\bin;$env:Path"

Once PATH is set, confirm the install:

Terminal window
keelson --version
keelson v0.1.0 (darwin/arm64)

Later updates are done with keelson upgrade.

The installer accepts these environment variables:

VariablePurpose
KEELSON_VERSIONInstall a specific version (e.g. KEELSON_VERSION=v0.1.0). Defaults to the current release
KEELSON_INSTALL_DIRChange the install destination. Defaults to ~/.keelson/bin on macOS/Linux and %USERPROFILE%\.keelson\bin on Windows
KEELSON_DOWNLOAD_BASEChange the release download origin. Defaults to https://dl.keelson.dev

Link this machine to your Keelson account:

Terminal window
keelson login

A browser opens with the login screen. Once you are logged in, return to the terminal. On machines without a browser, use keelson login --no-browser.

We provide a small Node (TypeScript) AI chat app for this walkthrough. It contains no authentication or security code of its own.

  1. Download the ZIP from ai-chat (GitHub) and extract it.
  2. In a terminal, change into the extracted folder.
Terminal window
cd ~/Desktop/ai-chat

The sample app requires an API key from at least one supported AI provider: GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY. Before deploying, create an env file in the sample app folder with one of those keys:

OPENAI_API_KEY=your-api-key

For the first deployment, the agent will need to run:

Terminal window
keelson deploy --new --secrets-from-env-file .env.keelson

Save the env file as .env.keelson. This command registers its values as Keelson secrets and excludes the file from the uploaded app.

Keelson lets your AI agent handle deploys and deploy status (log inspection) for you. The Skill it uses is bundled with the CLI — there is nothing extra to download.

From inside the sample app folder, run the command for the agent you use:

Terminal window
keelson install-agent claude-code
TargetDestination
claude-code.claude/skills/keelson/ in the current directory (--global installs to ~/.claude/skills/keelson/)
codex~/.codex/skills/keelson/
cursor~/.cursor/skills/keelson/

claude-code installs into the current directory by default, so the Skill lands in the sample app folder you just entered. Pass --global to use it in every project instead.

Open the extracted folder in your AI agent (Claude Code, for example).

No long commands are needed. Type one instruction into your agent:

Example: “Use the Skill to deploy this app to Keelson with the first-deployment command shown above.”

The agent reads keelson.yaml and starts the build and deploy. A deploy of this sample usually takes 3–6 minutes; most of that time is the build, including dependency installation. During the health check, the CLI shows whether it is waiting for permissions, routing, or the app to respond. When it finishes, the agent reports a public URL:

https://your-app-name.keelson.run/

You can also view the deployed app in the Keelson console.

A public URL is assigned when the app is created, so seeing the URL alone does not prove that the deployment succeeded. A command running without an interactive terminal can return while the deployment is still in progress, and the deployment may fail afterward. Before opening the URL, check the deployment ID reported by the deploy command:

Terminal window
keelson status <deploy-id>

If the deployment failed, this command shows the reason and the next action.

Open the deployed URL in a browser.

  • Logged-in browser: the app is shown.
  • Private window: the Keelson login screen is shown instead.

That is Security by Default. Keelson applies login protection without any authentication code in your app.

Core Concepts

Keelson is a runtime platform for securely publishing applications that are shared within an organization or team.

You can focus on developing the application itself while Keelson handles the systems required to publish, authenticate, and share it. This page explains how those responsibilities are divided.

A user’s browser does not connect directly to an application deployed on Keelson. Every request passes through the Keelson Proxy first.

When a user opens the URL of an application deployed on Keelson, the request is processed in this order:

  1. Keelson identifies the application associated with the public URL.
  2. The Keelson Proxy checks whether the user is logged in.
  3. Keelson checks whether the user has permission to access the application.
  4. Only an authorized request is forwarded to the application.
  5. The application’s response is returned to the user.

The important point is that authentication and access-control decisions happen outside the application itself. Application developers can therefore publish applications intended for sharing without building a login screen or session management from scratch each time.

Keelson separates authentication and access control from the application. The application can concentrate on business logic, while the platform applies security consistently when the application is shared.

Why does the application not need to implement authentication?

Section titled “Why does the application not need to implement authentication?”

Login protection is enabled as soon as an application is deployed to Keelson. We call this Security by Default.

  • Security is built in from the beginning instead of added later.
  • The application is protected when it is published, rather than requiring authentication to be added after it works.
  • The login screen shown in a private browser window during the Quickstart is the result of this protection.

This is why Keelson is more than application hosting: it is a runtime platform for internal applications.

Keelson provides login protection and access control at the application’s entry point. If an application needs additional controls based on its own business rules, implement those controls within the application.

When you use Keelson, the boundary between the application developer’s responsibilities and Keelson’s responsibilities is clear.

The application providesKeelson provides
User interfaceDeployment environment
Business logicPublic URL
APILogin protection
Data processingAccess control for the application
Runtime environment management

With this division, application developers do not need to spend time building infrastructure or platform-level security.

Why is Keelson suited to internal and team applications?

Section titled “Why is Keelson suited to internal and team applications?”

Keelson is not a platform for applications used locally by only one person. It is designed for secure sharing within a team or organization.

The platform therefore includes these features:

  • Public URL — deploying an application gives you a URL that you can share with your team.
  • Login protection — only authorized members can access the application.
  • Member management — control who can access each application.

Learn more on these pages:

Run the application first, then add configuration as needed

Section titled “Run the application first, then add configuration as needed”

At first, you only need to focus on deploying and running the application.

You can configure Keelson incrementally. The experience covered in the Quickstart is the entry point with minimal configuration. Add settings such as these when you need them:

FAQ

These are common questions when deciding whether to use Keelson. Each answer covers the conclusion and conditions; follow the links for details.

Can I deploy an app built with Lovable, Bolt, or v0?

Section titled “Can I deploy an app built with Lovable, Bolt, or v0?”

It depends on the structure. Static frontend output can be deployed directly in assets mode. Apps with server-side processing, such as Next.js, are deployed as servers. Parts that depend on external services such as Supabase can keep using those connections. See the bring your app guide for how to assess your app.

Not currently. Streamlit requires WebSockets, which do not fit Keelson’s execution model. Gradio is under evaluation. See the compatibility table in Supported apps and constraints.

After the 14-day free trial. Starter and Plus can start without a card; if you do not add one before the trial ends, it ends without a charge. Team requires a card when starting the trial. See Billing and subscriptions.

Which plan do I need for 2 builders and 50 users?

Section titled “Which plan do I need for 2 builders and 50 users?”

Only the builders count. Owners, Admins, and Developers together use Developers seats: Starter includes 1, Plus 2, and Team 3. App Users do not affect the price, regardless of their number. Two builders need Plus or above. Also check the concurrent-app allowance: Starter 1, Plus 2, and Team 4. See Plans and limits.

If I store 10 apps but usually use only some of them, which limit applies?

Section titled “If I store 10 apps but usually use only some of them, which limit applies?”

Paid subscriptions have no stored-app limit; only the number running concurrently is limited. Only apps accessed within the last 5 minutes occupy a slot. Unused apps sleep and release their slots. During the trial, stored apps are also limited to 3 (apps already deployed as static sites do not count). See Plans and limits.

What do users see when all concurrent-app slots are occupied?

Section titled “What do users see when all concurrent-app slots are occupied?”

Accessing a sleeping app shows an HTTP 503 information page. A slot becomes available after an app occupying it receives no access for about 5 minutes. No slots become available while the other apps remain in use. You can reserve a slot for an important app with priority start on Plus and above.

Can anyone who knows the URL open the app?

Section titled “Can anyone who knows the URL open the app?”

No. An app is visible only to signed-in workspace members who have permission to view it. People who are not signed in see the login page. To share an app with someone outside your organization, invite them as a member (App Users are unlimited). See Authentication and login.

Where is data stored, and are there backups?

Section titled “Where is data stored, and are there backups?”

The app and its Managed SQLite database are placed in the region selected when the app is created (Japan or the US West Coast). Selecting Japan keeps them in Japan. The workspace’s default region is determined by its billing country and can be changed in settings. Every plan includes daily backups, manual snapshots, point-in-time recovery (PITR), and downloads. Files stored through the Files / Media SDK are not covered by backups. See Databases.

What happens to apps and data when a trial ends, I change plans, or I cancel?

Section titled “What happens to apps and data when a trial ends, I change plans, or I cancel?”

Apps and data are not deleted immediately in any of these cases. When a subscription ends, new deployments and app starts are blocked, and apps are automatically deleted after a 30-day grace period. Resubscribe during the grace period to keep them. A plan downgrade takes effect at the end of the billing period, and apps exceeding the allowance cannot start. See Billing and subscriptions.

No. They run only while processing requests or executing declared scheduled jobs. They stop when there is no access and start on the next request. Persistent timers and work performed after returning a response do not run. Read How apps run before building.

Does Keelson’s price include API usage fees for services such as OpenAI inside my app?

Section titled “Does Keelson’s price include API usage fees for services such as OpenAI inside my app?”

No. Calls from your app to external APIs are billed under your agreement with that API provider. Keelson only passes API keys securely as secrets; it does not restrict or mediate external communications. See External integrations.

Building Apps


Supported App Types

Keelson is a runtime platform for securely deploying and sharing web apps, APIs, and scheduled jobs within a company or team.

It is particularly well suited to small and medium-sized business applications generated with AI tools such as ChatGPT, Claude, and Manus.

Typical applications include:

  • Internal tools for customer, project, or inventory management
  • Administration dashboards for viewing and editing data
  • Integrations that automate work across Slack, Notion, or Google Sheets
  • Lightweight automation such as CSV imports, scheduled aggregation, and alerts
  • Back-office workflows such as approval requests and report generation

A form, list view, and database make up a typical CRUD business application. Keelson Managed SQLite (libSQL, configured with db.mode: libsql) can provide the durable database without requiring a separately managed database server.

A text-search interface backed by an API can make company knowledge searchable. Keelson places authentication in front of the app so it can be shared safely inside the organization.

An upload, processing step, and download can automate repetitive data work. A Keelson URL makes the finished tool available to non-developers in a browser.

A form, status tracking, and notifications can support a small approval flow. Combine it with workspace membership and app access settings to control who can use it.

An input form and output template can standardize recurring reports. Scheduled jobs can aggregate the data automatically on a daily or weekly cadence.

Slack, Notion, or Google Sheets integration

Section titled “Slack, Notion, or Google Sheets integration”

A webhook receiver and outbound API calls can synchronize data with external services. The app can combine an HTTP service with scheduled jobs.

An app can consist only of cron jobs. Sales aggregation, log analysis, and notification delivery do not require a user interface or a resident web server.

A chat interface can call an external LLM API and expose an internal assistant to the team behind Keelson authentication.


Keelson is not tied to a specific web framework. A conventional application that can start as an HTTP server can run regardless of its framework.

RuntimeIntended use
python-slimLightweight Python apps such as APIs and text processing
python-mediaPython apps that need image and video libraries
node-slimLightweight Node.js apps
node-mediaNode.js apps that process media
go-slimLightweight Go apps
go-mediaGo apps that process media
  • Web app — Starts an HTTP server and serves a browser interface
  • API server — Provides a JSON or other HTTP API
  • Scheduled job (cron) — Runs a command on a schedule. Background work such as draining accumulated tasks is also expressed as a scheduled job

Examples of compatible frameworks include FastAPI, Flask, Express, Next.js, Hono, and Gin. If the application can be started with the command in keelson.yaml, it can generally run on Keelson.


Keelson is a good fit when several of the following are true:

  • The audience is internal — Access should be limited to a team or company
  • Authentication should be managed for you — The application should be protected without implementing its own login flow
  • You operate several small apps — One workspace can manage multiple apps
  • You want SQLite semantics — Keelson Managed SQLite (libSQL with db.mode: libsql) provides durable storage; local /data is ephemeral
  • You want to run an AI-generated app quickly — No Dockerfile is required; deployment is described in keelson.yaml
  • Non-developers need access — Users can open the shared URL in a browser

Keelson is not optimized for these workloads:

  • Very high-traffic public services — Large consumer services are outside the intended scope
  • Strict edge-latency requirements — Applications do not execute at CDN edge locations
  • Complex distributed systems — Advanced orchestration between many microservices is not the target use case
  • GPU-heavy inference — GPU instances are not provided
  • Specialized middleware — Apps that require self-managed Redis, external PostgreSQL, Kafka, or similar infrastructure need separate services
  • Dedicated infrastructure or strict network topology — VPC peering and dedicated nodes are not initial platform capabilities

Answer these questions in order for a quick compatibility check.

1. Are the users primarily members of your company or team?

If no, Keelson is probably not the right platform for an unrestricted public consumer service.

2. Does the app provide a web UI or an API?

If no, a cron-only application is still supported. Other execution models are outside the current scope.

3. Does it run on Node.js, Python, or Go?

If no, the required language is not currently among the supported runtimes.

4. Does it require a GPU or specialized middleware such as Redis or Kafka?

If yes, those dependencies are not currently provided by Keelson.

5. Is dedicated infrastructure or strict network control an initial requirement?

If yes, contact Keelson to discuss future Enterprise capabilities.

If the app passes all five checks, it should be a good fit for Keelson.


The following examples show the smallest practical deployment structure.

Directory structure:

my-app/
├── keelson.yaml
├── requirements.txt
└── app.py

keelson.yaml:

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none

Keelson injects PORT when the app starts. Read it in the application; do not declare it in keelson.yaml.

app.py:

import os
import uvicorn
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def index():
return {"message": "Hello from Keelson"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))

Directory structure:

my-app/
├── keelson.yaml
├── package.json
├── package-lock.json
└── index.js

keelson.yaml:

slug: my-app
runtime: node-slim
command: "npm start"
db:
mode: none
env:
NODE_ENV: "production"

Keelson installs the dependencies declared in package.json during the build. Generate package-lock.json with npm install and commit it with the source so the build uses the locked versions. Keep command limited to starting the app.

index.js:

const express = require("express");
const app = express();
const port = process.env.PORT || 8080;
app.get("/", (req, res) => {
res.json({ message: "Hello from Keelson" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`Listening on port ${port}`);
});

When deployment completes, Keelson assigns a URL such as https://my-app.keelson.run/. Only signed-in members who are allowed to use the app can access it at that URL.


No. Select a runtime and provide a startup command in keelson.yaml. Keelson builds the execution environment for you.

Can I host only a frontend or static site?

Section titled “Can I host only a frontend or static site?”

Yes. Configure assets in keelson.yaml to host built static files.

Yes. A JSON API without a user interface is a supported application form.

Bring an app from another tool

When bringing an app from another tool to Keelson, choose a deployment path based on its actual architecture, rather than the tool’s name. The same tool can generate different architectures depending on its settings and prompts.

  • What you can do: Deploy an app whose source code you have locally, using the path that matches its architecture
  • What you need: The complete source code (exported from the tool or in a Git repository), the Keelson CLI, and an AI agent (Quickstart)
  • Done when: The app opens at its Keelson URL and connects to the same data as in its original environment

Check these four things in the source code. Ask your AI agent to “check this app’s architecture,” and it will assess the app using the Skill’s procedure.

What to checkWhere to look
Does it have server-side processing?Dependencies and scripts in package.json. API routes, Server Actions, or Express indicate server-side processing. If it only produces vite build output, it is frontend-only
Which database does it use?Connection code for Supabase, Firebase, or PostgreSQL. File-based SQLite (sqlite3, better-sqlite3) needs to be rewritten
How does authentication work?Login screens and session management using Supabase Auth, Firebase Auth, Clerk, or similar services. Keelson authenticates users at the app’s entrance, so you need to decide how to handle existing authentication
Where are files stored?External storage (Supabase Storage, S3) can stay as it is. Writes to local disk need to be rewritten
Actual architectureDeployment path
Static frontend only (React, Vue, or Svelte build output; data comes from external service APIs)assets mode. Specify the build output directory
Server-side processing (Next.js, Express, FastAPI, etc.)Deploy as a server, started with command. See Framework notes
Frontend + API in a separate processHybrid configuration. Set both assets and command, and specify the API paths with assets.api
Depends on an external database, authentication, or storageUse one of the paths above, keep the connections, and check what needs to change (section 3 below)

This is the simplest path. Build locally and send only the output directory. For static site deployments, the CLI uploads only the contents of assets.dir and keelson.yaml. It does not run a server-side build (npm install / npm run build).

  1. Install dependencies and build locally. Put values that are embedded at build time, such as external service URLs and public keys, in the configuration file read during the build (.env.production for Vite, for example). Do not embed secrets in the frontend

    Terminal window
    npm install
    npm run build # Vite outputs to dist; Next.js static export outputs to out
  2. Add keelson.yaml. type: web is required for a static site without command

    slug: my-app
    type: web
    runtime: node-slim
    db:
    mode: none
    assets:
    dir: dist # Locally built output directory
    fallback: index.html # For SPA routing
  3. Run keelson deploy. Use --check --json to confirm that only files under dist and keelson.yaml will be uploaded

Automatic server-side builds (npm ci and npm run build --if-present) run only for server and hybrid configurations that have a command.

Update the server to listen on 0.0.0.0 using PORT, and put the startup command in command. For Next.js, use next start after next build; for Express, use node server.js. See Framework notes for startup commands and considerations for each framework.

File-based SQLite, local disk storage, and in-app timers need to be rewritten. If you ask an AI agent to deploy the app, it automatically handles changes marked ”△ Rewrite” in the compatibility table.

You can keep using them. Set db.mode: none and pass connection details (URLs and keys) through secrets. Keelson does not restrict outbound connections.

Configurations that call Supabase directly from the browser (anon key + RLS) also work. In that case, Supabase RLS continues to protect the data.

Authentication such as Supabase Auth or Firebase Auth

Section titled “Authentication such as Supabase Auth or Firebase Auth”

This requires the most consideration. On Keelson, users must log in through Keelson before entering the app. Decide what to do with the original authentication by examining authentication and data permissions together.

Original configurationDecision
Authentication only provides a login screen; data permissions do not depend on itRemove the original authentication and identify users using Keelson headers such as X-Keelson-User-Id. This removes the second login
Data permissions (RLS or policies using auth.uid()) depend on users from the original authentication systemYou cannot simply remove authentication. The app would lose the original user’s identity, so permission checks based on auth.uid() would no longer work (ownership policies would hide the data). Either retain the original authentication and require two logins, or move data access to the server and implement permission checks using Keelson user IDs
Per-user data is stored using user IDs from the original authentication systemYou need a mapping to Keelson user IDs. Matching by email address is a practical approach

Before simplifying a two-login flow, check where permissions are enforced.

Local files disappear on restart. Replace this storage with Managed SQLite or the Files / Media SDK.

Generated architectures vary, so these are only guidelines. Always inspect the actual app as described in section 1.

ToolCommon architectureLikely path
LovableReact + Vite frontend with Supabase (database, authentication, storage). Can sync to GitHubStatic frontend, keeping Supabase. Use the table above to decide how to handle Supabase Auth
BoltReact + Vite or Next.js. Uses Bolt’s built-in database or SupabaseStatic frontend or server-side processing. If using the built-in database, first check whether it accepts external connections
v0Next.js (App Router) by default. May include Server Actions or API routesServer-side processing. Start with next start
Built from scratch with Claude Code / Codex / CursorAny architecture is possibleCheck using section 1. The Skill assesses Python, Node.js, and Go apps

Open the app in your AI agent and ask it to “deploy this app to Keelson.” The agent assesses the app, creates keelson.yaml, declares secrets, deploys, and verifies the result. See Deploy an app for details.

After deployment, check that the app connects to the same data as in its original environment, uses Keelson login, and opens for other members.

  • Tool-specific backends (Lovable Cloud, Base44’s backend, etc.). Deploying source code to Keelson does not move the platform’s database, authentication, or functions with it. Keep them as external services (the tools provide guidance for hosting only the frontend elsewhere or migrating to Supabase), or rebuild them using Managed SQLite or other services
  • The tool’s editing features. Keelson runs the app; you edit it using your local AI agent
  • Languages such as Ruby, PHP, or Java, and architectures that require always-running processes. See Supported apps and constraints

How Apps Run

Most problems that survive a green deployment come from one assumption: that the application is a process that stays alive. It is not. This page states when your server-side code runs, and what that rules out.

If you only remember one sentence: server-side code is guaranteed to run while a request is in flight, and during a scheduled job you declared. There is no third time.


An application starts when a request arrives. After a period without traffic it stops, and the next request starts it again — so the first response after an idle period takes longer than usual.

Server-side code therefore runs in exactly two windows:

  1. While a request is being handled — a page view, an API call, a form submission.
  2. During a scheduled job you declared in crons — Keelson starts a separate instance, runs the command, and the instance exits.

Nothing runs in between. Code written for a resident server — a scheduler that fires at 09:00, a loop that polls an external service, work handed off after the response has been sent — does not run at all.

Front-end code in the browser is not affected by any of this. A timer or an auto-refresh in the page keeps working while the page is open.


None of the following produces an error. The deployment succeeds, the logs are silent, and the screen looks correct. That is what makes this list worth reading before you ship.

What the code doesWhat actually happensWhat to do instead
An in-process scheduler sends a report every morning (node-cron, APScheduler, setInterval plus a clock check, threading.Timer)Nothing happens at the scheduled timeDeclare it under crons
Work is handed off after the response is returned (FastAPI BackgroundTasks, a fire-and-forget promise)The screen reports success and the email never arrivesFinish it before responding, or record it and drain it from a crons job
The app returns “accepted” and the browser polls for a result computed in the backgroundThe status stays “processing” foreverRecord the work in the database and drain it from a crons job — and tell the user the wait can be up to one interval
A resident loop watches or synchronizes an external serviceIt stops when the app stopsDeclare a crons entry that does one pass and exits
State is kept in a module-level variable, an in-memory session, or a local fileIt disappears when the app stopsPut it in Managed SQLite

There is no queue service and no way for the app to trigger a run of itself. Keelson does not provide an API for submitting arbitrary asynchronous tasks, and event-driven background tasks are not currently supported. The workers: declaration that used to exist has been retired: a keelson.yaml that contains workers: — even an empty one — is rejected at deployment with workers_not_supported.


Do it inside the request. Anything whose success the caller needs to know about — validating, saving, a short external call — belongs before the response, where its failure can be reported.

Put a time-of-day or periodic task in crons. Move the job body into its own entrypoint that runs to completion and exits, then delete the in-process scheduler.

crons:
- name: daily-report
schedule: "0 9 * * *"
command: "node report.js"
timeout: 300

See Scheduled Jobs for the full configuration, plan limits, and worked examples.

Write down durable work and drain it from a crons job. For a side effect the user should not wait for — an email, a webhook, a sync — the request handler inserts a row into Managed SQLite and a scheduled job claims those rows, does the work, and marks them done. Three things are not optional here:

  • The drain must be idempotent. A run can be stopped at its timeout after the side effect but before the row is marked done, and the next run will see that row again.
  • Count attempts and give up. There is no automatic retry and no dead-letter queue. A row that fails forever is drained forever.
  • Tell the user about the delay before you build it. The row waits until the next run, and the shortest interval any plan allows is five minutes. “We will email you right away” is not a promise this shape can keep.

Stream output the user is waiting on. Server-sent events or chunked responses keep the work inside the request window, so the user sees progress instead of a spinner. The window ends when the client disconnects, so streaming is not a substitute for durable work.


LimitValue
Time to start responding to a request (first response header)120 seconds; exceeding it returns 504
Total lifetime of one request, streaming included300 seconds
Gap between chunks of a streamed response120 seconds
One scheduled-job runthe job’s timeout, 1–600 seconds, default 300

A 504 does not mean the work was rolled back — the handler may finish after the deadline. Keelson never retries a POST on your behalf, so make writes that a client might retry idempotent.

Scheduled jobs have their own rules that follow from running on a schedule rather than on demand: a run may start late, a run that is still going when the next occurrence arrives causes that occurrence to be skipped rather than queued, a failed run is not retried, and a run still executing at its timeout is stopped. Recovery is the next run.

Schedules are evaluated in the workspace time zone, which is set when the workspace is created and cannot be changed afterwards. Confirm the effective value before assuming your own local time.

The number of jobs per app, the shortest interval, the maximum timeout, and the monthly execution allowance all depend on the plan — see Plans and Limits.


Paste this into the tool that built the app:

In this Keelson app, server-side code is only guaranteed to run while a request is being handled and during a declared crons run. Find every in-process scheduler (node-cron, APScheduler, setInterval, threading.Timer) and everything deferred until after the response is sent, and either finish that work inside the request or move it to a crons entry in keelson.yaml. workers: no longer exists and there is no task-submission API. Leave front-end timers alone.


  • A daily or weekly task never runs, and nothing is logged.
  • A confirmation email or notification never arrives, although the screen said the operation succeeded.
  • A “processing” state never completes.
  • Data entered a moment ago is gone after a period of inactivity.

Storage and Data

A Keelson app runs with an ephemeral local filesystem.

AreaPersistenceIntended use
App directory (source code and dependencies)Ephemeral and replaced by deploymentsApplication code and installed packages
/tmpEphemeral and lost with the instanceTemporary scratch space

Keelson does not mount or create /data, and an application cannot normally create directories at the filesystem root. Use /tmp for bounded temporary work. Like other writes to the container filesystem, those bytes consume instance memory and disappear with the instance. Store durable relational data in Keelson Managed SQLite (db.mode: libsql). For durable files, use an appropriate Keelson SDK: the Files SDK for private files or the Media SDK for content served to app members.


Choose the durable interface that matches how the data is addressed and served.

  • Application records, such as customers, projects, and events: Managed SQLite (db.mode: libsql)
  • Private files, such as state, settings, and generated reports that only the app reads: Files SDK
  • Uploads and media served over HTTP: Media SDK
  • Recreatable caches and temporary files: /tmp; it is ephemeral and consumes instance memory

Do not use the local filesystem for persistence

Section titled “Do not use the local filesystem for persistence”

Do not put anything on the local filesystem that you cannot afford to lose. /data is not a platform-provided writable path. Choose storage by purpose:

  • Business data and other durable records: Keelson Managed SQLite (db.mode: libsql)
  • Private key-addressed files: use the Files SDK and retain each key
  • Published uploads and media: use the Media SDK and retain each returned ID
  • Temporary files: use /tmp, then delete scratch files promptly
  • Recreatable caches: dependencies and build output do not require durable storage
  • Application logs: inspect them through Keelson’s logging features instead of accumulating log files locally

For new apps, we recommend Keelson Managed SQLite. The platform provisions a dedicated managed SQLite-compatible libSQL database for each app and isolates it at the database level by workspace and app.

  • No separate setup: connection details are injected as KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN
  • Workspace isolation: each app’s credentials are scoped to its own database
  • Durable commits: writes go to the primary and are durable when committed
  • Regional placement: the primary is located in Tokyo
  • Point-in-time recovery: available on every plan; the recovery window depends on the plan

JavaScript, TypeScript, and Python applications can connect with a libSQL client. Enable the managed database explicitly in keelson.yaml; the platform does not infer this choice from application code.

db:
mode: libsql

Use an external database with db.mode: none

Section titled “Use an external database with db.mode: none”

If you need PostgreSQL, MySQL, or an externally managed libSQL database, set db.mode: none. Add its connection details as secrets yourself. Keelson does not provision or inject credentials for an external database.

db:
mode: none

Frameworks that normally default to file-based SQLite need a supported durable database integration. For Django, use this route with an external database, such as PostgreSQL. Keelson does not currently provide a supported Django backend for Managed SQLite. There is no durable deployment path for a local SQLite file.

A SQLite database placed directly on the container filesystem is not persistent. /data is neither mounted nor normally writable, and any writable local path can be lost on restart or scale-to-zero. Deployment checks fail closed when they detect an undeclared file-SQLite configuration.

Move applications using file-based clients such as better-sqlite3 or sqlite3 to a libSQL client and use db.mode: libsql. A regenerable temporary SQLite database may be declared under db.local_sqlite, but its path must be under /tmp or use :memory: and it remains intentionally ephemeral.

db:
mode: none
local_sqlite:
policy: ephemeral
paths:
- /tmp/cache.db
reason: "Rebuilt from the upstream API after every cold start"

Use the Files SDK for private durable files that only the app can read. Install it from the public package registry with npm install @keelsonhq/files, pip install keelson-sdk (from keelson import files), or go get github.com/keelsonhq/go-sdk (.../go-sdk/files).

Address each object by its Files SDK key and keep that key, ownership, and other application metadata in Managed SQLite. Files SDK objects have no URL and are not served over HTTP. See Files and media for operations, limits, and local-development behavior.

Use the Media SDK for images, PDFs, attachments, and other durable content served to app members. Install it from the public package registry with npm install @keelsonhq/media, pip install keelson-sdk (from keelson import media), or go get github.com/keelsonhq/go-sdk (.../go-sdk/media).

Uploading returns an object ID. Keep that ID, ownership, and other application metadata alongside the related record in Managed SQLite. Media URLs use the app’s authentication and are available to app members; they are not public URLs. See Files and media for operations, limits, and local-development behavior.

Managed SQLite commits are stored by the managed database service rather than on the app’s ephemeral filesystem. Restarting or redeploying the app therefore does not discard committed database data.

Daily backups, manual snapshots, and point-in-time recovery (PITR) are available on every plan. Manual snapshots are limited to five per app per day.

PlanDaily backup retentionPITR window
Starter1 generation24 hours
Plus3 generations7 days
Team7 generations14 days

Saved backups and any eligible PITR restore point can be downloaded on every plan. A point-in-time export creates a temporary database fork, produces a dump, and removes the fork after download. Export availability is not plan-gated.

PITR recovers a database to an eligible time within the plan window. Restore switches to a fork and retains the pre-restore generation for 72 hours, during which the restore can be undone.

ResourceCovered by Managed SQLite recovery?
Committed Managed SQLite (libSQL) dataYes, through daily backups, manual snapshots, and PITR within the plan window
Files under /tmpNo; /tmp is ephemeral
Objects in an external storeNo; use that provider’s recovery features
Application source codeNo; it is uploaded on each deployment
Environment variablesNo; they are managed separately in the console

Keep application code in version control such as Git. Put durable application records in Managed SQLite so they are eligible for the managed recovery path. Export critical data separately when your retention or recovery requirements extend beyond the PITR window.

Choose a recovery time within the plan’s window when starting a point-in-time recovery. Recovery operates on one app’s database and does not expose data from other apps. The requested time must also fall within the recovery window made available by the managed database service.

When to use an external database or object store

Section titled “When to use an external database or object store”

Managed SQLite works well for many internal applications, but an external service may be a better fit in these cases.

SituationWhyPossible choice
The dataset’s capacity or performance requirements outgrow Managed SQLiteThe workload needs a different capacity or performance profilePostgreSQL
The workload has very high concurrent write volumeSQLite serializes writersPostgreSQL or MySQL
BI or core business systems must query the data directlyOther systems need independent accessShared PostgreSQL or a data warehouse
Operations require specialized replication or recovery controlsThe database needs capabilities outside the managed defaultsManaged PostgreSQL

For a customer, inventory, or project management app, store records in Managed SQLite.

db:
mode: libsql

For CSV imports or scheduled aggregation, use /tmp as intermediate workspace and save records in Managed SQLite. Write a downloadable report with the Files SDK and keep its key in Managed SQLite.

For an internal RAG or chat backend, store document metadata and embeddings in Managed SQLite. Store private source documents with the Files SDK and keep their keys in Managed SQLite.

Files written to the local container filesystem can be lost with the instance. /tmp is the supported writable scratch location; /data is not a writable platform mount. Put durable records in Managed SQLite and durable file objects in the Files SDK or Media SDK, according to who should be able to read them.

# Wrong: local paths are ephemeral, and /data is not a writable mount.
db_path = "./data.db"
db_path = "/tmp/data.db"
db_path = "/data/main.db"
# Correct: connect to Managed SQLite with the injected credentials.
import os
import libsql
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)

An app that tries to create a database under /data with better-sqlite3, sqlite3, or a similar file client cannot rely on that path being writable or durable and fails deployment checks. Migrate to a libSQL client and Managed SQLite.

Daily backups, manual snapshots, PITR, and exports cover committed Managed SQLite data, not local files. Check the daily-backup retention and PITR window for your plan. Download a restore point when you need an independently retained copy.

Letting temporary storage grow without bounds

Section titled “Letting temporary storage grow without bounds”

Remove scratch files when processing finishes. Writable container files consume instance memory. Unbounded writes can exhaust that memory and crash the instance. Keep temporary work in /tmp; Keelson does not assign /data a separate disk capacity.

Code such as sqlite3.connect("/data/main.db") assumes durable local storage that Keelson does not provide. Read the injected Managed SQLite credentials and use a libSQL client instead.

Python: write to Managed SQLite with libSQL

Section titled “Python: write to Managed SQLite with libSQL”
import os
import libsql
def get_db():
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)
conn.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
return conn
conn = get_db()
conn.execute("INSERT INTO items (name) VALUES (?)", ("Example item",))
conn.commit()

The corresponding keelson.yaml enables Managed SQLite:

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql

Declare the libSQL client in requirements.txt or pyproject.toml. Keelson installs dependencies during the image build. The startup command must only start the app, and the app must listen on the PORT value Keelson injects.

Node.js: write to Managed SQLite with libSQL

Section titled “Node.js: write to Managed SQLite with libSQL”
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
await db.execute(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await db.execute({
sql: "INSERT INTO items (name) VALUES (?)",
args: ["Example item"],
});
  • Business data for internal apps, such as customers, projects, and stock
  • AI-generated apps that need a database immediately, without provisioning an external database
  • Many small apps, each with database-level app and workspace isolation
  • Very large datasets where an external database offers a better capacity and performance profile
  • Concurrent access from several independent systems, rather than access primarily from the deployed app
  • Specialized recovery or replication requirements beyond the plan’s managed capabilities
  • Direct integration with BI or core business systems that need their own database connection

Managed SQLite: No fixed limit by plan. The underlying platform’s technical limits still apply. If we detect load far beyond normal business use, or use that affects platform stability, other customers, or our reasonable operating costs, we may restrict or suspend the application. In an emergency, we may impose restrictions without prior notice.

The separate plan storage quota, from 10 GB on Starter to 50 GB on Team, is a workspace-wide aggregate. It counts deployment artifacts, stored file data, snapshot dumps and backups, and metered app data usage. It does not count the Managed SQLite database itself; Managed SQLite usage is metered separately. Purchasing additional workspace storage does not change the database’s separate technical limits. The container filesystem does not provide durable capacity.

Local container data can be lost. Managed SQLite data remains available after a redeploy because committed data is stored outside the app’s ephemeral filesystem. Eligible recovery times are limited by the plan’s PITR window.

Yes. Managed SQLite is the default recommendation, but an app can connect to an external PostgreSQL, MySQL, or other database over an outbound connection. Set db.mode: none and supply its credentials as secrets.

Local filesystems and Files SDK objects are isolated. If another app needs private data, expose an authenticated API from the app that owns the Files SDK object. Use the Media SDK only for content intended to be served to app members; its URL is not public and uses the app’s authentication.

How long does point-in-time recovery take?

Section titled “How long does point-in-time recovery take?”

Recovery time depends on data volume and the managed database service. Do not assume a fixed recovery time when defining an application’s recovery objective.

Use Keelson Managed SQLite (libSQL) and declare db.mode: libsql.

Read KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN from the environment and use a libSQL client. Do not use file-based SQLite.

Store private durable files with the Files SDK and keep each key in Managed SQLite. Use the Media SDK for content served to app members.

Use /tmp for bounded scratch data. Do not write to /data; Keelson does not provide it as a writable platform mount.

Files and media

The local filesystem is ephemeral, so save files that need to persist through an SDK. There are two SDKs for different purposes.

Files SDKMedia SDK
What to storeFiles that the app names and updates itself (settings, state, caches, generated CSV files)Images, PDFs, and attachments uploaded by users or generated by the app
Who can read themOnly the app. They have no URL and are not served over HTTPMembers of the app. They are served at URLs
OverwritingSupported (about once per second per key)Not supported (one ID per file, write once)
Limit10 MiB per file50 MiB per file

Neither SDK requires a declaration in keelson.yaml. A file is persistent when the save completes; there is no background synchronization. For structured data that is read and written on each request, use Managed SQLite.

LanguagePackage
Node.jsnpm install @keelsonhq/files @keelsonhq/media
Pythonpip install keelson-sdk (from keelson import files, media)
Gogo get github.com/keelsonhq/go-sdk (.../go-sdk/files, .../go-sdk/media)

Keys are slash-separated relative paths (settings.json, reports/2026-08.csv). The SDK has four operations: write, read, delete, and list. If a key does not exist, read returns null / None.

import * as files from "@keelsonhq/files";
await files.write("settings.json", JSON.stringify({ theme: "dark" }));
const raw = await files.read("settings.json"); // Uint8Array | null
const settings = raw ? JSON.parse(new TextDecoder().decode(raw)) : {};
const keys = await files.list("reports/"); // ["reports/2026-08.csv", ...]
await files.delete("reports/2026-07.csv");
import json
from keelson import files
files.write("settings.json", json.dumps({"theme": "dark"}))
settings = json.loads(files.read("settings.json") or "{}") # read() -> bytes | None
keys = files.list("reports/")
files.delete("reports/2026-07.csv")

A common use is to replace writes to local files with SDK calls. Change open("seen_urls.json", "w") to files.write("seen_urls.json", ...), and the Scheduled Job and web app can share the file.

Uploading with put returns an ID (ULID). Store the ID in the database, then use url(id) to construct a URL when displaying the file.

import * as media from "@keelsonhq/media";
// Upload
const id = await media.put(req.file.buffer, {
contentType: req.file.mimetype,
filename: req.file.originalname,
});
await db.execute({ sql: "UPDATE items SET photo_id = ? WHERE id = ?", args: [id, itemId] });
// Display
const src = media.url(id); // "/__keelson/media/<id>"
from keelson import media
file_id = media.put(upload.read(), content_type=upload.mimetype, filename=upload.filename)
src = media.url(file_id) # "/__keelson/media/<id>"
  • The URL is /__keelson/media/<id>. It uses the same authentication as the app, so only members with view permission for that app can open it. It is not a public URL
  • The SDK provides get(id) for content, stat(id) for the Content-Type and size, as well as exists(id) and delete(id)
  • If no Content-Type is specified, it is determined from the filename extension

Both SDKs automatically use local mode outside Keelson.

  • Files: writes actual files under ./.keelson/files/. Add .keelson/ to .gitignore
  • Media: writes files under ./media/ (configurable with MEDIA_DIR)

The SDKs use the KEELSON_MODE environment variable to determine whether they are running on Keelson. No configuration is required.

The Node.js Files SDK does not support local mode on Windows. Use WSL2 or a Linux devcontainer. Python and Go do not have this restriction.

  • You cannot list, download, or replace an app’s files from the console or CLI. If users need to retrieve files, implement an authenticated download endpoint in the app (Keelson authentication applies to regular routes)
  • Save files that exceed the limits directly from the app to an external S3-compatible object store (outbound traffic is unrestricted)

Files stored with Files / Media count toward the workspace storage capacity described in Plans and limits.

Scheduled Jobs (Cron)

Keelson can run code automatically on a schedule as well as serve an application. You manage jobs from the same codebase and the same keelson.yaml as your web application, making them useful for automating routine business operations.

Common use cases include:

  • generating a morning report and saving sales or KPI totals to a database;
  • synchronizing an external API with your database every hour;
  • sending daily Slack reminders about overdue tasks or pending approvals;
  • importing accepted CSV data in a nightly batch;
  • periodically deleting old records; and
  • draining work that the web application recorded for later processing.

The crons section is the only background execution mechanism available to an application. The former workers: declaration has been retired, and Keelson does not provide an API for submitting arbitrary asynchronous tasks. How Apps Run explains when server-side code is guaranteed to run and which common patterns that rules out.

Scheduled Jobs do not consume the plan quota for concurrently available web applications. They are counted separately from web applications.


  1. Define a job under crons in keelson.yaml.
  2. Deploy the application, and Keelson registers the scheduled job.
  3. Keelson runs the configured command according to its schedule.
  4. Keelson records the result and logs for each execution.

A job starts from the same codebase and image as the web application, but it runs in a separate instance. It does not share memory or local disk with the web application. Keelson does not provide a writable /data directory; use /tmp only for temporary files that may be discarded when the execution ends.

ConfigurationBehavior
Cron only, with no top-level commandRuns as a job-only application and does not consume the concurrent web-app quota
Web application plus cron, with a top-level commandRuns the web application and each job in separate instances; shared state belongs in a durable store

The job definitions use the same format in both configurations.

  • The dashboard shows whether each execution succeeded or failed.
  • Standard output and standard error are recorded as logs.
  • Usage includes runs that actually execute, whether they succeed, fail, time out, or are triggered manually. An occurrence skipped before the container starts does not consume the monthly execution quota.
  • A failed cron execution is not automatically retried. It waits for the next scheduled occurrence.

Define jobs in the crons section of keelson.yaml:

slug: my-app
runtime: python-slim
db:
mode: none
crons:
- name: daily-report
schedule: "0 9 * * *"
command: "python report.py"
timeout: 120
FieldRequiredDefaultDescription
nameYesJob name: 1–63 lowercase letters, numbers, and hyphens
scheduleYesFive-field cron expression
commandYesCommand to execute
timeoutNo300-second schema defaultTimeout from 1–600 seconds; the effective default is capped at the plan maximum

The number of jobs allowed in one application depends on its plan: Starter 3, Plus 5, and Team 10.

crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "python sync.py"
- name: daily-cleanup
schedule: "0 3 * * *"
command: "python cleanup.py"
timeout: 60
- name: weekly-report
schedule: "0 9 * * 1"
command: "python weekly_report.py"
timeout: 600

The 600-second timeout in the last example requires the Team plan.

Environment variables declared in the top-level env section are also available while a job runs.

db:
mode: libsql
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack incoming webhook URL"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: notify
schedule: "0 9 * * *"
command: "python notify.py"

The schedule is a standard five-field cron expression:

┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * *
GoalCron expressionMeaningMinimum plan
Every day at 9:000 9 * * *Run at 9:00 each dayStarter
Every 30 minutes*/30 * * * *Run at minute 0 and 30 of every hourPlus
Every hour0 * * * *Run at minute 0 of every hourStarter
Every 10 minutes*/10 * * * *Run at ten-minute intervalsTeam
Weekdays at 9:000 9 * * 1-5Run Monday through Friday at 9:00Starter
First day of each month0 0 1 * *Run at midnight on the first dayStarter
Every day at 3:000 3 * * *A common nightly batch scheduleStarter
Every minute* * * * *Syntax example only; no current plan permits this intervalNot available

Cron schedules are evaluated in the workspace time zone, which is detected automatically from the browser when the workspace is created. For example, 0 9 * * * runs at 09:00 in the workspace time zone.


Aggregate and save sales data every morning

Section titled “Aggregate and save sales data every morning”
  • Input: sales records in Managed SQLite (db.mode: libsql)
  • Processing: aggregate the day’s records and calculate totals
  • Output: save the result in a reports table
crons:
- name: daily-sales
schedule: "0 8 * * *"
command: "python aggregate_sales.py"
timeout: 120

Synchronize data from an external API every hour

Section titled “Synchronize data from an external API every hour”
  • Input: an external service’s REST API
  • Processing: fetch the latest data and apply changes to Managed SQLite
  • Output: updated rows in the relevant table
crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "python sync_from_api.py"
timeout: 180
  • Input: database records
  • Processing: delete rows whose expired_at value is in the past
  • Output: write the number of deleted rows to the log
crons:
- name: cleanup-expired
schedule: "0 2 * * *"
command: "python cleanup_expired.py"
timeout: 60
  • Input: weekly data from the database
  • Processing: render a report from a template
  • Output: save the report through an object-storage API
crons:
- name: weekly-report
schedule: "0 9 * * 1"
command: "python generate_weekly_report.py"
timeout: 300

The 300-second timeout requires a Plus or Team plan. On Starter, use at most 180 seconds or split the report into smaller batches.


A job runs repeatedly and may be interrupted. Design it so that running the same operation again does not create duplicate data or corrupt existing state.

# Avoid an unconditional INSERT that creates a duplicate on every run.
db.execute("INSERT INTO reports (date, total) VALUES (?, ?)", (today, total))
# Prefer an UPSERT that safely replaces the row for the same date.
db.execute("""
INSERT INTO reports (date, total) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET total = excluded.total
""", (today, total))

The schema default is 300 seconds, but the effective timeout is the lower of that value and the plan maximum. For example, omitting timeout on Starter gives an effective timeout of 180 seconds. An explicitly configured value must be in the absolute 1–600 second range and at or below the plan maximum; a larger value is rejected during deployment.

If work cannot finish in ten minutes, limit the number of records handled in one run and let later scheduled executions continue with the remainder.

Plan for failure without automatic retries

Section titled “Plan for failure without automatic retries”

Keelson does not automatically rerun a failed job. An idempotent job can recover naturally on its next scheduled execution. For critical jobs, also record a failure signal or send an alert to a service such as Slack.

When a job calls an external API, account for that service’s rate limits. A hypothetical every-minute schedule would quickly exceed many providers’ allowances, although current Keelson plans do not permit that interval.

Coordinate concurrent Managed SQLite writes

Section titled “Coordinate concurrent Managed SQLite writes”

The web process and every cron use the same Managed SQLite database. A cron can write while the web process is writing, and different crons can overlap when their execution times cross. The libSQL primary accepts one writer at a time, so write contention can fail an execution with errors such as SQLITE_BUSY or TRANSACTION_TIMEOUT.

Keep write transactions short and split large updates into bounded batches. If several processes update the same rows, serialize those writes in application logic or use a small, bounded retry with backoff for transient contention. Make the operation idempotent before retrying it; Keelson does not automatically retry a failed cron execution.

The web process and cron process do not share a local SQLite file or filesystem. Use Managed SQLite for shared relational state and the Files SDK for durable files. Files written to /tmp are ephemeral. Keelson does not provide a writable /data directory.


Standard output, such as Python print calls or JavaScript console.log calls, and standard error are captured separately. Each stream is limited to 256 KiB. If a stream exceeds that limit, Keelson discards the excess and appends an …[output truncated at 262144 bytes]… marker to the captured text. Output near the end of a very verbose run, including a final exception or summary, may therefore be absent. Open the dashboard to inspect the execution history and captured logs for an individual job.

Log useful progress information and record counts so a failure can be located without reproducing the entire run.

import datetime
print(f"[{datetime.datetime.now()}] job started")
# ... process records ...
print(f"processed records: {count}")
print(f"[{datetime.datetime.now()}] job completed")
  1. Read the execution log for an error message and stack trace.
  2. Check environment variables such as API credentials and database URLs.
  3. Check the timeout and confirm the command finishes within it.
  4. Reproduce locally by running the same command in a comparable environment.

You can give the Keelson job log directly to an AI agent and ask it to identify the cause, update the code, and redeploy the application.

Review this job error log, identify the cause, and fix the application.


Web applications, scheduled jobs, and durable data can work together in one Keelson application. The database, rather than process memory or local disk, is the handoff point between the web and cron instances.

Pattern: admin interface and nightly batch

Section titled “Pattern: admin interface and nightly batch”

Use the web application to enter settings and data, and let a nightly job handle the heavier aggregation work.

slug: sales-tool
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: nightly-aggregate
schedule: "0 2 * * *"
command: "python aggregate.py"
timeout: 300

The 300-second timeout requires a Plus or Team plan. Starter accepts a maximum of 180 seconds.

Declare Python dependencies in requirements.txt; Keelson installs them during the build. The web application must listen on the PORT environment variable injected by Keelson rather than declaring its own value.

  • During the day, users enter sales data through the admin interface.
  • At night, the job aggregates the data and updates report rows.
  • The next morning, the interface reads and displays those rows.

Pattern: process uploaded CSV records periodically

Section titled “Pattern: process uploaded CSV records periodically”

Record each accepted upload as a pending database row. A job can claim those rows periodically, process them, and write back the result.

crons:
- name: process-csv
schedule: "*/30 * * * *"
command: "python process_inbox.py"
timeout: 180

This 30-minute schedule requires a Plus or Team plan. Starter jobs must be scheduled at least 60 minutes apart.

  • The web application creates a pending row for the accepted CSV.
  • Every 30 minutes, the job processes pending rows and updates their status.
  • The web application displays or downloads the completed result.

Store the uploaded content with the Files SDK when the job needs the original file. Keep its key in Managed SQLite; do not pass it through a local directory.

crons:
- name: daily-summary
schedule: "0 7 * * *"
command: "python generate_summary.py"
timeout: 600

This 600-second timeout requires the Team plan.

  • At 7:00 each morning, the job summarizes the previous day’s data with an AI API.
  • The job stores the summary in Managed SQLite.
  • The web application displays summaries by date.

The field order is minute, hour, day of month, month, and day of week. To run at 9:00, use 0 9 * * *. The reversed 9 0 * * * runs at 00:09.

# Wrong for 9:00: this runs at 00:09.
schedule: "9 0 * * *"
# Correct: this runs at 09:00.
schedule: "0 9 * * *"

Output written to /tmp is not available to a later execution. A cron also runs separately from the web instance, and Keelson does not provide a writable /data directory. Put durable relational data in db.mode: libsql, and use an appropriate Keelson SDK for durable file content: Files for private files or Media for content served to app members.

A command that works locally can fail on Keelson if its API key exists only in your local environment. Configure environment variables and secrets for the deployed application instead of embedding credentials in source code.

A job that sends many requests every minute can be rejected by the external service. Increase the interval, batch the work, or limit requests within a run.

Scheduled Jobs have monthly execution quotas: Starter 1,000, Plus 5,000, and Team 15,000. After the quota is reached, remaining occurrences for the month are skipped. Only runs that actually execute count toward usage; skips caused by the quota, a previous active run, a disabled cron, or a stopped application do not increase the used count. When a cron or application is disabled, the scheduler may be removed entirely, so no skip record is guaranteed for each tick.

Hypothetically, an every-minute schedule would have about 43,200 occurrences in a 30-day month. No current plan permits that schedule, even for testing: the shortest available interval is five minutes on the Team plan. Keelson validates the plan’s minimum interval during deployment and rejects * * * * *.


This configuration runs a job without a web application:

slug: my-cron
runtime: python-slim
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
crons:
- name: heartbeat
schedule: "0 * * * *"
command: "python heartbeat.py"
timeout: 30

heartbeat.py:

import datetime
print(f"OK: {datetime.datetime.now()}")

This hourly schedule is accepted by every current plan.

Python: aggregate sales data in Managed SQLite

Section titled “Python: aggregate sales data in Managed SQLite”

This cron-only sample uses Managed SQLite. Add the following files alongside aggregate_sales.py.

keelson.yaml:

slug: daily-sales
runtime: python-slim
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: daily-sales
schedule: "0 8 * * *"
command: "python aggregate_sales.py"
timeout: 120

requirements.txt:

libsql==0.1.11

Keelson injects KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN because the deploy declares db.mode: libsql. The example expects the web application or a prior migration to have created and populated sales, and to have created reports with a unique date column.

aggregate_sales.py:

import datetime
import json
import os
import libsql
conn = libsql.connect(
database=os.environ["KEELSON_DB_URL"],
auth_token=os.environ["KEELSON_DB_AUTH_TOKEN"],
)
today = datetime.date.today().isoformat()
row = conn.execute(
"SELECT COUNT(*), SUM(amount) FROM sales WHERE date = ?", (today,)
).fetchone()
report = {"date": today, "count": row[0], "total": row[1] or 0}
conn.execute(
"""
INSERT INTO reports (date, payload) VALUES (?, ?)
ON CONFLICT(date) DO UPDATE SET payload = excluded.payload
""",
(today, json.dumps(report)),
)
conn.commit()
print(f"aggregation complete: {report}")

This cron-only sample needs these files alongside sync.mjs.

keelson.yaml:

slug: catalog-sync
runtime: node-slim
db:
mode: libsql
env:
SYNC_API_URL: "https://api.example.com/items"
crons:
- name: hourly-sync
schedule: "0 * * * *"
command: "node sync.mjs"
timeout: 180

Replace the example SYNC_API_URL with the HTTPS endpoint to synchronize. If the endpoint needs a credential, declare the credential under secrets and set its value separately instead of committing it under env.

package.json:

{
"private": true,
"dependencies": {
"@libsql/client": "0.17.4"
}
}

Run npm install --package-lock-only after creating package.json, and commit the generated package-lock.json so the build resolves the same dependency version. Keelson injects the two database environment variables for db.mode: libsql.

sync.mjs:

import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.KEELSON_DB_URL,
authToken: process.env.KEELSON_DB_AUTH_TOKEN,
});
const API_URL = process.env.SYNC_API_URL;
async function sync() {
const res = await fetch(API_URL);
const items = await res.json();
await db.execute(`
CREATE TABLE IF NOT EXISTS synced_items (
id TEXT PRIMARY KEY,
data TEXT,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
for (const item of items) {
await db.execute({
sql: `INSERT INTO synced_items (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET
data = excluded.data,
synced_at = CURRENT_TIMESTAMP`,
args: [item.id, JSON.stringify(item)],
});
}
console.log(`synchronized ${items.length} records`);
}
sync().catch((err) => {
console.error("synchronization failed:", err);
process.exit(1);
});
import datetime
import json
import os
import urllib.request
slack_webhook = os.environ["SLACK_WEBHOOK_URL"]
message = {"text": f"Daily report is ready: {datetime.date.today()}"}
request = urllib.request.Request(
slack_webhook,
data=json.dumps(message).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(request)
print("Slack notification sent")

keelson.yaml:

slug: daily-notifier
runtime: python-slim
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
secrets:
items:
- name: SLACK_WEBHOOK_URL
description: "Slack incoming webhook URL"
required:
- all_of: [SLACK_WEBHOOK_URL]
crons:
- name: daily-notify
schedule: "0 9 * * 1-5"
command: "python notify.py"
timeout: 30

Declare only the secret name in keelson.yaml; never commit its value.

The CLI can set a secret only after the application exists. For an existing application, set the value and immediately redeploy it with --apply:

Terminal window
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --apply

For a new application, run keelson deploy --new once. The required-secret check blocks that first deployment, but the application remains created. Store the value without --apply, because there is no completed deployment to redeploy yet, and then deploy the same source tree again without --new:

Terminal window
printf '%s' "$SLACK_WEBHOOK_URL" | keelson secrets set SLACK_WEBHOOK_URL --app daily-notifier
keelson deploy

Use crons not only for a particular time of day, but also for work that should be drained every few minutes. Define one short-interval schedule and let each execution process a bounded number of pending records before exiting.

slug: mailer
runtime: python-slim
command: "python app.py" # Web: create a pending row
db:
mode: libsql # Durable state shared by web and cron
env:
PYTHONUNBUFFERED: "1"
crons:
- name: drain
schedule: "*/5 * * * *" # Team: drain every five minutes
command: "python drain.py" # Process pending rows and exit 0
timeout: 120

The five-minute schedule requires the Team plan. On Plus, use at least 15 minutes; on Starter, use at least 60 minutes.

  1. Queue work in database rows and drain it idempotently. The web side writes a pending row to Managed SQLite. The cron claims pending rows, performs the work, and updates processed_at or status. Make repeating any item harmless, because a timeout can leave it for the next run.
  2. Do not use an in-process scheduler. APScheduler, node-cron, FastAPI BackgroundTasks, setInterval, and similar mechanisms assume a resident process. Keelson applications can scale to zero, so move periodic work into a crons declaration.
  3. Use only durable services for shared state. Put durable relational state in Managed SQLite and private durable files in the Files SDK. A file written to a cron’s local disk is discarded and is never visible to the web application.

A run is stopped after at most 600 seconds. When the amount of work is variable, set a maximum batch size and leave the rest for the next schedule. If the previous execution is still active, the overlapping occurrence is skipped.


Limits for crons vary by plan.

LimitStarterPlusTeam
Cron entries per app3510
Minimum cron interval60 min15 min5 min
Execution timeout maximum3 min5 min10 min
Scheduled Jobs executions per month1,0005,00015,000
  • Every timeout also has an absolute 600-second maximum. The lower of the plan limit and the absolute maximum applies.
  • A schedule more frequent than the plan’s minimum interval is rejected.
  • The monthly quota counts runs that actually execute, not skipped occurrences. After it is reached, remaining occurrences are skipped without increasing the used count until the quota resets the following month.

Keelson rejects a deployment when its crons configuration does not fit the execution model or plan. The structured error includes a code and a hint that you can pass to an AI agent along with the configuration.

SituationResolution
workers: is declared (workers_not_supported)Delete the block and rewrite the operation as a crons entry
timeout is above 600 secondsReduce it to 600 or less
timeout is above the plan maximumReduce it to the plan limit or upgrade the plan
The schedule is more frequent than the plan permitsIncrease the interval or upgrade the plan
The application defines too many cron entriesCombine jobs, remove entries, or upgrade the plan

  • Routine business automation: daily or hourly aggregation, notifications, data synchronization, and cleanup.
  • Workflows shared with a web application: interactive data entry during the day followed by scheduled batch processing.
  • Periodic external integration: fetching from APIs or sending scheduled webhooks.
  • Regular data maintenance: removing old records and creating reports.
  • Immediate event-driven processing: finish small work within the request or use an external task service when an event must trigger work immediately.
  • Jobs longer than ten minutes: split the work into bounded batches handled over multiple executions.
  • Arbitrary asynchronous tasks submitted by the application: Keelson does not provide a task-submission API in the initial release.
  • Complex job chains: use a dedicated workflow engine when jobs require dependencies, branching, or orchestration.
  • Second-level precision: five-field cron expressions schedule by the minute.

No. The job runs again at its next scheduled occurrence. Make the command idempotent so the next execution can safely recover unfinished work.

Can I run only a job, without a web application?

Section titled “Can I run only a job, without a web application?”

Yes. Omit the top-level command and define one or more crons entries to deploy a job-only application.

The dashboard shows each job’s execution history, success or failure status, and logs.

What happens when the monthly quota is reached?

Section titled “What happens when the monthly quota is reached?”

Remaining occurrences in that month are skipped. The quota resets the next month. Limits are Starter 1,000, Plus 5,000, and Team 15,000 occurrences per month.

Cron schedules are evaluated in the workspace time zone. This setting is detected automatically from the browser when the workspace is created.


Add a job that aggregates sales data every morning at 9:00. Define it in the crons section of keelson.yaml.

Create a batch process that fetches an external API every 30 minutes and synchronizes the result to Managed SQLite. This schedule requires Plus or a higher plan.

Add a cleanup job that deletes old records every day at 3:00.

Configuring keelson.yaml

keelson.yaml tells Keelson how to build, start, and publish an application.

This page explains why the file exists, what its main fields do, and how it is used. For the complete definition of every field, see the keelson.yaml reference.

When you use the Keelson Agent Skill, an AI agent can generate this file for you, so you usually do not need to write it from scratch.

keelson.yaml is an application-specific configuration file placed in the root directory of your project.

Keelson reads it to determine:

  • the app identifier used in its URL
  • the runtime used to execute the app
  • how the app starts
  • whether Keelson should provision Managed SQLite
  • whether the app has scheduled jobs
  • whether Keelson should serve static assets

The file is not application code. It is the description of how to run that application on Keelson. You do not need to memorize every option; start with a small configuration and add only the features the app needs.

Applications differ in language, startup command, database requirements, and the platform features they use. Keelson reads keelson.yaml to understand those differences and deploy each app correctly.

Without the file, Keelson cannot decide how to handle the project. An AI agent can inspect your code and generate the configuration, but the resulting file is still the explicit deployment contract.

Keelson reads keelson.yaml when a deployment starts. It then:

  1. Validates the configuration, including field values and cross-field rules
  2. Builds the app for the selected runtime
  3. Starts it with the configured command, when it has a web process
  4. Prepares features such as Managed SQLite and scheduled jobs
  5. Publishes it at the URL associated with its slug

If the configuration does not match the application, the build can fail or the built application may fail to start.

Fields are defined at the top level unless shown as part of a nested block.

FieldDescriptionExample
slugApp identifier used as part of its URLmy-app
runtimeExecution environmentpython-slim, node-slim, go-slim
db.modeDatabase strategy; Managed SQLite or no platform databaselibsql, none

For an application with a web process, add command. A cron-only app can omit it and define crons instead. A static site can omit it and configure assets.

command: "python app.py"
FieldDescription
commandWeb process startup command, as a string or argument list
envNon-secret environment variables as key-value pairs
dbManaged SQLite choice and database-related configuration
cronsTime-triggered scheduled jobs
assetsStatic asset serving configuration
typeApp type; web enables static-site behavior

See the keelson.yaml reference for field types, constraints, and all supported values.

The combination of command and assets determines how an app is served.

PatterncommandassetsBehavior
Regular appPresentAbsentRuns as an application process
Static site or SPAAbsentPresentServes static files
HybridPresentPresentServes static files and a backend API

Every mode still declares runtime and db.mode, even when it does not use a platform-managed database.

Do not put API keys, tokens, passwords, or other secret values directly in keelson.yaml.

The file normally lives in the same repository as your source code. Add secret values through the console and declare secret requirements using the supported secrets configuration when needed. Use env only for non-sensitive values. Do not set PORT in env: Keelson injects the platform-assigned PORT, and your web server must read it when starting.

# Wrong: a secret value is committed with the app.
env:
OPENAI_API_KEY: "replace-with-a-real-key"
# Correct: configure OPENAI_API_KEY as a secret in the console.
env:
NODE_ENV: "production"

For details, see Environment Variables and Secrets.

The smallest useful web-app configuration declares slug, runtime, command, and the required database strategy. Use db.mode: none when the app does not need Keelson Managed SQLite.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none
slug: my-app
runtime: node-slim
command: "npm start"
db:
mode: none
slug: my-app
runtime: go-slim
command: "./app"
db:
mode: none

Keelson builds the Go binary for Linux as ./app during deployment. Runtimes come in -slim variants for general applications and -media variants that include image and video processing libraries. Start with -slim unless the app needs those media libraries. For every runtime, declare dependencies in the project-root manifest (requirements.txt or pyproject.toml, package.json, or go.mod). Keelson installs them during the image build, so command must only start the already-built application. Each web app must listen on the injected PORT value.

Different kinds of apps use different optional fields.

Set db.mode: libsql to provision a managed database. Keelson injects its connection details as KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: libsql

Local /data storage is ephemeral. Put durable records in Managed SQLite.

Set type: web and point assets at the built asset directory. Static sites still declare db.mode: none because they do not use Managed SQLite.

The deploy archive normally excludes .git, .venv, __pycache__, .pytest_cache, node_modules, dist, build, .idea, .vscode, and .DS_Store. Keelson CLI v0.1.1 and later exempt the declared assets.dir and its ancestors, so the dist example below is included. Exclusions still apply inside that directory (dist/node_modules/** remains excluded), and .git, symlinks, other non-regular files, and a file selected by --secrets-from-env-file are never archived. Run keelson version and upgrade before using dist or build with an older CLI.

slug: my-site
type: web
runtime: node-slim
db:
mode: none
assets:
dir: dist
fallback: index.html

A cron-only app can run a script on a schedule without starting a web server.

slug: daily-report
runtime: python-slim
db:
mode: none
crons:
- name: generate
schedule: "0 9 * * *"
command: "python report.py"
timeout: 120

A hybrid app serves a frontend and a backend API from one deployment.

slug: my-app
runtime: node-slim
command: "node server.js"
db:
mode: none
assets:
dir: public
fallback: index.html
api: /api

See the configuration examples in the keelson.yaml reference for complete patterns.

Use identity in your app

Keelson handles sign-in and permission checks, so your app does not need authentication code. The app only reads the information added by Keelson and changes its behavior based on who the user is and what they can do.

There are three levels, depending on what you need.

What you want to doWhat to useAdditional setup
Identify who is accessing the app (record the creator, show only the user’s own data)Headers X-Keelson-User-Id / -Email / -NameNone
Create a screen that only managers can seeHeader X-Keelson-User-App-PermsNone (permissions are set in the console)
Use three or more permission levels or branch by groupIdentity SDK attributes.groupskeelson apps directory enable

Keelson adds headers to authenticated requests.

HeaderContents
X-Keelson-User-IdUser ID (a stable identifier; use this when storing the user in a database)
X-Keelson-User-EmailEmail address (empty if the user has no email address in Keelson)
X-Keelson-User-NameDisplay name (empty if the user has no display name in Keelson)
# FastAPI / Flask
user_id = request.headers.get("X-Keelson-User-Id")
email = request.headers.get("X-Keelson-User-Email")
// Express
const userId = req.headers["x-keelson-user-id"];
const email = req.headers["x-keelson-user-email"];

The proxy adds these headers. Authorization, Cookie, and headers with the same names sent by the browser are removed before reaching the app, so they cannot be spoofed. The app does not manage sessions itself.

The Identity SDK lets you read the headers with types and requires no network access.

import { getCurrentUser } from "@keelsonhq/identity";
const user = getCurrentUser({ headers: req.headers }); // { id, email, name }

An app has two permissions: view and manage. Assign them to groups in the console or CLI (Groups and app access). Keelson passes the result in a header.

X-Keelson-User-App-PermsMeaning
viewCan view
view,manageCan view and manage

The app only checks whether manage is included. Do not put group names in the code.

const perms = (req.headers["x-keelson-user-app-perms"] ?? "").split(",");
const canManage = perms.includes("manage");
app.get("/admin", (req, res) => {
if (!canManage) return res.status(403).end();
...
});
perms = request.headers.get("X-Keelson-User-App-Perms", "").split(",")
can_manage = "manage" in perms

This level supports cases such as an app only for the accounting team or a settings screen only for managers. You can change who has manage in the console without changing the code.

If you need three or more permission levels or group-based branching, use the Identity SDK to retrieve the user’s groups.

Allow the app to read the Directory API, then redeploy it.

Enabling Directory access stores the token, but it is not injected into the app until you redeploy.

Terminal window
keelson apps directory enable
keelson deploy

This injects KEELSON_DIRECTORY_TOKEN into the app, and the SDK uses it automatically. Use the token only on the server; do not pass it to the browser.

import { getCurrentIdentity } from "@keelsonhq/identity";
const me = await getCurrentIdentity({ headers: req.headers });
const groups = me.attributes?.groups ?? []; // Example: ["everyone", "developers", "accounting"]
if (groups.includes("accounting")) { ... }
from keelson_identity import get_current_identity
me = get_current_identity(headers=request.headers)
groups = me.attributes.groups if me.attributes else []
if "accounting" in groups: ...

attributes.groups contains group keys.

  • Role-based system groups: Owner belongs to owners, developers, and everyone; Admin belongs to admins, developers, and everyone; Developer belongs to developers and everyone; App User belongs to everyone
  • Custom groups that the user belongs to and that have permission for this app. Groups that are not assigned to the app are not included
  • Keys do not change, so you can use them in code for checks. Non-ASCII keys such as 経理 are also supported

The return value of getCurrentIdentity also contains the user (id, email, name), their workspace role (workspace.role), and their permissions for this app (app.permissions).

When you need information about other members, such as options for an assignee field, use listMembers / list_members (with search and filtering by role or group), getUser, and listGroups.

The Identity SDK returns a dummy user when KEELSON_LOCAL_MODE=1. Change the values with KEELSON_LOCAL_USER_ID, KEELSON_LOCAL_USER_EMAIL, KEELSON_LOCAL_WORKSPACE_ROLE, and related variables. Code that reads only the headers must account for None / undefined because the headers are absent during local development.

Requests without sign-in, such as webhooks

Section titled “Requests without sign-in, such as webhooks”

Paths called by external systems (auth.endpoints) do not receive X-Keelson-User-Id. Authenticate these requests with a webhook signature or app token. See External integrations.

External integrations

There are no restrictions on outbound traffic from an app to services such as OpenAI, Slack, Google Sheets, or an external database. Pass API keys as secrets and make the calls.

By default, all traffic coming into the app from an external source stops at the sign-in screen. You must declare callers that do not have a browser sign-in, such as a Stripe webhook or an API call from an internal system.

Declare paths that allow requests without sign-in under auth.endpoints in keelson.yaml. Paths must start with /api/webhooks/ or /api/external/.

auth:
endpoints:
- path: /api/webhooks/stripe
methods: [POST]

Requests to these paths do not receive X-Keelson-User-Id. The app must verify that the caller is legitimate.

  • When the sender provides a signature (Stripe, GitHub, Slack, and similar services): receive the signing secret through secrets and verify the signature
  • When the sender does not provide a signature: issue an app token (described below) and receive it in the X-Webhook-Secret header or in the URL /api/webhooks/<token>/...

Undeclared paths continue to require sign-in. Open only the paths required for webhooks.

The platform reserves /api/webhooks/email and /api/webhooks/email-events, so you cannot declare them as custom app webhooks. When inbound email is enabled, the platform calls /api/webhooks/email directly as described below; you do not add it to auth.endpoints.

Issue an app token when another internal system or script needs to call the app’s API.

Terminal window
keelson apps tokens create --name batch-client --scope api --allowed-ip 203.0.113.0/24
  • Tokens have the format keelson_... and are shown only once, when created
  • --scope api corresponds to paths under /api/external/, while --scope webhook corresponds to paths under /api/webhooks/
  • Use --allowed-ip to restrict source IPs (CIDR, multiple values supported). This restriction applies per token, separately from workspace or app IP controls
  • You can configure the same settings from the app screen in the console. Use keelson apps tokens list / rotate / delete to rotate and revoke tokens

The caller adds Authorization: Bearer keelson_... and calls a path declared in auth.endpoints.

Terminal window
curl -H "Authorization: Bearer keelson_xxx" https://acme--myapp.keelson.run/api/external/status
auth:
endpoints:
- path: /api/external/status
methods: [GET]
- /api/external/import # No methods specified = all methods

Before forwarding the request, the gateway checks in order that the token is valid, the scope matches, the token belongs to this app, the source IP is allowed, and the declared path and method match.

You can receive email addressed to your app through Keelson.

email:
inbound:
enabled: true

This is disabled by default. Do not configure it for apps that only send email or do not handle email.

After a successful deployment, the app receives the address <slug>@inbound.keelson.run, where <slug> is the app slug. The console shows the exact address assigned in the current environment.

Implement POST /api/webhooks/email in the app. This is a platform-reserved endpoint, so do not declare it under auth.endpoints; Keelson calls it directly when mail arrives.

The following JSON is an excerpt of one delivery, not the complete payload. Your handler should ignore additional keys.

{
"delivery_id": "del_01JEXAMPLE",
"attempt": 1,
"received_at": "2026-09-04T10:15:30Z",
"from": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"to": [
{
"name": "Support",
"address": "my-app@inbound.keelson.run"
}
],
"cc": [],
"reply_to": {
"name": "Ada Lovelace",
"address": "replies@example.com"
},
"subject": "Question about my account",
"text": "Hello from the plain-text part.",
"html": "<p>Hello from the HTML part.</p>",
"envelope_to": "my-app@inbound.keelson.run",
"references": ["<earlier-message@example.com>"],
"attachments": [
{
"id": "att_01JEXAMPLE",
"filename": "question.pdf",
"content_type": "application/pdf",
"size_bytes": 48231,
"download_url": "https://example.invalid/temporary-download"
}
]
}

Keelson provides the signing secret to the app in KEELSON_EMAIL_WEBHOOK_SECRET. Always verify the signature against the unmodified request body before processing the message. Choose the example for your SDK; each endpoint returns 200 after successful verification and 401 when verification fails.

import os
from fastapi import FastAPI, HTTPException, Request, Response
from keelson_email import EmailError, verify_webhook
app = FastAPI()
secret = os.environ["KEELSON_EMAIL_WEBHOOK_SECRET"]
@app.post("/api/webhooks/email")
async def receive_email(request: Request) -> Response:
try:
message = verify_webhook(await request.body(), request.headers, secret)
except EmailError as exc:
raise HTTPException(status_code=401, detail="invalid signature") from exc
# Process message here.
return Response(status_code=200)
import { createServer } from "node:http";
import { verifyWebhook } from "@keelsonhq/email";
const secret = process.env.KEELSON_EMAIL_WEBHOOK_SECRET;
if (!secret) throw new Error("KEELSON_EMAIL_WEBHOOK_SECRET is required");
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/api/webhooks/email") {
response.writeHead(404).end();
return;
}
let message;
try {
message = await verifyWebhook(request, secret);
} catch {
response.writeHead(401).end();
return;
}
// Process message here.
response.writeHead(200).end();
}).listen(process.env.PORT ?? 3000);
package main
import (
"net/http"
"os"
"github.com/keelsonhq/go-sdk/email"
)
func main() {
secret := os.Getenv("KEELSON_EMAIL_WEBHOOK_SECRET")
if secret == "" {
panic("KEELSON_EMAIL_WEBHOOK_SECRET is required")
}
http.HandleFunc("/api/webhooks/email", func(w http.ResponseWriter, r *http.Request) {
message, err := email.VerifyWebhook(r, secret)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Process message here.
_ = message
w.WriteHeader(http.StatusOK)
})
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
panic(err)
}
}

To connect to PostgreSQL, MySQL, or your own libSQL database, set db.mode: none and pass the connection details as secrets. See Database for details.

Save files that exceed the Files / Media SDK limits directly from the app to an S3-compatible object store. Pass credentials as secrets.

Framework notes

Keelson is not limited to a particular framework. Any HTTP server that listens on PORT can run. However, deploying a development server can make the app slow, expose internal information on error pages, or drop requests during shutdown. This page lists production startup methods by framework.

If you ask an AI agent to deploy the app, it applies these settings automatically because the Skill contains the same information.

  • Bind to 0.0.0.0 on PORT. Requests cannot reach a server bound to 127.0.0.1
  • Do not put dependencies in command. They are installed at build time from requirements.txt, package.json, or go.mod
  • Shutdown is SIGTERM, then SIGKILL after 10 seconds. Finish in-flight requests within 10 seconds
  • 1 vCPU. Use one worker
  • Debug mode is off by default. Debug pages may expose environment variables, including database authentication tokens
  • Use KEELSON_MODE to detect Keelson. The value is keelson when running on the platform. Using DEBUG for this purpose prevents the app from starting locally
ON_KEELSON = os.environ.get("KEELSON_MODE") == "keelson"
DEBUG = os.environ.get("DEBUG", "false").lower() == "true" # Defaults to false
command: "uvicorn main:app --host 0.0.0.0 --port $PORT"
env:
PYTHONUNBUFFERED: "1"

Do not add --reload; file watching consumes memory and may start the app twice. Keep the default of one worker.

app.run() is a development server. Add gunicorn to requirements.txt and use it to start the app.

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 app:app"
env:
PYTHONUNBUFFERED: "1"

Replace app:app with the module name and app object name. --graceful-timeout 9 keeps shutdown within the 10-second SIGTERM grace period.

Django can only run with an external database (db.mode: none plus connection details such as PostgreSQL passed as secrets). This is because Django’s ORM has no backend for Managed SQLite (libSQL).

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 config.wsgi:application"
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
  • Default to DEBUG = False. Pass SECRET_KEY as a secret
  • With DEBUG=False, Django does not serve static files. Add whitenoise to MIDDLEWARE, configure STATIC_ROOT, and include the output of collectstatic
  • Keep ALLOWED_HOSTS = ["*"]. Restricting it may cause health checks to return 400
  • Construct CSRF_TRUSTED_ORIGINS from KEELSON_APP_URL because the request’s Host is an internal hostname

Replace the sqlite:///app.db engine configuration with sqlalchemy-libsql-native. Models and queries do not change. This path is experimental, and an AI agent asks for confirmation before applying it.

Streamlit does not currently work because it requires WebSockets. Gradio 4 and later are under verification.

NODE_ENV is not set automatically. Declare it in keelson.yaml. Without it, Express and similar frameworks may include stack traces in responses.

env:
NODE_ENV: "production"

Only package-lock.json is used as a lockfile. pnpm and yarn lockfiles are ignored, so generate it with npm install --package-lock-only.

command: "npm start" # package.json: "start": "node server.js"
  • Do not return stack traces from error handlers
  • Use app.set("trust proxy", 1) to receive the client IP and protocol from the edge
  • Pass session and JWT secrets through secrets

Build the app and start it with next start. next dev is not for production.

command: "npm run start" # package.json: "start": "next start -p $PORT"
  • npm run build runs automatically during the build. Confirm that it produces .next/
  • If the script is set to "start": "next dev", fix it instead of adding a workaround
  • Read KEELSON_DB_URL on the server. Putting it in NEXT_PUBLIC_* sends it to the browser

provider = "sqlite" does not work as-is. Use @prisma/adapter-libsql pinned to the same major version as @prisma/client (verified with 6.x). An AI agent asks for confirmation before applying this change.

Keelson builds the app with go build -o /workspace/app . and starts it with ./app.

runtime: go-slim
command: "./app"
  • The app is built with CGO_ENABLED=0. You cannot use cgo SQLite drivers such as mattn/go-sqlite3. Use a pure Go libSQL client with Managed SQLite
  • Do not leave ListenAndServe running without shutdown handling. Use signal.NotifyContext and server.Shutdown to drain in-flight requests within 10 seconds
  • If you use GORM, replace it with database/sql plus libSQL

Set assets.dir to the build output from Vite, a Next.js static export, Astro, or a similar tool. The build runs automatically as npm run build. Even if dist/ is in .gitignore, declaring it in assets.dir includes it in the archive.

Deploy


Deploy an App

This page explains how to deploy an app you have built to Keelson. It is intended as the next step after you have tried the sample app in the Quickstart.

In Keelson, a deploy does not end when you upload your code. A deploy covers building the app, starting it, checking that it is healthy, and making it available at its app URL.

A deployment follows these steps:

  1. Prepare the app — Have the source code available in a local project directory.
  2. Create keelson.yaml — Add the configuration file that tells Keelson how to run the app.
  3. Ask an AI agent to deploy it — Use the Keelson Agent Skill to run the deployment.
  4. Let Keelson build, start, and verify the app — Keelson builds a container image, starts the container, and runs health checks.
  5. Open the app URL — After the deployment succeeds, the app is available at https://<workspace-slug>--<app-slug>.keelson.run.

Confirm that you have the following:

  • The app’s source code
  • An app that works with a supported runtime
  • A keelson.yaml file in the project root
  • A list of the environment variables and secrets the app needs; see Environment Variables and Secrets
  • The Keelson CLI installed and logged in
  • The Keelson Agent Skill installed for your AI agent

keelson.yaml is the configuration file Keelson uses to determine how to run your app. Place it in the root directory of the project you deploy.

The file describes the app identifier, runtime, start command, and other settings required for a deployment.

Here is a minimal example:

slug: my-app
runtime: python-slim
command: "python app.py"
  • slug identifies the app and forms the app-specific part of its URL.
  • runtime selects an execution environment such as python-slim, node-slim, or go-slim.
  • command is the command Keelson runs to start the app.

The file can also define non-sensitive environment variables, a managed database, and scheduled jobs. See the keelson.yaml Reference for every field.

Open the app directory in an AI agent that supports Agent Skills, such as Claude Code or Codex, and ask it to deploy the project.

Example requests:

“Deploy this app to Keelson.”

“If this project does not have a keelson.yaml file, create one and then deploy it to Keelson.”

“If the app fails to start, inspect the error, fix it, and redeploy.”

The agent reads keelson.yaml and uses the CLI to perform the build and deployment. It can also inspect the project and create keelson.yaml when the file is missing.

The Agent Skill ultimately uses the Keelson CLI. You can run the same operation yourself from the project root. For the first deployment of a new app, run:

Terminal window
keelson deploy --new

For a later deployment to the existing app, run:

Terminal window
keelson deploy

If you belong to more than one workspace or need to choose a specific existing app, the CLI prompts for the missing target or accepts the relevant --workspace and --app options. An AI agent normally handles that selection for you.

Keelson performs these operations in order:

  1. Validate configuration — Check the contents of keelson.yaml.
  2. Build — Produce a container image from the source code.
  3. Start — Start the container and the app process.
  4. Health check — Verify that the app can accept requests.
  5. Publish the app URL — Make the app reachable at its keelson.run URL.

You do not need to operate these stages individually. The CLI watches their progress, and an AI agent can follow the result through completion.

Keelson serves your HTML exactly as you deployed it.

A deployment is successful only when all of the following are true:

  • The app container has started.
  • The app has passed its health checks.
  • The app can be opened at its app URL.

A successful build alone is not a successful deployment. The app must start and respond through its URL.

Verify the deployed app with authentication

Section titled “Verify the deployed app with authentication”

After deployment, you or an AI agent can fetch a protected app path without a browser:

Terminal window
keelson app curl -i / [--app <slug>] [--confirmation <id>]
keelson app curl /api/items --method POST --data '{"name":"example"}' --header 'Content-Type: application/json' [--app <slug>] [--confirmation <id>]
keelson app curl --form title=example --form photo=@sample.png --form 'raw=@sample.bin;type=application/octet-stream' /upload [--app <slug>] [--confirmation <id>]

This command creates a short-lived preview credential and makes one authenticated request. It prints the response body to standard output and the method, requested URL, and HTTP status to standard error. It does not display the credential or follow redirects. The path must begin with one /; absolute URLs and paths beginning with // are rejected so the credential cannot be sent to another host.

Add -i (or --include) to print the response protocol/status line and headers to standard error before the body is transferred. The body remains alone on standard output, so you can redirect it to a file while inspecting headers. Check both the status and Content-Type: for example, a missing JavaScript file may return 200 with Content-Type: text/html when an SPA fallback serves the page instead of the script. Authenticated GET and HEAD checks also work for the app’s /__keelson/media/... and /__keelson/assets/... paths.

The default is a read-only GET. To test an API that changes data, explicitly select POST, PUT, PATCH, or DELETE with --method. This requests a write-enabled credential that lasts 5 minutes. --data supplies the body and can use @filename to read it from a file; it is accepted only with a write method. Repeat --header 'Name: value' to add request headers. app curl does not accept --ttl.

For a multipart request, repeat --form name=value for text fields and use --form name=@path for files. With --form, the method defaults to POST when --method is omitted. The CLI infers each file’s media type from its extension; append ;type=<media-type> to override it. The CLI sets the multipart Content-Type and boundary, so a Content-Type supplied with --header cannot be combined with --form.

To use a separate HTTP client, choose a lifetime, or make multiple requests, issue the credential directly:

Terminal window
keelson preview [--app <slug>] [--ttl <duration>] [--confirmation <id>]
keelson preview --allow-writes [--app <slug>] [--ttl <duration>] [--confirmation <id>]

By default, a preview credential permits only GET and HEAD. It lasts 30 minutes, and --ttl accepts 1 to 30 minutes. --allow-writes explicitly adds POST, PUT, PATCH, and DELETE; that credential lasts 5 minutes by default and accepts a lifetime from 1 to 10 minutes. Normal output contains only the credential; --json returns the credential, app URL, and expiration time. Keelson returns the raw credential once through standard output and does not save it to disk, so keep it out of logs and reports.

If you did not create the active deployment, the command provides a browser confirmation URL. Approve the request, then repeat the command with the provided --confirmation <id>.

This check proves only that the requested operation produced that authenticated HTTP response. It does not verify every route or the app’s business logic. Write-enabled credentials work on container API routes; static sites still reject write methods because they have no write target. Read-only credentials still reject every method except GET and HEAD. Keelson cannot prevent a side effect if the app itself performs one in a GET handler, so do not use preview verification on such a route.

The CLI reports the failed stage, and an AI agent can inspect build or runtime logs to diagnose the cause. After correcting configuration or application errors, deploy again.

Start with the error message and the relevant logs. Avoid repeatedly redeploying unchanged code when the message identifies a configuration problem.

CauseWhat to check
Missing or invalid keelson.yamlConfirm that the file exists in the project root and that its fields are valid.
Incorrect start commandConfirm that command matches the app’s real entry point.
Missing environment variableAdd a non-sensitive value to env, or configure a sensitive value as a secret.
App is listening on the wrong portMake the app listen on the port supplied in the PORT environment variable.
Build succeeds but the process exitsInspect runtime logs for missing dependencies and configuration errors.

The app was updated, but it still looks old

Section titled “The app was updated, but it still looks old”

Browsers save files that control an app’s appearance, including styles and images, and reuse them on later visits. This makes pages load faster, but it can occasionally leave an old appearance visible just after an app update. The app’s data may be current even while its appearance is stale.

First confirm that the deployment has completed, then try a normal reload. If the old appearance remains, do a hard reload once:

  • Windows or Linux: Ctrl + Shift + R
  • Mac: Cmd + Shift + R

A hard reload ignores the saved appearance files and downloads them again. Browsers that opened an app under Keelson’s previous cache policy may still need this one-time action after the platform fix. In newly loaded sessions, unhashed files are checked for updates during normal reloads.

Environment Variables and Secrets

Apps often need API keys, connection details, and other configuration to communicate with external services. Managing these values as environment variables lets you change configuration without hard-coding it in the application.

This page explains how to configure ordinary environment variables and sensitive secrets in Keelson.

Keelson provides two places for user-defined environment variables:

  • Use the env field in keelson.yaml for non-sensitive values that are safe to commit.
  • Use secrets in the console for API keys, access tokens, passwords, and other sensitive values.

Both kinds are exposed to the running application as normal environment variables, but they differ in visibility and in where you manage them.

Define non-sensitive values in keelson.yaml

Section titled “Define non-sensitive values in keelson.yaml”

Values in the env field are available to the running application. They are not passed into the build.

slug: my-app
runtime: node-slim
command: "npm start"
env:
NODE_ENV: "production"
PUBLIC_API_ORIGIN: "https://api.example.com"

Use this field for configuration that does not need to be secret, such as an operating mode or a public service URL.

Do not define the same key twice, and do not use YAML merge keys to construct env. Keelson rejects ambiguous definitions rather than guessing which value you intended.

Configure sensitive values from the Secrets area in the Keelson console. Keelson encrypts secret values at rest and injects them into the app at runtime.

The console lists each secret’s key, scope, and last-updated time. It does not return the stored value for display. To change a secret, enter a replacement value.

Secret names use environment-variable syntax. Choose a descriptive uppercase name such as OPENAI_API_KEY or DATABASE_URL, and make the application read that name.

Secrets can be defined at workspace scope or app scope.

A workspace secret is available to apps in that workspace. Use workspace scope for a value intentionally shared by multiple apps, such as a common service credential.

Workspace secrets are managed from the workspace’s secret settings. Changes are tracked as configuration changes for the apps that use them.

An app secret applies only to one app. Manage it from the Secrets tab on the app details page.

Use app scope when an app needs its own credential or when it must override a workspace value.

If the same key is defined in more than one place, Keelson resolves it in this order:

PrioritySourceEffect
HighestApp secretApplies only to the selected app.
MiddleWorkspace secretApplies to apps in the workspace unless an app secret overrides it.
Lowestkeelson.yaml envSupplies the non-sensitive value committed with the source.

For example, you can define API_KEY as a workspace secret and then define a different API_KEY as an app secret for one app. That app receives its app-specific value.

Adding, replacing, or deleting a secret marks the app configuration as having unapplied changes. Use Apply changes in the console, or redeploy the app, to create a new revision with the updated environment.

Changing env in keelson.yaml also requires a deployment. Existing running revisions do not change in place.

Stagekeelson.yaml envSecrets
BuildNot availableNot available
RuntimeAvailableAvailable

Neither env values nor secrets reach the build. The build sees only the source files you upload, so a public value a frontend build needs — such as VITE_API_URL — must be written into a file that ships with your source, such as your build tool’s config file.

Be careful with frontend build variables: values embedded in browser JavaScript are public even if their names look sensitive. Never use a build-time variable to conceal a credential.

Your app reads both env values and secrets through its language’s ordinary environment-variable API.

Python:

import os
api_key = os.environ["API_KEY"]

Node.js:

const apiKey = process.env.API_KEY;

Go:

apiKey := os.Getenv("API_KEY")

Treat a missing required variable as a startup configuration error. This produces a clear deployment failure instead of an error only when a user reaches a particular feature.

Store values like these as console secrets:

  • API keys and access tokens
  • OAuth client secrets
  • Database connection strings containing credentials
  • Credentials for external services
  • Webhook signing secrets
  • Encryption and session keys

Do not print these values in build output, runtime logs, error messages, or client-side responses.

Keelson injects platform-owned variables into each running app. Do not define or override them yourself.

VariableMeaning
PORTPort on which the web app must listen.
TZWorkspace timezone.
KEELSON_MODEPlatform mode marker.
KEELSON_APP_IDInternal application ID.
KEELSON_WORKSPACE_IDInternal workspace ID.
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID (same value).
KEELSON_DEPLOY_IDInternal ID of the current deployment.
KEELSON_APP_URLThe app’s public origin, when its host can be resolved.
KEELSON_DIRECTORY_BASE_URLBase URL of the Directory API, when the app’s host can be resolved.

The former tenant-named variable remains accepted as a compatibility alias. No removal date is set.

Additional variables are injected when a feature is enabled. For example, an app using managed libSQL receives its database URL and authentication token through platform-managed environment variables.

Verify a deployment

A deployed app is behind authentication, so a normal curl request returns 401. With keelson app curl and keelson preview, you can fetch paths from the app while authenticated as yourself. AI agents also use this route to verify that a deployment succeeded.

Send a single authenticated request.

Terminal window
keelson app curl / # GET /
keelson app curl -i /api/items # Also show headers
keelson app curl /api/items --method POST \
--data '{"name":"example"}' \
--header 'Content-Type: application/json'
keelson app curl /upload --form title=example --form photo=@sample.png
  • The response body goes to standard output, while the method, URL, and status go to standard error. With -i, headers also go to standard error, so you can redirect only the body to a file
  • The default method is GET. If you explicitly select POST, PUT, PATCH, or DELETE with --method, Keelson uses a write-enabled credential that is valid for 5 minutes
  • --data can only be used with a write method. Use @filename to read the data from a file
  • With --form, the method becomes POST, and the CLI sets the multipart Content-Type (you cannot override it with --header)
  • The path must start with /. Absolute URLs and paths starting with // are rejected. Redirects are not followed
  • You can also check /__keelson/media/... and /__keelson/assets/... with GET or HEAD
  • To target an app outside the current directory, use --app <slug>

Check the Content-Type as well as the status. If the SPA fallback handles a request for a nonexistent JavaScript file, the response can be 200 with a text/html content type.

Issue a credential directly when you want to use another HTTP client, make multiple requests, or choose the credential lifetime.

Terminal window
keelson preview # GET / HEAD only; 30 minutes by default
keelson preview --ttl 5m
keelson preview --allow-writes # Also allows POST / PUT / PATCH / DELETE; 5 minutes by default, 10 minutes maximum
keelson preview --json # Credential, URL, and expiration time as JSON

Use the token and app_url fields from --json with another HTTP client. Pass the credential as a Bearer token:

Terminal window
curl --header 'Authorization: Bearer <token>' '<app_url>/api/items'

The credential is displayed once on standard output, and Keelson does not save it. Do not include it in logs or reports. Each user can have one credential issued by keelson preview per app. Issuing another replaces and revokes the previous one, even if it has not expired. keelson app curl uses a separate internal credential and does not revoke the credential issued by preview.

If you did not create the deployment currently serving traffic, the command shows an approval URL to open in a browser. After approval, rerun the same command with the displayed --confirmation <id> option.

What this check does and does not tell you

Section titled “What this check does and does not tell you”
  • It only tells you which HTTP response the app returned to an authenticated request. It does not verify that the app’s business logic is correct
  • Static sites have no write target, so write methods are rejected
  • If the app implements side effects in a GET handler, Keelson cannot prevent those side effects. Do not use a preview check on such a path

App URL

After a deployment succeeds, Keelson automatically assigns the app a standard public URL. This page explains how that URL is formed, where to find it, and how access to it is protected.

The standard app URL has this form:

https://{workspace-slug}--{app-slug}.keelson.run

For example, an app with the slug dashboard in the acme workspace receives:

https://acme--dashboard.keelson.run

The app slug comes from the slug field in keelson.yaml. The workspace slug is managed in the workspace settings. Together they make the hostname unique across Keelson.

You can find the app URL in these places:

  • Keelson console — The app details page displays the URL.
  • Deployment result — The CLI reports the URL when deployment completes, and an AI agent can pass it back to you.

You can copy the URL and share it with team members who should have access.

Share the app URL separately with App Users. They cannot open the console, so copy the URL from one of the places above and send it to them — otherwise they have no way to learn it.

Keelson provides the app’s own public origin to the running process as KEELSON_APP_URL. Use it when the app must construct an absolute callback or redirect URL.

Do not derive the public origin from an internal service hostname. Read KEELSON_APP_URL, or use a relative URL when the browser already knows the current origin.

Access to an app URL is restricted to authorized users by default. All connections use HTTPS, and Keelson manages the TLS certificate; you do not need to request or renew one.

IP restrictions can add a network-level check before a request reaches the app. Authentication and IP restrictions remain in effect when users follow the standard URL.

For details, see:

The standard hostname contains both the workspace slug and the app slug. Changing either slug can therefore change the URL. Treat the URL as configuration rather than embedding it throughout your source code.

When another system needs a callback URL, copy the current value from the console and update that system if the app URL changes.

The Team plan lets you publish an app at your own subdomain, such as app.example.com. A workspace can register up to 10 custom domains, with one custom domain per app. Apex domains such as example.com and email domains are not supported.

Configure a custom domain in this order:

  1. As an Owner or Admin, register the domain in the app settings in the console, or run keelson domains add <hostname> [--app <slug>]. Keelson then displays the CNAME target to use.
  2. At your DNS provider, point the registered subdomain’s CNAME to the displayed target. The production target is custom-origin.keelson.run; always use the value shown in the console or CLI.
  3. Check the status in the console, or run keelson domains verify <hostname> [--app <slug>], to confirm that the domain is active. Activation can take a few minutes.

Keelson automatically verifies domain ownership, issues the HTTPS certificate, and configures traffic delivery. Authentication and IP restrictions also apply through the custom domain. KEELSON_APP_URL continues to contain the standard keelson.run URL.

App status and logs

The app list and app page in the console show the status as a badge.

StatusMeaning
ActiveThe app is running. It uses one App Slot
SleepingThe app is deployed but stopped. It starts automatically when accessed and does not use an App Slot
PublishedA static site or SPA that is not manually suspended. It has no process and is served from the edge
SuspendedThe app was stopped manually. It does not start or become available again when accessed. Its data, URL, and settings are retained
Starting / DeployingThe app is transitioning between states
Not deployedThe app has been created but has not been deployed yet
ErrorThe latest deployment or execution failed. The app page shows the cause
DeletingThe app is being deleted. It disappears from the list when deletion finishes
An app that only runs Scheduled Jobs. It has no running or sleeping state

Apps stop automatically when idle (sleep) and start when accessed. Only apps accessed in the last 5 minutes count against your App Slots. If you access a sleeping app while no slot is available, an HTTP 503 information page appears (a slot becomes available in about 5 minutes).

To stop an app temporarily, select SettingsSuspend on the app page. You can suspend static sites and SPAs as well as apps with a server. A suspended app does not start in response to access, a webhook, or a cron, and its static files are not served. Keelson’s standard URL shows the existing suspension page, and serving from custom domains also stops. Data, URLs, and settings are not deleted.

Select Resume in the app page header to resume the app. If you deploy a static site or SPA while it is suspended, it remains suspended and returns to serving only after you explicitly resume it. Stopping and resuming static-file serving takes effect asynchronously, so the serving state may take a short time to change after the operation.

Turn on Priority start in the app page header to reserve one App Slot at all times. This prevents the app from being unable to start because all slots are in use.

  • This setting does not keep an instance running continuously. The app still stops when idle and starts when accessed
  • On Plus and higher plans, you can enable it for up to one fewer app than the number of App Slots

View logs on the Logs tab of the app page.

TypeContents
App logsThe app’s standard output and standard error. You can switch between Web and cron. The tab shows the latest 200 lines, and an export can retrieve up to 5,000 lines
Access logsWho accessed which path and when, and whether access was allowed or denied. You can filter by people, machines (app tokens), or webhooks
Deployment logsSelect View logs for a deployment on the Deploys tab. For a failed deployment, the end of the build or startup error is shown
Cron run historyThe Scheduled Jobs tab shows the start time, result, duration, and logs for each run

The retention period depends on your plan. See Plans and limits.

Use keelson logs to retrieve logs from the CLI. When giving logs to an AI agent, the most reliable approach is to paste the output of keelson logs app <slug> --json as-is.

The Deploys tab lists previous deployments and lets you download the source and view logs for each deployment.

Use the CLI to return to an earlier deployment.

Terminal window
keelson rollback # Return to the previous successful deployment
keelson rollback <deploy_id> # Return to the specified deployment

A rollback does not rebuild the app. It starts a new revision with the selected deployment’s image and settings. The current secret values are used. You cannot roll back while a deployment is in progress.

Redeploy in the app page header starts the latest successful deployment again with the same image. Use it to apply secret changes.

Delete an app from SettingsDanger Zone. Approval in a browser is required. After approval, the app itself, its URL, and its execution slot are deleted promptly. This cannot be undone.

Database backups, files stored in Files, and a configuration record (app name, URL, cron definitions, and the names of environment variables and secrets) are retained in quarantine for 7 days after deletion. The app cannot be restored. Export any data you need before deleting it.

Update and restore

Choose the operation you need: update an app, roll back to an earlier version, recover deleted data, or retire an app.

  • What you can do: Work with code, configuration, and databases with a clear understanding of what each operation can restore
  • Required permission: manage on the app. Database restoration and app deletion require browser approval
  • Done when: You open the app URL after the operation and confirm the expected state

In this table, “rollback” means returning code to an earlier deployment. “DB restore” means restoring Managed SQLite to a restore point.

ItemRestored by rollback?Restored by DB restore?Notes
Code (image)YesNoStarts the target deployment’s image without rebuilding. Static sites / SPAs are excluded (see below)
keelson.yaml settings (env, crons, assets, health)YesNoUses the settings from the target deployment
Secret valuesNoNoAlways uses the current values. Change them back manually in the console
Managed SQLite dataNoYesDaily backups, manual snapshots, and point-in-time recovery (PITR)
Schema changes made by db.migrateNoYesRestores the schema as it was at the restore point
Files SDK filesNoNoNot covered by backups. Retain copies in your app
Media SDK filesNoNoWrite-once; remain unless deleted
External databases (such as PostgreSQL)NoNoRestore using the external service’s backups
Changes to external services (Slack posts, emails, payments)NoNoSide effects cannot be undone. Watch for duplicates when retrying
App permissions, IP restrictions, public URLNoNoIndependent of deployments. Change them back manually
  1. Run and test the app locally
  2. Use keelson deploy --check --json to inspect configuration and archive contents in advance, without uploading
  3. Run keelson deploy, or ask your agent to deploy
  4. When finished, open the URL and check the result. For authenticated API checks, see Verify a deployment

If deployment fails before traffic switches, users can continue using the old revision. However, any partially executed db.migrate changes remain (see “Change the database schema” below).

See Deploy an app for which changes take effect and when, including secrets that do not take effect until the next deployment.

Apps with a server (those with a command) can return to a previous successful deployment that has a container image.

Terminal window
keelson rollback # Return to the previous successful deployment
keelson rollback <deploy_id> # Return to a specific deployment
  • Starts a new revision using the target deployment’s image and keelson.yaml settings, without rebuilding
  • Only completed deployments with an image can be targets. Static site / SPA deployments and deployments created by the older deployment system cannot be rollback targets
  • Uses current secret values
  • If the target deployment’s keelson.yaml contains db.migrate, rollback also runs it before switching traffic. Rollback is not necessarily an operation that leaves the DB untouched
  • The database is not rolled back. If newer code changed the schema, the restored code will read the newer schema. Adding columns or tables is usually compatible. If columns were removed or types changed, consider restoring the DB as well
  • Cannot run while a deployment is in progress

The console’s “Deployments” tab lists deployments and lets you download each deployment’s source.

To restore a static site / SPA, deploy the previous artifacts again. Check out an earlier Git commit and rebuild locally, or download the previous deployment’s source (assets.dir contents and keelson.yaml) from the “Deployments” tab and run keelson deploy directly.

Restore Managed SQLite (db.mode: libsql) data from “Restore points” on the app page in the console.

  1. Choose a time on the restore-point timeline: a daily backup, a manual snapshot, or an arbitrary PITR time. See Plans and limits for retention counts and recovery windows by plan
  2. You can download the data first to inspect it
  3. Run Restore. Browser approval is required
  4. Open the app and check the restored data

What restoration does:

  • All writes after the selected time are lost. This is not a partial restore of only the deleted data. Downloading before restoration lets you manually reapply changes you want to keep
  • The state before restoration is retained for 72 hours, during which you can undo the restore
  • Only one app’s DB is affected. Other apps are unaffected
  • Files / Media files and external databases are excluded

Take a manual snapshot before important operations (up to five per app per day).

The command in db.migrate runs on every deployment, before traffic switches.

  • If it fails, traffic stays on the old revision. However, partially applied changes are not automatically undone. Check migration logs (keelson logs deploy <deploy_id>) and the DB state before fixing the issue
  • Prefer changes that still work with the old code, such as adding columns or tables. Make migrations idempotent (for example, with IF NOT EXISTS) because they run every time
  • Before changes that are hard to reverse, such as dropping columns or changing types, take a manual snapshot and schedule the work during a quiet period

Suspending and deleting are separate operations.

OperationWhat happensReversible?
Suspend (app “Settings” → “Suspend”)Requests, webhooks, and cron jobs cannot start the app. The URL shows a suspended page. Data, URL, and settings remainResume at any time
Delete (“Settings” → “Danger zone”)Removes the app, URL, deployment history, and secrets. Requires browser approvalNo

Export these before deletion:

  • DB data — Download from restore points
  • Files / Media files — Cannot be downloaded from the console or CLI. Implement download endpoints in the app to export them (Files and media)
  • Source code — Keep it in Git, or download it from each deployment in the “Deployments” tab
  • Secret values — Cannot be retrieved after deletion. Keep a copy if needed

After deletion, DB backups, Files, and non-sensitive configuration notes (app name, URL, cron definitions, and environment variable and secret names) are held in quarantine for seven days. This allows discretionary assistance with accidental deletion; it does not guarantee recovery.

For apps after a subscription ends, see Billing and subscriptions.

Troubleshooting

Remember one thing first: give the error’s code and hint to your AI agent. Keelson errors are designed so that agents can read them and make the necessary correction. If you investigate the problem yourself, check the following in order.

Terminal window
keelson status # App and latest deployment status
keelson diagnose <deploy_id> # Diagnose a failed deployment
keelson logs deploy <deploy_id> # Progress logs and details of a failed deployment
keelson logs app <slug> # App standard output and standard error

When a deployment fails, keelson logs deploy also shows the saved details. If the app exits during startup, its stored startup logs preserve the traceback or process error for that deployment. Read them with keelson logs deploy <deploy_id> or keelson diagnose <deploy_id> before checking the listening port and health.path. The same commands can be used later to investigate dependency or Dockerfile build failures, unset secrets, and configuration or migration errors. Build output from successful deployments is not saved. If there are no progress logs or saved failure details to show, the command only explains why no logs are available.

In the console, the app page has a failure summary under Overview, deployment logs under View logs on Deploys, and app logs under Logs.

Where it stoppedWhat to inspectCommon causes
Before upload (CLI preflight check)Command outputMissing go.mod for a Go app; a Node app with only a pnpm or yarn lockfile; pip install in command; missing db.mode; unquoted env values
Validationcode and messageRetired workers or databases fields; reserved names; plan limits
BuildDeployment logsDependency resolution failure; mismatch between package.json and package-lock.json; private registry; build script error
StartupStored startup logs in keelson logs deploy <deploy_id>Not listening on 0.0.0.0; hard-coded PORT; exception caused by an unset secret
Health checkStored startup logs, then app logsThe app exited during startup; / returns 5xx; startup takes more than 120 seconds
MigrationDeployment logsSQL error in db.migrate. The previous revision continues running
Running but not behaving as expectedApp logs and access logsSee Runtime problems below

Ctrl-C stops only this client from watching. The deploy is still running server-side and may still become the active version. Use the deploy_id shown by the CLI to check its progress:

Terminal window
keelson status <deploy_id>

If you do not want the new version, wait for the deploy to finish and then return to the previous completed deploy:

Terminal window
keelson rollback --app <slug>

If the app has no earlier completed deploy, stop it after the deploy finishes:

Terminal window
keelson app stop --app <slug>
  • Listening on localhost — change it to 0.0.0.0
  • Hard-coding the port — read the PORT environment variable
  • Depending on files that only exist locally — such as .env, a local database file, or files covered by .gitignore. Check archive.excluded in the output of keelson deploy --check --json to see what was excluded
  • Environment variables are not set — values that exist only in your local .env are not available on Keelson. Declare them in secrets and set their values
  • Using file-based SQLite — neither /data nor /tmp is persistent. Move the data to Managed SQLite

Keelson’s Node builder uses npm, so dependency pinning requires npm’s package-lock.json. Run npm install in the project root, commit the generated package-lock.json, and deploy again.

You do not need to delete pnpm-lock.yaml or yarn.lock. The app can be deployed with either file alongside package-lock.json, and the build uses package-lock.json.

Check whether you are writing to a local file. Files in /tmp, /data, and the app directory are lost on restart, redeployment, or an idle stop. Store the data in a database or with the Files / Media SDK.

Work after a response is returned, in-app timers, and threads do not run. Finish the work synchronously within the request or move it to crons. See Scheduled Jobs.

  • Check whether the schedule is shorter than the plan’s minimum interval (it is rejected during deployment)
  • Check whether the previous run is still in progress (overlapping runs are skipped)
  • Check whether the monthly execution limit has been reached (remaining runs for the month are skipped)
  • Check whether the app is suspended
  • Check the run history on the Scheduled Jobs tab for the reason it was skipped

The cause is the browser cache. Confirm that the deployment has finished, then perform a hard reload (Mac: Cmd + Shift + R; Windows: Ctrl + Shift + R). A static-site redeployment takes 25–30 seconds to propagate to the edge.

A user says “I can’t access the app”

Section titled “A user says “I can’t access the app””
  1. Are they a workspace member, and have they accepted their invitation?
  2. Are they blocked?
  3. Are they in a group with permission to view the app?
  4. Are they signed in with the registered account?
  5. Are they denied by IP access controls? The 403 page shows the source IP.

See Members and roles for details.

This means the concurrent App Slot limit has been reached. Apps accessed in the last 5 minutes use a slot. Wait about 5 minutes, enable priority start for an important app, or upgrade your plan.

The app did not start its response within 120 seconds. Shorten the operation, send headers first by streaming, or move heavy work to crons. Server-side work may finish even after a 504 response, so make write operations idempotent.

  • Check whether the path is declared in auth.endpoints (it must start with /api/webhooks/ or /api/external/)
  • Check whether the method matches the declaration
  • Check whether the token scope and allowed IP addresses match

See External integrations.

Secrets are injected at deployment time. Redeploy the app or select Apply in the console.

If you receive a platform error such as deploy.platform.error, or if the steps above do not solve the problem, include the deploy_id (available from keelson status --json) when you contact us.

Access Control


Auth & Login

Every app deployed to Keelson requires authentication. Knowing an app’s URL is not enough to access it: a user must sign in and have permission to use that app.

This page explains how authentication and login work in Keelson.

Keelson places an authentication proxy in front of every app. Every user request passes through this proxy, which verifies the user’s identity and permissions before forwarding the request to the app.

As a result, you do not need to implement authentication in your app. An app gains authentication when you deploy it, regardless of the language or framework used to build it. Even an app generated by an AI tool is not exposed to the public without authentication when deployed as-is.

You can sign in to Keelson with either of these accounts:

  • Google account
  • Microsoft account

Only users registered as members of the workspace can sign in. A user who is not a workspace member cannot access its apps, even if they have a valid Google or Microsoft account.

For information about adding members, see Members & App Permissions.

  1. The user opens the app’s URL.
  2. If the user is not signed in, Keelson redirects them to the login page.
  3. The user authenticates with a Google or Microsoft account.
  4. After login, Keelson returns the user to the original app.
  5. If the user has permission to view the app, Keelson displays it.
  6. Otherwise, Keelson denies access.

The login session remains valid for a period of time. While it is valid, the user can open other apps in the same workspace without signing in again.

Being able to sign in does not automatically mean that a user can use an app.

Keelson evaluates access in this order:

  1. Authentication — Is the user signed in?
  2. App permission — Does the user have permission to view this app?
  3. Network conditions — If IP restrictions are configured, is the request coming from an allowed IP address?

A successful login does not grant access when the user lacks permission to view the requested app.

For authenticated requests, the authentication proxy adds user information as request headers.

Read the X-Keelson-User-Id header in your app to identify the current user.

# Python (Flask, FastAPI, and similar frameworks)
user_id = request.headers.get("X-Keelson-User-Id")
// Node.js (Express and similar frameworks)
const userId = req.headers["x-keelson-user-id"];

The proxy removes incoming authentication headers and cookies before the request reaches the app. The app receives only the identity headers added by Keelson.

Authentication controls who can access an app. IP restrictions control which network a request can come from.

Keelson evaluates an IP restriction after sign-in and app permission, immediately before the request reaches the application. A request from outside the allowed range is denied at that point. Authentication and IP restrictions are independent controls, and you can combine them for stronger protection.

IP restrictions do not apply to static sites, SPAs, and the static files of hybrid apps. Configuration changes take effect without a redeploy.

For details, see IP Allowlist.

Members & App Permissions

Keelson controls access to each app within the context of workspace membership. The groups with view permission determine which apps a member can use, while the groups with manage permission determine which apps a member can manage.

This page explains member management, roles, and the ways users can join a workspace.

Keelson combines workspace membership with permissions assigned per app.

  • Joining the workspace is a prerequisite for using an app.
  • Only members of groups with view permission can use a running app.
  • Members of groups with manage permission can deploy and configure that app.
  • View and manage permissions are independent. Manage permission by itself does not allow a member to use the running app.
  • A user who has not joined the workspace cannot access any of its apps.

When you create an app, view permission is assigned by default to the group containing all members. Manage permission is assigned by default to the developer group containing Owners, Admins, and Developers. You can change or remove these assignments independently for each app. No role, including Owner, bypasses the app permission check.

For the login flow, see Auth & Login.

A workspace has four member roles.

RoleWorkspace administrationDefault app permissionsDeveloper seat
OwnerCan manage all roles and settingsView and manageUsed
AdminCan manage members other than Owners and manage settingsView and manageUsed
DeveloperNot availableView and manageUsed
App UserNot availableViewNot used

The app permissions in this table are default assignments made when an app is created. Actual access and management capabilities depend on that app’s group assignments. No role bypasses the permission check.

Owners can add and remove members, change roles, and manage workspace settings. They still need view permission to use an app and manage permission to manage it. Every workspace must have at least one Owner.

Admins can manage the workspace but cannot manage members with the Owner role. They need the corresponding app permissions to use or manage each app. This role is appropriate for team administrators.

The Developer role is intended for members who build and deploy apps. By default, Developers have view and manage permissions, so they can use the app and can deploy it, change its settings, and edit its secrets. They cannot manage members or groups, change workspace settings, or change public URLs. Removing a Developer’s view permission for an app prevents them from using that app; removing manage permission prevents them from performing management operations.

By default, App Users can use apps for which they have view permission, but they are not assigned manage permission. Removing view permission prevents an App User from using that app. An App User must be assigned manage permission to manage an app. App Users cannot administer the workspace and do not consume a developer seat. This role is intended for an app’s end users.

There are three ways to become a workspace member.

An administrator invites a member by email address and selects a role. The user becomes a member after accepting the unique invitation link.

For step-by-step instructions, see Invite Members.

If domain auto-join is enabled for a workspace, a user from an allowed domain becomes a member immediately after opening the workspace’s join URL. An administrator does not need to approve the user.

A workspace with domain auto-join enabled appears in Discover. A user from an allowed domain can request to join from Discover and becomes a member after an administrator approves the request.

A domain can use an invitation-only, auto-join, or approval-based join policy. In the console, you can register and remove domains. On the Team plan or higher, you can also enable or disable the join URL to switch between auto-join and invitation-only behavior. The current console does not provide an operation for selecting the approval-based policy.

You can register only the domain of your own email address, and that domain must also match the workspace creator’s email domain. The same domain can be configured for more than one workspace.

PolicyBehaviorPlan requirement
Invitation only (default)Only users invited by an administrator can joinNone
Auto-joinUsers from the domain can join through the join URL or request access through DiscoverTeam plan or higher
Approval-basedThe join URL and Discover requests are unavailable; users join by administrator invitationNone

Members who join through auto-join receive the App User role. Auto-join is not available for public email domains such as Gmail and Outlook.

Changing the join URL setting does not affect existing members.

You can block a member who no longer needs access from the console.

  • A blocked member can no longer use any app permissions assigned to them.
  • Blocking disables access; it does not delete the member’s account.
  • You cannot block the workspace’s last Owner.
  • You cannot block yourself.
  • A blocked member can be reactivated from the member list.

Use this when someone leaves the organization or changes responsibilities.

A user who is not a workspace member cannot access an app. Ask an administrator to invite them.

A former active member may have been blocked. Check the member list in the console.

Is the user signed in with the correct account?

Section titled “Is the user signed in with the correct account?”

If the user has multiple Google or Microsoft accounts, they may be signed in with an account that is not registered in the workspace.

When a user requests access through Discover, they cannot access the workspace until an administrator approves the request.

The problem may be a network restriction rather than workspace membership.

Does joining a workspace grant access to every app?

Section titled “Does joining a workspace grant access to every app?”

No. In addition to workspace membership, the user needs view permission through a group assigned to the app. Manage permission is a separate permission for deployment and configuration; it does not grant use of the running app. Default permissions can be changed independently for each app.

What is the difference between an App User and a Developer?

Section titled “What is the difference between an App User and a Developer?”

By default, an App User can use apps for which view permission is assigned. A Developer has both view and manage permissions by default but cannot manage members or workspace settings. Both roles’ default app permissions can be changed for each app.

Can I change the role of a member who used auto-join?

Section titled “Can I change the role of a member who used auto-join?”

Yes. Auto-join assigns the App User role, but an Owner or Admin can change the role from the console.

All app permissions assigned to that member become ineffective immediately.

Share an app with your team

This guide walks through invitations and verification using an app that only the sales team can use.

  • What you can do: Make an app available only to a specific group in your workspace
  • Required permissions / plan: Owner or Admin to invite members and create groups. Anyone with manage on the app can change its permissions. Available on all plans
  • Done when: A sales user can open the app, and a user outside sales sees “Access denied”

First, distinguish these three decisions, which are easy to confuse.

DecisionWho controls itExample
Workspace administration permissions (roles)Owner / Admin assigns rolesWhether someone can invite members or view billing
App access and management permissions (groups × view / manage)Someone with manage on the app assigns permissionsWhether someone can open the app, deploy it, or change its settings
Business permissions within the appThe app’s codeWhether someone can see only their own requests or approve requests as a manager

This page covers the second layer. For the first, see Members and roles. For the third, see Use identity in your app.

manage gives an operator permission to deploy and configure the app. That person is not necessarily a business approver. A rule such as “only managers can approve” belongs to the third layer and is enforced by the app.

Go to “Members” → “Invite,” enter the sales team’s email addresses, and select the App User role (for people who only use apps; it does not consume a Developers seat).

Recipients open the email link, log in with the account matching the invited email address, and accept the invitation. See Invite members for details.

For larger organizations, domain auto-join on Team plans and above lets employees join without an invitation.

Go to “Members” → “Groups,” create a “Sales” group, and add its members. The key (such as sales) cannot be changed after creation, so choose a stable name such as a department code.

Terminal window
keelson groups create sales --name "Sales"
keelson groups members add sales tanaka@example.com suzuki@example.com

Open the app’s “Permissions” tab. This is the key step.

New apps default to view access for everyone (all members) and manage access for developers. Adding view access for “Sales” alone still lets everyone open the app, because everyone retains view access.

  1. Remove everyone from the view groups
  2. Add sales to the view groups
  3. Keep developers as the manage group (someone needs to update the app)
Terminal window
keelson access set --app sales-tool --view sales --manage developers

Members of the manage group (developers = Owner / Admin / Developer) can open the app even if they are not in a view group, because manage includes view access. You cannot hide the app from its developers. Since they can deploy it, assume they can also see its contents.

Changes take effect when saved and usually apply to users who are already logged in within two minutes.

App Users cannot open the console, so they cannot look up the app URL themselves. Copy the URL from the app page (https://<workspace>--<app>.keelson.run) and send it by chat or email. Invitation emails do not include the app URL.

Check both that authorized users can open the app and that other users cannot.

CheckExpected result
A user in the sales group opens the URLThe app appears after login
An App User outside sales opens the URLAn “Access denied” screen appears after login
Someone who is not logged in opens the URLThe Keelson login screen appears; the app is not displayed

If no one is available to test denied access, invite another account of your own (with a different email address) as an App User, leave it out of the sales group, and test with it.

If “Access scope” in the app list shows a member count and groups instead of “Everyone,” the settings have been applied.

SymptomWhat to check
A sales user cannot open the appHave they joined the workspace (accepted their invitation)? Are they in sales? Are they logged in with the invited email address? Are IP restrictions blocking them?
Users outside sales can also open itIs everyone still in the view groups? Is the user in developers (Owner / Admin / Developer)?
A settings change has not taken effectChanges usually apply within two minutes. Reload the browser

See Troubleshooting access for additional checks.

Limit users to their own data inside the app

Section titled “Limit users to their own data inside the app”

The steps above restrict app access to sales. Rules such as “show only my requests” or “only managers can approve” belong to the third layer. The app enforces them by reading X-Keelson-User-Id and group information.

Changing what the screen displays is not enough. Filter lists to the user’s own records, and verify on the server that users cannot retrieve or update someone else’s request by passing its ID to the API. Base these decisions on headers added by Keelson, rather than values sent by the browser. See Use identity in your app for implementation guidance.

Workspace


Invite Members

Workspace membership is a prerequisite for using an app. After joining, the apps a member can use are determined by the view permissions assigned to groups for each app.

This page explains how to invite members, choose their roles, and manage invitations.

  1. Open the workspace member management page in the console.
  2. Select Invite.
  3. Enter the users’ email addresses, up to 50 at a time.
  4. Select a role.
  5. Create the invitations.

An invitation link is valid for seven days from the time it is created. Keelson automatically emails each invited user a unique invitation link. When the user opens and accepts the link, they become a workspace member.

The invitation link also appears in the console. If the email cannot be delivered, share the corresponding link with the invited user directly.

Select the role to assign to the member when you create the invitation.

RoleIntended useDeveloper seat
OwnerAdministrator of the entire workspaceUsed
AdminMember and app administratorUsed
DeveloperBuilds and deploys appsUsed
App UserEnd user of deployed appsNot used

For details about each role, see Members & App Permissions.

  • Owners can invite members with any role, including Owner.
  • Admins can invite Admins, Developers, and App Users, but cannot invite an Owner.
  • Developers and App Users cannot invite members.

Invitations for the Owner, Admin, and Developer roles consume developer seats under the workspace’s plan. An unaccepted invitation that has not expired reserves one developer seat as soon as it is created. The usage display counts reserved seats, including pending invitations that have not expired. When an invitation is accepted or a member’s role is changed, the limit check counts only members who have already accepted.

You cannot invite one of these roles when the developer-seat limit has been reached. App Users do not consume developer seats, so they can be invited regardless of that limit.

If no developer seats are available, consider upgrading the plan or changing an existing member who no longer needs a developer role to App User.

An invitation has one of four states.

StateMeaning
PendingCreated but not yet accepted
AcceptedAccepted; the user is now a member
RevokedRevoked by an administrator
ExpiredThe invitation has expired

You can check invitation states from the member management page in the console.

An Owner or Admin can revoke an invitation while it is Pending. Its link stops working immediately after revocation.

Only Pending invitations can be resent. Resending issues a new link and invalidates the old link. The new link is valid for seven days from the time it is resent.

If an invitation is expired or revoked, create a new invitation for the same email address.

The invited user completes these steps:

  1. Open the invitation link received from the administrator.
  2. Sign in with a Google or Microsoft account.
  3. Accept the invitation.
  4. Join the workspace and use apps for which they have view permission.

If domain auto-join is enabled for the workspace, users can join in these ways in addition to an invitation:

  • Join URL — Opening the URL immediately adds the user as a member without waiting for approval.
  • Discover — The user requests access from Discover and waits for an administrator to approve the request.

Both methods are available only when the user’s email domain is an allowed domain for the workspace. For a domain with an approval-based policy, join URLs and Discover requests are unavailable, so an administrator must invite the user.

For configuration details, see Members & App Permissions.

  • Does the sender have permission to invite? Only Owners and Admins can send invitations.
  • Is the email address correct? Check the entered address.
  • Is the user already a member? Check the member list.
  • Are enough developer seats available? When inviting an Owner, Admin, or Developer, check the plan’s seat limit.

First check the spam folder. If the email is not there, ask the administrator who created the invitation to share the link displayed in the console.

An administrator can resend an invitation while it is Pending. Resending invalidates the old link.

  • Is the user signed in with the invited address? Another account cannot accept the invitation.
  • Has the invitation expired? Create a new invitation after expiration.
  • Was the invitation revoked? Ask an administrator to check its state.

The member cannot access an app after joining

Section titled “The member cannot access an app after joining”
  • Does the member have view permission for the app? Ask an administrator to check that app’s permissions.
  • Is the member signed in with the correct account? Take care when switching between multiple accounts.
  • Are IP restrictions blocking the request? Connect from an allowed network.

Does an invitation grant access to every app?

Section titled “Does an invitation grant access to every app?”

No. The user needs both workspace membership and view permission assigned to a group for each app. Members with manage permission for an app can change that app’s permission settings.

Yes. An invitation expires seven days after creation. Create a new invitation if the original has expired.

Can I change a member’s role after inviting them?

Section titled “Can I change a member’s role after inviting them?”

Yes. After the member joins, an Owner or Admin can change the role from the console.

Yes. You can enter up to 50 email addresses in a single operation and invite them together.

Groups and app access

Whether a workspace member can use an app is determined by the permissions assigned to groups for each app. Roles (Owner / Admin / Developer / App User) determine workspace administration permissions; roles do not determine whether someone can use an app.

PermissionWhat it allows
View (view)Open and use a running app
Manage (manage)Deploy the app, change settings, edit secrets, and change permissions. Includes view permission

Permissions are assigned to groups, not individual users. When you change a group’s members, permissions for all apps follow that change.

When you create an app, Keelson automatically assigns the following.

GroupPermission
everyone (all members)View
developers (Owner / Admin / Developer)Manage

With no changes, everyone in the workspace can use the app, and people with a developer role can manage it. Change only the apps whose access you want to restrict.

Manage groups under MembersGroups.

System groups — Determined automatically from roles. You cannot edit them.

KeyMembers
ownersOwner
adminsAdmin
developersOwner / Admin / Developer
everyoneAll members

Custom groups — Create these for business units such as Accounting, Sales, or Store Staff. The key (an identifier such as accounting; Japanese characters are also allowed) cannot be changed after creation. You can change the display name and members. You cannot delete a group while it is assigned to an app.

You can also manage groups from the CLI.

Terminal window
keelson groups list
keelson groups create accounting --name "Accounting"
keelson groups members add accounting tanaka@example.com suzuki@example.com

On the app’s Permissions tab, select the view groups and manage groups.

Terminal window
keelson access show --app my-app
keelson access set --app my-app --view accounting --view admins --manage admins
keelson access set --app my-app --view none # Leave view empty
  • At least one manage group is required. Manage groups cannot be empty or consist only of groups with no members
  • You are asked for confirmation when removing your own manage permission, even if another manage group has a valid member
  • Changes take effect when saved. They normally apply within two minutes, including for users who are already signed in
GoalConfiguration
An internal tool everyone can useKeep the defaults
An app only the accounting team can useView: accounting; manage: developers
Everyone can use the app, but only administrators can change settingsView: everyone; manage: admins
Build a screen inside the app that only administrators can seeAssign view and manage separately, then check manage in X-Keelson-User-App-Perms within the app
Show different content to each departmentCreate custom groups, assign them to the app, then check attributes.groups within the app

For the app-side implementation, see Use identity information in your app.

In the app registry, Access shows Everyone when the app is available to everyone. When access is restricted, it shows the number of people and the applicable groups.

Domains and join policy

When your company has many employees, inviting them one at a time takes effort. Registering your company’s email domain (example.co.jp) allows users with an account on that domain to join without an invitation. This is available on the Team plan and higher.

Go to SettingsSecurityAccess Control to add a domain.

  • You can register only the domain of your own email address, and it must match the workspace creator’s email domain
  • Public domains such as Gmail and Outlook.com cannot be registered
  • The same domain can be registered with multiple workspaces, such as separate workspaces for different departments
PolicyBehaviorRequirement
Invitation only (default)Users can join only when invited by an Owner or Admin
Auto-joinUsers from an allowed domain can join immediately from the join URL. They can also submit a request from DiscoverTeam plan or higher

Auto-join turns on when you enable the Join URL and returns to invitation only when you disable it. There is no screen for selecting the policy directly.

Go to SettingsSecurityTeam Join Link, enable it, copy the URL, and share it within your company.

  • A user who opens the URL becomes a member immediately without waiting for approval if they sign in with an email address from an allowed domain
  • The user receives the App User role. An Owner or Admin can change the role later
  • Use Reset link to invalidate the URL and issue a new one. Do this if the URL is shared with someone who should not have it
  • Disabling the URL does not affect members who have already joined

A workspace with auto-join enabled appears on the Join screen for users who are signed in to Keelson. When a user from an allowed domain selects Request to Join, the Owner or Admin receives the request.

Approve or reject requests under Pending Join Requests in SettingsSecurity. Until a request is approved, the requester cannot access apps.

IP Allowlist

Keelson can restrict access to an app by the source IP address of each request.

With an IP allowlist, only requests from approved addresses or networks can proceed to the app. This is useful for internal apps that should be reachable only from an office, a corporate VPN, or another controlled network.

  • Allow access only through your office network or VPN.
  • Add a network boundary in addition to user authentication.
  • Prevent access from arbitrary networks even when someone knows the app URL.

An IP allowlist works best when your users connect through stable public egress addresses. If a network’s public address changes frequently, users may be blocked until the allowlist is updated.

When an IP restriction is active, Keelson compares the request’s source address with the effective allowlist. A request is accepted only when the address belongs to at least one allowed IP address or CIDR range.

Requests from outside every allowed range are denied before they reach the application. A valid Keelson account does not bypass the network restriction.

The IP allowlist therefore supplements authentication; it does not replace authentication or app permissions.

IP restrictions do not apply to static sites, SPAs, and the static files of hybrid apps.

Keelson stores allowed addresses in named access sources (shown as Sources in the console) at workspace scope. Each access source can contain one or more IP addresses or CIDR ranges. An app can either inherit the workspace’s default access sources or select its own set of access sources.

Use the Keelson console to configure the policy:

  1. Open Workspace Settings, then open the security settings.
  2. Under Allowed Sources, create an access source for an office, VPN, or other trusted network and add its IP addresses or CIDR ranges.
  3. Add the access source to the workspace default if apps should inherit it.
  4. To use a different policy for one app, open that app’s settings and select the access sources for its app-specific IP restriction.
  5. Save the configuration.
  6. Test from both an allowed network and a network that is not allowed.

Changes take effect without a redeploy. They normally take effect within two minutes. Changes made during an incident take effect after recovery.

An app-specific policy selects from access sources already registered in the workspace. Create or edit the address ranges in the workspace security settings first.

Before saving a restrictive policy, confirm that you have the correct public egress address for your office or VPN. The private address shown on a laptop, such as 192.168.x.x, is usually not the address Keelson sees.

An allowlist entry can represent one address or a CIDR network range.

  • A single IPv4 address, such as 203.0.113.10
  • An IPv4 CIDR range, such as 203.0.113.0/24
  • A single IPv6 address or an IPv6 CIDR range when your network uses IPv6

203.0.113.10 allows one public IPv4 address.

203.0.113.0/24 allows addresses in the corresponding 256-address IPv4 block. Use the narrowest range that covers the intended network.

If you are unsure which range represents your organization, ask the person who manages the office network or VPN. Do not broaden a CIDR range merely to make a failed test pass.

Test the allowlist after every change:

  1. Open the app from an allowed office or VPN connection.
  2. Confirm that an authorized user can sign in and reach the app.
  3. Switch to a connection outside the allowed range, such as a separate mobile connection.
  4. Confirm that Keelson denies the request before the app loads.

Keep an administrative console session available while testing so that you can correct an accidental lockout. Remember that a VPN may change the source address observed by Keelson.

IP restriction and user authentication are separate checks. Keelson evaluates the IP restriction after sign-in and app permission, immediately before the request reaches the application.

For stronger protection of an internal app:

  • Keep authentication enabled.
  • Grant app permissions only to the members who need them.
  • Enable an IP allowlist when access should also be limited by network location.

Passing the IP check does not grant a user access. The user must still satisfy the app’s authentication and permission requirements.

  • Prefer fixed public egress addresses for offices and VPNs.
  • Review entries when a network provider or VPN configuration changes.
  • Remove obsolete ranges instead of leaving temporary broad access in place.
  • Test both IPv4 and IPv6 paths if clients can use either protocol.
  • Record who owns each allowed range so it can be reviewed later.

Plans and limits

This page summarizes Keelson’s numerical limits. See the pricing page for pricing.

StarterPlusTeam
Concurrent apps124
Developers (Owner / Admin / Developer seats)123
Scheduled Jobs executions / month1,0005,00015,000
Cron entries / app3510
Minimum cron interval60 minutes15 minutes5 minutes
Maximum timeout per cron run3 minutes5 minutes10 minutes
Builds / month1003001,000
Build time / month500 minutes1,500 minutes5,000 minutes
Concurrent builds112
Daily backup retention1 generation3 generations7 generations
PITR (point-in-time recovery) window24 hours7 days14 days
Storage (workspace total)10 GB20 GB50 GB
Database capacity (per DB)No fixed limitNo fixed limitNo fixed limit
Priority startYesYes
Custom domainsUp to 10
Access log retention30 days90 days180 days
App log retention7 days14 days30 days

The Enterprise plan is in preparation. Contact us if your requirements exceed these limits.

  • Unlimited app users
  • Paid plans allow an unlimited number of stored apps. Only the number that can run concurrently is limited. During the trial, separate limits apply to owned and concurrently running apps; see Limits during the trial below
  • Authentication, access control, and IP restrictions
  • Daily backups, manual snapshots (five per app per day), PITR, and backup downloads
  • Managed SQLite (databases isolated by app and workspace)
  • A 14-day free trial (once per account). You can start Starter and Plus without registering a card

The number of web apps running concurrently. A slot is automatically released about five minutes after the last access, so only apps accessed within the last five minutes consume slots.

  • Sleeping and suspended apps do not consume slots
  • Apps that only run cron jobs do not consume slots; they run in a separate allocation
  • Static sites do not use app slots, and there is no limit to how many you can create
  • Apps configured for Priority start always reserve a slot. On Plus and above, you can configure Priority start for up to one fewer app than the effective number of slots

Developer seats are used by accepted members with the Owner, Admin, or Developer role and by unaccepted invitations that have not expired. An unaccepted invitation reserves one seat when created and is included in usage. Only accepted members are counted when checking limits for accepting invitations and changing roles. App Users are not counted.

Storage is the workspace total for app artifacts, Files / Media SDK files, and snapshots. Logs are not included. Managed SQLite capacity is managed separately from this allocation and has no fixed per-plan limit.

  • Except for apps deployed as static sites, a trial workspace can own at most 3 apps. Undeployed apps and apps that use a server count toward this limit, and creating a 4th is rejected
  • Every trial workspace can run at most 3 apps at the same time, regardless of its plan. This replaces the plan’s app-slot limit during the trial and does not include extra-app add-ons, so Team drops from 4 slots to 3
  • Builds are limited to 7 per day
  • Builds are limited to 30 for the entire trial. Because these 30 builds are cumulative, the allowance does not reset when the month changes
  • Build time and concurrent-build limits remain at the values for your plan
  • When you move to a paid plan, the owned-app limit is removed and the concurrent-app limit returns to your plan’s app slots plus any extra-app add-ons
LimitBehavior
Concurrent appsYou cannot start or deploy a new app. Running apps are unaffected. If you access a sleeping app when no slot is available, an HTTP 503 information page appears. A slot is released after about five minutes
DevelopersYou cannot invite a member to a developer role or change a member to one. You can still invite App Users
Scheduled Jobs executionsRemaining executions for the current month are skipped. The quota resets the next month
Cron entries / minimum interval / timeoutA deployment is rejected if keelson.yaml does not comply with the plan
Builds / build time (monthly)New builds are rejected. Both allowances reset the next month. The trial’s cumulative limit of 30 builds does not reset, so you must move to a paid plan to continue
Concurrent buildsWhile the limit is reached, new builds are rejected until a running build finishes
StorageService does not stop immediately. Support will contact you about the excess usage
Database capacityThere is no fixed per-plan limit. However, technical limits of the underlying infrastructure apply. Keelson may also restrict or stop an app if it detects a load that significantly exceeds ordinary business use or usage that affects platform stability, other customers, or Keelson’s reasonable operating costs. In an emergency, Keelson may impose restrictions without advance notice
Add-onUnit
Additional storage+50 GB
Additional concurrent apps+1 slot
Additional Developers+1 seat

Billing and subscriptions

This page explains how to manage your subscription. For numerical limits, see Plans and limits. For prices, see the pricing page.

  • What you can do: Change plans and add-ons, check payment methods and invoices, cancel or resume a subscription, and delete a workspace
  • Required role: Owner or Admin. Only the Owner can delete a workspace
  • Where: In the console, go to Settings → Billing and usage. To delete a workspace, go to Settings → Dangerous actions
  • Each account can use a 14-day free trial once. Starter and Plus can start without a card. If you do not add a card before the trial ends, it ends automatically without a charge. Team requires a card when starting the trial (cancel during the trial to avoid a charge)
  • If you have added a card, the trial end date is your first billing date. Subsequent charges occur monthly based on your subscription date
  • If an account that has already used its trial creates a second workspace, billing starts as soon as payment is completed
  • Your billing country determines the currency (JPY including tax for Japan, USD excluding tax elsewhere). The currency is fixed when you first subscribe; contact support to change it later
  • Payment by invoice or bank transfer is not supported

The number of App Users does not affect the price.

Trial limits and what happens when it ends

Section titled “Trial limits and what happens when it ends”

During the trial, every plan uses these limits: up to 3 stored apps (apps already deployed as static sites do not count), up to 3 concurrent apps, and up to 7 builds per day and 30 in total. See Plans and limits.

Status at the end of the trialWhat happens
Card addedYour subscription converts to a paid plan and the first charge is made. The stored-app limit is removed, and concurrent-app slots return to your plan’s allowance
No card added (Starter / Plus)The trial ends automatically without a charge. New deployments, app starts, and scheduled jobs are blocked. Apps and data are not deleted (see “Apps and data after cancellation” below)

During the trial, both upgrades and downgrades take effect immediately without a charge.

Go to Billing and usage → Change plan. The amount is shown before you confirm.

ChangeTakes effectBilling
UpgradeImmediatelyThe unused portion of your current plan is deducted from the cost of the remaining period on the new plan, and the difference is charged when you change plans. The full new price applies from the next billing date
DowngradeAt the end of the current billing periodNo prorated adjustment. You can keep using your current plan until then

A downgrade appears as a scheduled change. Use “Cancel scheduled change” to cancel it before it takes effect.

A warning appears if your current usage exceeds the new plan’s limits. If you switch while still over a limit:

  • Concurrent apps: Sleeping apps cannot start until usage falls within the limit
  • Developers seats: Invitations and role changes to Developer or higher are blocked until usage falls within the limit
  • Scheduled job runs: Runs are skipped for the rest of the month
  • Storage: This is a soft limit. Exceeding it does not immediately stop service; a warning appears at deployment and support will contact you. Sustained, significant overages may result in restrictions
  • Custom domains (Team only): Existing registrations remain, but you cannot add or re-register domains

Change add-ons (paid Team plans and above)

Section titled “Change add-ons (paid Team plans and above)”

Go to Billing and usage → Add-ons to set quantities for extra app slots (+1), extra Developers seats (+1), and extra storage (+50 GB). You cannot change add-ons during the trial; you can add them after it ends. The prorated amount is charged immediately to your saved payment method. Reductions are credited toward future invoices.

Recent invoices appear under Invoices on the Billing and usage page. Each row links to the Stripe invoice page and PDF.

Use the “Manage billing” button to open the Stripe customer portal in a new tab to change your payment method, edit billing details, or view older invoices. You can also cancel in the portal, but cancelling in the Keelson console lets you review the end date and impact first.

If a payment fails and becomes overdue, service continues as usual while Stripe retries. If payment is still outstanding after retries finish, the subscription is suspended and new deployments, app starts, and scheduled jobs are blocked. Use “Update payment method” to update your details.

Go to Billing and usage → Cancel plan, review the details, then select “Confirm cancellation”.

  • Your plan remains active until the end of the current billing period. Nothing stops immediately when you confirm, and the remaining period is not refunded
  • You can undo the cancellation with “Resume” at any time before the period ends
  • After the period ends, new deployments, app starts, and scheduled jobs are blocked. Cancellation itself does not delete apps or data

When a workspace’s subscription ends—after cancellation takes effect, suspension for nonpayment, or a trial ending without a card—apps can no longer start, but they are not deleted immediately.

  1. When affected apps are identified, the Owner and Admins receive an advance deletion notice in the console and by email. The notice includes the scheduled deletion date
  2. The grace period is 30 days from the notice. Usage restrictions remain in place, but you can sign in to the console and download database backups
  3. Resubscribing by choosing a plan during the grace period cancels the notice and keeps your apps. This is the only way to retain them
  4. After the grace period, apps are deleted automatically. They receive the same treatment as a user-approved deletion: only database backups, Files, and non-sensitive configuration notes are retained in isolation for 7 days. Recovery is not guaranteed

Export any data you want to keep during the grace period (see “Export your data” below).

Go to Settings → Dangerous actions → Delete this workspace. Only the Owner can do this, and confirmation requires entering the workspace name.

  • The workspace and all its apps are deleted, and members lose access. This cannot be undone
  • Each app’s database backups, Files, and non-sensitive configuration notes are retained in isolation for 7 days, but this does not guarantee recovery
  • Deletion signs you out
  • If you have an active subscription, deleting the workspace also cancels it. You do not need to cancel first. As a precaution, check the Stripe portal (open it through “Manage billing” before deletion) or invoice emails to confirm that billing has stopped

You can delete your Keelson account yourself from the account menu at the top right of the console → Account settings → Security → Delete account. This deletes your Keelson account, not the Google or Microsoft account you used to sign in. Apps and data in workspaces you belonged to also remain.

Before cancelling or deleting, export the following. The steps differ for databases and Files / Media.

DataHow to export
Managed SQLite dataDownload any available point in time as a SQLite dump from Restore points on the app’s console page. This remains available during the grace period after your subscription ends
Files SDK / Media SDK filesDownloads are not available through the console or CLI. Implement authenticated download endpoints in your app for listing and retrieving files, and export them while the app is running. Apps cannot start after the subscription ends, so do this before cancelling
Source codeFrom Git, or download it from an individual deployment in the Deployments tab
Environment variables and secretsSecret values are not displayed in the console. If needed, record them from their original source
Access logs and app logsExport from the Logs tab (up to 5,000 lines for app logs)

The terms of service allow us to delete data after a period we specify following the end of the subscription (generally within 30 days). Export your data yourself before the subscription ends.

Reference


Keelson Deploy Spec

Spec version: 2026-09-02 / Raw text (for AI agents): /docs/reference/deploy-spec.txt

This document is the canonical definition of the supported runtimes, constraints, and requirements for deploying an app to Keelson. Use it when deciding whether an app can be deployed.

For a quickstart and step-by-step instructions, see Deploy an app.


A completed build alone does not make a Keelson deployment successful. A deployment succeeds only when all of the following conditions are met:

  1. The app build has completed.
  2. The app process has started.
  3. The app has passed its health check.
  4. An app URL (https://<slug>.keelson.run) has been issued.
  5. The app is reachable at that URL.

If the build succeeds but the app fails to start or pass its health check, the deployment is not successful.


Every deployment requires a keelson.yaml file in the project root directory.

Minimal configuration:

slug: my-app
runtime: python-slim
command: "python app.py"

Framework-based apps such as Flask require a production server. See Production hardening.

See the keelson.yaml reference for details about every field.


Each application is placed in one logical region when it is created. Defined region keys are jp-tokyo (Japan) and us-oregon (US West); Keelson shows these display names instead of cloud-provider region names.

For a new application, select a region with keelson deploy --new --region <region>, or set the top-level region field in keelson.yaml. The CLI option takes priority over the file, and the file takes priority over the workspace’s default region. A region can be listed but temporarily unavailable for new applications during rollout; an explicit unavailable selection is rejected.

The region is fixed after application creation. Deploying with another region does not move an existing application and is rejected; create a new application to use another region.


Keelson can run apps only on the following runtimes.

RuntimeLanguageIntended use
python-slimPythonLightweight APIs, text processing, automation, and similar workloads
python-mediaPythonMedia processing; includes image and video libraries
node-slimNode.jsLightweight web apps, APIs, and similar workloads
node-mediaNode.jsMedia processing; includes image-processing libraries
go-slimGoLightweight Go apps
go-mediaGoGo apps with media-processing dependencies

Select a runtime with the runtime field in keelson.yaml. If you are unsure, start with a -slim runtime and switch to -media when the app needs media-processing libraries.

  • slim — Contains the language runtime and standard libraries only. It builds faster and has a smaller image.
  • media — Adds common system libraries required for image processing, such as Pillow and sharp, and for video processing.

Keelson does not depend on a specific framework. An app can run if its command starts it and it accepts requests as an HTTP server.

Examples include FastAPI, Flask, Express, Next.js, Hono, and Gin.


The following languages and runtimes are not supported:

  • Ruby
  • Java / Kotlin / Scala
  • PHP
  • Rust
  • .NET / C#
  • Elixir / Erlang
  • Swift

Apps that require an unsupported runtime cannot be deployed to Keelson, even after configuration changes that do not replace that runtime.


Keelson provides fixed build and runtime environments. The app must be able to build and start in those environments.

  • OS: Linux
  • CPU: x86_64 (amd64)

Apps run as a non-root user. They cannot use sudo, run apt-get install, or make system-level changes.

Custom Dockerfiles are not supported. Keelson selects a runtime and starts the app with command. Specify the runtime and start command in keelson.yaml instead of a Dockerfile.

PathWritablePersistentPurpose
/dataNo; it cannot be created by your appNoKeelson does not provide it. Apps run as UID 1000 and cannot create directories under the root-owned /
App directoryNo; not a supported write locationNoSource code and dependencies
/tmpYes, temporarilyNoTemporary files
Other pathsNo
  • Every write to the local file system is ephemeral and is lost after a restart or scale-to-zero event.
  • Use Managed SQLite (db.mode: libsql) for persistent relational data. A file-based SQLite database under /data is not persistent.
  • For durable files, use the Files SDK for private app data or the Media SDK for content served to app members. See Files and media.
  • A web app must listen for HTTP requests on the port specified by the PORT environment variable.
  • It must listen on 0.0.0.0. Requests cannot reach a server bound to 127.0.0.1 or localhost.
  • Keelson terminates HTTPS. The app itself must listen over HTTP.

Keelson does not provide an environment where arbitrary OS packages can be added.

  • -slim runtimes contain only a minimal set of system libraries.
  • -media runtimes contain common libraries required for image and video processing.
  • An app may not work if it needs a system library that is not present in its runtime.
  • Packages cannot be added with apt-get or similar tools because the app runs as a non-root user.
  • The normal model is one process started by command.
  • systemd and daemon managers are not available.
  • Use crons for background work. workers has been removed, and declaring it causes the deployment to be rejected.

Even when the language runtime is supported, dependencies and system requirements can prevent an app from being deployed.

Dependencies installed by language package managers

Section titled “Dependencies installed by language package managers”

Pure language packages managed by the following package managers can be installed normally:

  • Python: pip (requirements.txt)
  • Node.js: npm (package.json)
  • Go: go mod (go.mod)

Keelson installs dependencies automatically while building the image. Do not install them in command; use command only to start the app.

# Python
command: "python app.py"
# Node.js
command: "npm start"
# Go (Keelson builds `./app` during deployment)
command: "./app"

Some packages require C libraries or other system-level dependencies.

  • Commonly supported by -media runtimes: General media-processing libraries such as Pillow, opencv-python, sharp, and ffmpeg-related packages.
  • Potentially unsupported: Packages that depend on a system library not included in the runtime.
PatternReason
Requires apt-get installPackages cannot be added without root privileges
Depends on a specialized C libraryThe library may not be included in the runtime
Requires a GPU inference libraryGPU instances are not provided
Runs a database server such as PostgreSQL, MySQL, or RedisThe app can connect to an external service, but cannot run that server on Keelson
Requires systemd or a background daemonThe process model is different

A deployment mode is not chosen directly. It is derived automatically from the presence of command and assets. CLI and API output exposes the raw deploy_mode label; use the following table to interpret it.

NameRaw deploy_modecommandassetsDescription
Web appcontainerPresentAbsentStandard app deployment
Static siteedge-staticAbsentPresent, without fallbackStatic files only
SPAedge-spaAbsentPresent, with fallbackSingle-page app with fallback routing
HybridhybridPresentPresent; fallback requiredStatic files plus a backend API

Keelson reserves only one URL namespace on an app’s host: /__keelson/*. Every other path belongs to the app. The platform does not take over common paths such as /assets, /files, /static, /uploads, or /api.

  • /__keelson/* is for platform internals. Keelson uses it for platform-served assets, file downloads, and internal endpoints. Do not define app routes under this path.
  • Do not emit a __keelson directory at the root of the served build output. A build containing this reserved path is rejected with the error code reserved_path_conflict. Rename the directory and deploy again.
  • No other path is reserved. Framework defaults such as /assets/*, Vite’s default output path, can be served without configuration changes.
  • Related rules: an auth.endpoints path cannot start with /__keelson; it must start with /api/external/ or /api/webhooks/. Reserved words also cannot be used as a slug.

Framework static-path collision matrix (reference)

Section titled “Framework static-path collision matrix (reference)”

The following table lists the default static paths of common frameworks. None conflicts with /__keelson/*. This is reference information: “Verified” means the behavior has been checked in this repository; “Needs verification” is a knowledge-based estimate and must be checked before it is used as evidence.

FrameworkDefault static pathConflicts with /__keelsonStatus
Vite (Vue / Svelte / React / Solid / Preact)/assets/*NoVerified
Remix v2 / VitePress/assets/*Expected not toNeeds verification
Angular/assets/*Expected not toNeeds verification
Next.js/_next/static/*Expected not toNeeds verification
Nuxt / SvelteKit / Astro/_nuxt/* · /_app/* · /_astro/*Expected not toNeeds verification
CRA / Django / Flask/static/*Expected not toNeeds verification

Configure API keys, tokens, and connection details required at startup as environment variables or secrets instead of embedding them in source code.

  • env in keelson.yaml — Values that are safe to include in version control.
  • Console secrets — API keys, tokens, and other values that should not be committed to source code.

The app may fail to start correctly if a required value is missing.

See Environment variables and secrets for details.

VariableDescription
PORTPort the app must listen on. Read it; do not set it
TZWorkspace time zone, detected automatically from the browser when the workspace is created
KEELSON_MODEMarker indicating that the app is running on Keelson (keelson)
KEELSON_APP_IDInternal app ID
KEELSON_WORKSPACE_IDInternal workspace ID
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_IDInternal ID of the current deploy

The former tenant-named variable remains accepted as a compatibility alias. No removal date is set.


Keelson apps run on Cloud Run and may start and stop during normal operation. On shutdown, the process receives SIGTERM and has 10 seconds before SIGKILL. Use a production server and handle graceful shutdown within that window.

Flask: app.run() starts the Werkzeug development server. It may remain behind a local __main__ entry point, but it must not serve a deployment, even with debug=False. Add gunicorn to requirements.txt and use:

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 app:app"

Keelson provides 1 vCPU, so use one worker. A graceful timeout of 9 seconds fits within the 10-second SIGTERM window. Replace app:app with the module and application object for the project.

Django: Start Django with gunicorn:

command: "gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 --graceful-timeout 9 config.wsgi:application"
  • Keep DEBUG off by default.
  • With DEBUG=False, Django does not serve static files itself. Add whitenoise to MIDDLEWARE, configure STATIC_ROOT, and ensure the deployed artifacts include the output of collectstatic.
  • Keep ALLOWED_HOSTS = ["*"]; restricting it can make health checks return HTTP 400.
  • Replace config.wsgi:application with the WSGI module for the project.

FastAPI / uvicorn: Bind to 0.0.0.0:$PORT and leave the worker count at its default of one:

command: "uvicorn main:app --host 0.0.0.0 --port $PORT"

Do not use --reload. File watching consumes memory and can start the app twice. Replace main:app with the module and application object for the project.

Next.js: Start the built application, not the development server:

command: "npm run start"

Set the package script to:

"start": "next start -p $PORT"

If package.json contains "start": "next dev", fix that script rather than working around it. next dev is not a production server.

Node.js: Keelson does not set NODE_ENV automatically. Declare production mode in keelson.yaml; without it, frameworks such as Express may return stack traces:

env:
NODE_ENV: "production"

Go: Start the built binary with command: "./app". Do not leave ListenAndServe without shutdown handling: use signal.NotifyContext and server.Shutdown to stop accepting new requests and drain in-flight requests within the 10-second window.

Defaults must be safe for production. In Python, default DEBUG to false:

DEBUG = os.environ.get("DEBUG", "false").lower() == "true"

Do not default it to true:

DEBUG = os.environ.get("DEBUG", "true").lower() == "true"

Debug pages can expose environment variables, including database authentication tokens.

Use KEELSON_MODE, which Keelson sets to keelson, to detect the platform:

ON_KEELSON = os.environ.get("KEELSON_MODE") == "keelson"

Apply fail-fast checks for missing production configuration only when ON_KEELSON is true. Do not use DEBUG as the platform check, because that can prevent the app from starting locally.


TargetLimit
HTTP requestA general request timeout applies
Scheduled job (cron)1–600 seconds; default 300 seconds, with the same maximum on every plan
BuildLimited; practical duration depends on the amount of dependencies

  • Apps can connect to external APIs and services by default.

SymptomCauseResolution
Build succeeds, but the app exits immediately after startingServer is not listening on 0.0.0.0Set host="0.0.0.0" explicitly
Cannot connect to the portPort number is hard-codedRead the port from the PORT environment variable
Module not foundDependency is missing from the dependency manifest or failed to install during the image buildCheck the build logs and declare the dependency in requirements.txt or package.json
Native module fails to buildRequired system library is missingSwitch to a -media runtime or replace the dependency
Start command not foundEntrypoint path is incorrectCheck the file name and path
Build fails with reserved_path_conflictA __keelson directory exists at the root of the served build outputRename the directory; only /__keelson/* is reserved
A scheduled job never fires, and no error is loggedThe schedule lives inside the app process (an in-process scheduler). The app has scaled to zero by the time it is dueDeclare it as a crons entry instead
The UI reports success, but the email or notification never arrivesThe work was deferred until after the response was returned, so it never ranFinish it before responding — or write it to a queue table in the database, drain it from a crons entry, and tell the user the wait is up to one interval
  1. Check the build logs — Confirm that dependencies were installed successfully.
  2. Check the runtime logs — Look for startup and runtime errors.
  3. Check keelson.yaml — Verify runtime, command, and env.

When deploying an app to Keelson, an AI agent should make decisions in the following order.

  1. Check the runtime — Confirm that the app’s language appears in this specification’s supported runtimes.
  2. Check dependencies — Confirm that native dependencies and system-library requirements fit within the constraints.
  3. Check keelson.yaml — Confirm that the file exists and its configuration is valid. Create it if it is missing.
  4. Check environment variables — Identify any missing environment variables or secrets the app requires.
  5. Deploy — Use the CLI or Skill to run the deployment.
  6. Verify completion — Check startup, the health check, and URL access, not only build completion.
  7. Handle failure — Inspect the logs and make changes where possible. If a constraint is violated, state the reason and stop.
  • Do not attempt a deployment when the runtime is unsupported. State the reason and stop.
  • When required configuration is missing, explain what is needed.
  • Do not treat build success alone as deployment completion.
  • Always inspect the logs after a startup failure.
  1. The latest Keelson Deploy Spec on the web (this document)
  2. The latest CLI version information on the web
  3. The copy of the specification bundled with a Skill
  4. General knowledge and assumptions

If the web canonical specification conflicts with information bundled with a Skill, follow the web canonical specification.


keelson.yaml

keelson.yaml is the deployment configuration file at the root of your project. It defines the application runtime, startup command, environment variables, database mode, scheduled jobs, and static assets.

Keelson reads this file during deployment and uses it to determine how to build and run the application. For a task-oriented introduction, see Configure an app with keelson.yaml.

slug: my-app
runtime: python-slim
command: "python app.py"
db:
mode: none

FieldTypeRequiredDefaultDescription
slugstringYesApplication identifier; used when the application is created
workspacestringNonullDefault workspace for project-scoped CLI commands. Prefer the workspace slug. Omit this field when your account has only one workspace because the CLI selects it automatically. An explicit --workspace overrides this value; apps list remains unfiltered
descriptionstringNonullA short description of the application, up to 300 characters
typestringNonullApplication type; only "web" is accepted
runtimestringYesExecution environment; see Supported runtimes
commandstring | listConditionalStartup command; optional for cron-only and static-only deployments
envmapNo{}Non-secret environment variables
dbobjectYesDatabase and local SQLite policy
cronslistConditional[]Scheduled jobs; required if there is no command or static asset deployment
storageobjectNo{}Compatibility block that accepts only the deprecated disk_id field
assetsobjectNonullStatic asset delivery configuration
secretsobjectNo{}Secret declarations and requirements
authobjectNonullEndpoints that bypass interactive Keelson authentication
emailobjectNo{}Inbound email configuration
verifystring | listNo[]Extra paths checked after a deployment becomes healthy
regionstringNonullLogical placement region for a new application

The retired top-level databases and workers keys are not valid, even when their value is null or an empty list. Use db.mode: libsql for durable relational data and crons for time-triggered work.

The former top-level tenant field remains accepted as a compatibility alias for workspace. No removal date is set. If both fields are present, their raw string values must match exactly.


The application identifier. It is used as part of the application identity and, when creating an application, contributes to its default public address.

slug: my-app

Rules:

  • Use lowercase letters, digits, and hyphens only: a-z, 0-9, and -.
  • The length must be between 1 and 63 characters.
  • The value must start and end with a letter or digit.
  • Consecutive hyphens are not accepted.
  • Reserved slugs are admin, api, assets, auth, console, health, static, and www.

public_slug is a deprecated compatibility field. It is ignored because the public URL is derived from the application host.


An optional one- or two-sentence explanation of who the application is for and what it does.

description: "An internal dashboard for reviewing support requests."

The value is trimmed and may contain at most 300 characters. An empty value is treated as unset. During deployment, the description is used only when the application does not already have a description, so a description edited in the Console is not silently overwritten.


The optional application type.

type: web

The only accepted value is web. It enables static asset delivery through assets and permits a deployment without a top-level command when static assets are present.

Constraints:

  • type: web cannot be combined with crons.
  • Omitting type is appropriate for ordinary application and cron-only deployments.

The execution environment. A runtime is required even for a static-only deployment because Keelson still uses it during the build.

RuntimeLanguageIntended use
python-slimPythonLightweight applications, APIs, and text processing
python-mediaPythonImage and video processing workloads
node-slimNode.jsLightweight Node.js applications
node-mediaNode.jsMedia-processing Node.js applications
go-slimGoLightweight Go applications
go-mediaGoMedia-processing Go applications

Start with a -slim runtime unless the application needs the extra media libraries included in a -media runtime.

Keelson installs dependencies automatically during the image build. If the application uses external libraries, put the appropriate dependency manifest at the project root. Keep command limited to starting the application. If command includes pip install, npm install, or go build, the CLI pre-deploy check stops the deployment with command_installs_dependencies.

RuntimeDetected filesBuild step
python-*requirements.txtpython -m pip install --user -r requirements.txt
python-*pyproject.toml with [project] or [build-system]python -m pip install --user .
node-*package-lock.json / package.jsonnpm ci when a lockfile is present, or npm install, followed by npm run build --if-present
go-*go.mod / go.sumgo mod download, then go build -o /workspace/app . (start it with command: "./app")

Build constraints:

  • An installable Python pyproject.toml must be accompanied by a recognized lockfile: requirements.txt, poetry.lock, uv.lock, or Pipfile.lock.
  • Node builds use npm and require package-lock.json. If only pnpm-lock.yaml or yarn.lock is present, the pre-deploy check stops with lockfile_unsupported. Those files may remain alongside package-lock.json.
  • A Go module that declares third-party dependencies must include go.sum. A standard-library-only module does not require it.
  • Dependencies must be available from public registries. Private registries, git+ssh dependencies, and authenticated installs fail.
  • Go builds use CGO_ENABLED=0.
  • The build target is linux/amd64 only.
  • Secrets are not available during the build.
  • The build execution time limit is 600 seconds.

The command executed when the application starts. Use either a shell string or an argument list.

# String form: executed through a shell
command: "python app.py"
# List form: executed directly
command:
- python
- app.py

Rules:

  • At least one of command, crons, or a static assets deployment must be present.
  • A cron-only application does not need a top-level command.
  • A static-only type: web application with assets does not need a top-level command.
  • Do not include dependency installation or build steps. Keelson runs them automatically during the image build.
  • An empty string or empty list is not a command.

Defines non-secret environment variables as key-value pairs. All values are strings.

PORT is set by Keelson at runtime; do not declare it in env.

Always quote environment variable values. An unquoted value is rejected with env_value_not_string, and deployment does not start. Numbers and booleans must also be quoted.

env:
NODE_ENV: "production"
DEBUG: "false"
LOG_LEVEL: "info"

Quoting is required because the CLI and API parse the file using YAML 1.2 and YAML 1.1 implementations, respectively. Those versions can assign different types to the same unquoted scalar. Quoting guarantees that both paths deploy the same string.

An unquoted key must start with a letter or underscore and contain only letters, digits, and underscores. Ordinary names such as NODE_ENV and DB_POOL can therefore be written without quotes.

The following words cannot be used as unquoted keys because YAML 1.1 may interpret them as booleans or null:

yes Yes YES no No NO true True TRUE false False FALSE on On ON off Off OFF null Null NULL

Quote a key to use a hyphen, a reserved YAML word, or another otherwise unsupported spelling.

env:
NODE_ENV: "production"
"MY-VAR": "x"
"yes": "x"

Keys beginning with KEELSON_, case-insensitively, are reserved for the platform and cannot be set in env. Duplicate env declarations, duplicate keys inside env, and YAML merge keys (<<) are also rejected.

Use secrets to declare sensitive settings. Do not commit secret values to keelson.yaml.


Selects how the application handles relational data. The db block and its mode field are required for new deployments.

db:
mode: libsql
FieldTypeRequiredDefaultDescription
modestringYeslibsql or none; legacy turso is normalized to libsql
migratestringNonullMigration command run during deployment; valid only with libsql
auto_adoptbooleanNofalseCompatibility option for adopting an existing managed database
local_sqliteobjectNonullExplicit declaration of regenerable, ephemeral local SQLite files
modeMeaning
libsqlKeelson provisions an isolated managed database and injects KEELSON_DB_URL and KEELSON_DB_AUTH_TOKEN
noneKeelson does not manage a database; use this for database-free apps or an external database

Managed SQLite with mode: libsql requires no separate account or connection configuration. It supports scale-to-zero and is the recommended option for durable relational data.

For PostgreSQL, MySQL, or an independently managed libSQL service, select mode: none and configure connection details as secrets. Keelson does not inject credentials for an external database.

There is no mode that makes a SQLite file under /data durable. Applications that need durable SQLite semantics must use a libSQL client with mode: libsql.

db.migrate defines an application-owned migration command. Keelson runs it once against the new image before switching rollout traffic.

db:
mode: libsql
migrate: "python -m alembic upgrade head"

The command must be a string and is only valid with mode: libsql. An empty value is treated as no migration command.

Use db.local_sqlite only for a disposable file that can be regenerated. It documents the application policy; it does not make the file persistent.

db:
mode: none
local_sqlite:
policy: ephemeral
paths:
- /tmp/cache.db
reason: "A derived cache rebuilt from the source API"
FieldTypeRequiredConstraint
policystringYesMust be ephemeral
pathslist of stringsYesNon-empty; each entry must be under /tmp/ or be :memory:
reasonstringYesNon-empty explanation of why losing the data is safe

The retired databases block is always rejected. It cannot be used to persist a file SQLite database.


Defines scheduled jobs. Keelson starts each job according to its schedule in a separate instance from the web service.

crons:
- name: cleanup
schedule: "0 3 * * *"
command: "python cleanup.py"
timeout: 60
enabled: true
FieldTypeRequiredDefaultDescription
namestringYesUnique job name, 1–63 lowercase letters, digits, or hyphens
schedulestringYesValid five-field cron expression
commandstring | listYesCommand to execute
timeoutintegerNo300Timeout in seconds, from 1 through 600
enabledbooleanNotrueWhether Keelson schedules the job

Rules:

  • A configuration may contain at most 10 jobs. The plan may impose a lower limit.
  • Job names must be unique.
  • type: web and crons cannot be combined.
  • A job runs in a different instance from the web service, so local files such as /data are not shared.
  • If the previous run is still active, Keelson skips the overlapping run.
  • Schedules are evaluated in the workspace time zone. This setting is detected automatically from the browser when the workspace is created.
  • The platform-wide timeout ceiling is 600 seconds. A plan-specific ceiling may be lower.

Plan limits for Starter, Plus, and Team are, respectively: 3, 5, and 10 jobs; minimum intervals of 60, 15, and 5 minutes; and timeout limits of 3, 5, and 10 minutes.

ExpressionMeaning
* * * * *Every minute
0 * * * *At minute 0 of every hour
0 3 * * *Every day at 03:00
0 0 * * 1Every Monday at 00:00

See Scheduled jobs for execution behavior, examples, and troubleshooting.


workers for background workers or periodic drains has been retired. Any top-level workers key is rejected with workers_not_supported, including an empty list or null value.

Represent time-triggered background work with crons. A drain-style process can run on a short schedule, process the pending items, and exit successfully.

# Invalid: deployment is rejected
workers:
- name: drain
command: "python worker.py"
every: 10m
# Valid: use crons
crons:
- name: drain
schedule: "*/5 * * * *"
command: "python drain.py"
timeout: 120

storage accepts only the deprecated disk_id field. The value is ignored; it does not enable persistent storage or automatic synchronization of /data.

General-purpose persistent file I/O cannot be configured in keelson.yaml.

storage:
disk_id: my-disk
FieldTypeRequiredDefaultDescription
disk_idstringNonullDeprecated and ignored identifier; 1–32 lowercase letters, digits, or hyphens

Configures static asset delivery for static sites, SPAs, and hybrid applications that combine static files with a backend API.

assets:
dir: dist
fallback: index.html
api: /api
FieldTypeRequiredDefaultDescription
dirstringYesAsset directory relative to the project root
static_dirstringNonullDeprecated alias of dir; if both are present, they must match
fallbackstringConditionalnullRelative fallback file for an SPA, such as index.html
apistringNonullPath prefix forwarded to the backend, such as /api

When api is set, requests under that path go to the backend application and all other requests are served as static assets.

When the CLI creates the deploy archive, it normally excludes paths containing any of these names: .git, .venv, __pycache__, .pytest_cache, node_modules, dist, build, .idea, .vscode, and .DS_Store. The declared assets.dir and its ancestor directories are exempt, so build output such as dist is included. Built-in exclusions still apply below assets.dir: for example, dist/index.html is included but dist/node_modules/** is not. .git is always excluded, as are symlinks, other non-regular files, and a file selected by --secrets-from-env-file, regardless of its name. The CLI stores only that project-relative path, never secret values, in the automatically excluded .keelson-config/previous-secrets-files.json file.

Env-style files and directories are also excluded, including inside assets.dir. A final path element is treated as an env name when it starts with .env or ends with .env, case-insensitively. Examples include .env, .env.production, .envrc, and .secrets.env. This rule applies whether or not --secrets-from-env-file is used.

To exclude project-specific content, create .keelsonignore in the project root. It accepts one gitignore-like pattern per line, ignores blank lines and # comments, and supports /, trailing /, *, ?, **, [abc], [a-z], and leading ! negation. Matching is case-sensitive and the last matching rule wins. Negation can deliberately re-include an env file such as !.env.production, but cannot re-include .git, built-in exclusions, runtime build exclusions, .keelsonignore itself, or a file selected by --secrets-from-env-file. Invalid patterns produce a warning and are ignored.

For a static app with assets and no string- or array-form command, the archive includes only files under assets.dir and keelson.yaml by default; ancestor directories remain traversable so the CLI can reach the asset directory. Files outside that tree are not uploaded. A leading ! rule can deliberately restore an outside file. To restore a directory and its contents, use both !keep/ and !keep/**. Container and hybrid apps still archive the source needed for their builds.

Avoid assets.dir: . and equivalent paths such as ./: the project root is then the public asset directory, so every otherwise archived file can be served. Keelson warns before deployment but does not reject this configuration.

Run keelson deploy --check --json and review its archive.excluded, archive.excluded_env_files, archive.secret_like_files, archive.embedded_credentials, archive.unscanned_files, and archive.unscanned_binary_count values before upload. Embedded credential entries expose only the path and kind, never the value. Preview and app-token matches block upload; webhook-signing-only matches warn and continue. Files over 16 MiB and files classified as binary are not content-scanned, and excluded files are outside the scan. secret_like_files warns about secret-like names and about an existing file selected by an earlier --secrets-from-env-file deploy when it is not selected for exclusion this time. Such files remain included, including non-env names such as prod-secrets.txt. The assets.dir exemption requires Keelson CLI v0.1.1 or later; check with keelson version and upgrade an older release before deploying assets from dist or build.

JavaScript and CSS files with a clear content hash in the filename, such as index-B7hK2mQ1.js, are cached for one year because the content at that URL is expected never to change. Keelson recognizes a final 8–64 character segment made of ASCII letters, digits, and underscores, containing both letters and digits, as a hash. HTML, unhashed files such as style.css and app.js, images, and all other assets are checked for updates on every visit. When a file has not changed, its ETag makes this a lightweight validation request.

You can redeploy an unhashed file under the same name. For hashed files, make sure your build tool generates a new filename whenever the content changes.

Rules:

  • dir cannot contain a .. path component.
  • fallback must be a relative path under assets.dir.
  • api must begin with /, cannot be /, and cannot be under /__keelson.
  • api is valid only when a top-level command provides a backend.
  • A hybrid deployment with both assets and command requires fallback.

Declares which secrets an application understands and which combinations are required. Values are set separately and never belong in this file.

secrets:
items:
- name: API_TOKEN
description: "Token used to call the upstream API"
- name: OAUTH_CLIENT_SECRET
required:
- all_of:
- API_TOKEN
message: "Set API_TOKEN before deploying."
FieldTypeRequiredDefaultDescription
itemslistNo[]Secret definitions
items[].namestringYesNon-empty secret name; KEELSON_ is reserved
items[].descriptionstringNo""Human-readable purpose of the secret
requiredlistNo[]Rules checked against configured secrets

Each required entry must contain exactly one of any_of or all_of. The selected list must be non-empty and may reference only names declared in items. An optional message supplies remediation text.


Configures application endpoints that are intended for external systems, such as webhook senders, and therefore do not use the normal interactive Keelson login flow.

auth:
endpoints:
- /api/webhooks/payments
- path: /api/external/status
methods:
- GET

auth.endpoints is required when auth is present and must be a non-empty list. Each entry is either a path string or an object with path and optional methods.

Endpoint paths must be unique and start with /api/external/ or /api/webhooks/. Paths under /__keelson are reserved. If methods is provided, it must be a non-empty list.


Controls inbound email support.

email:
inbound:
enabled: true

email.inbound.enabled is a boolean and defaults to false.


Adds explicit paths to the post-deployment verification probe. The default probe always checks /; these paths are additional entry points that Keelson should fetch after the deployment becomes healthy.

verify:
- /dashboard
- /settings

Use this for routes or assets that cannot be discovered from the default entry page, such as a route loaded only through a dynamic import. Omit it when the default probe is sufficient.

  • A declared path must return 2xx or 3xx. A 404, 401, 403, 405, or any other response outside those ranges fails verification with verification_declared_path_unreachable, for both container/hybrid apps and static/SPA apps.
  • Write only paths that do not require the app’s own user login. Verification requests identify the candidate revision but do not carry an app user identity, so an app-level 401 or 403 fails the deploy.
  • / remains a tolerant default even if it also appears in verify; an API-only app may legitimately return 404 there.

This differs from health.path: the health probe goes directly to the candidate revision and accepts 404 as proof that the process started. verify checks whether a declared public path is actually servable, so 404 does not pass.


Optionally selects the placement region when creating a new application.

region: us-oregon

Use a Keelson logical region key, not a cloud-provider region identifier:

KeyDisplay name
jp-tokyoJapan
us-oregonUS West

For a new application, selection priority is the CLI --region option, then region in keelson.yaml, then the workspace default. An unknown or not-yet-available explicit selection is rejected instead of falling back to another region.

An application’s region is fixed when the application is created. A later deploy with a different region is rejected and cannot move the application; create a new application to use another region.


Deployment mode is derived from the presence of command, assets, and fallback; it is not selected directly. CLI and API output such as keelson status --json returns the raw deploy_mode label.

NameRaw deploy_modecommandassetsfallbackDescription
Web applicationcontainerPresentAbsentOrdinary application deployment
Static siteedge-staticAbsentPresentAbsentStatic files only
SPAedge-spaAbsentPresentPresentStatic files with a fallback
HybridhybridPresentPresentRequiredStatic files plus a backend API

Some fields are valid only in specific combinations.

RuleRequirement
An executable surface is requiredSupply command, crons, or static assets
type: web excludes scheduled jobsDo not combine type: web with crons
workers is retiredReplace every workers declaration with an appropriate crons design
Managed migration requires managed datadb.migrate requires db.mode: libsql
Local SQLite is disposabledb.local_sqlite.paths accepts only /tmp/ paths and :memory:
assets.api requires a backendSet a top-level command before using an API prefix
Hybrid deployments require a fallbackSet assets.fallback when both assets and command are present

slug: flask-crud
description: "A CRUD application backed by Managed SQLite."
runtime: python-slim
command: "python app.py"
db:
mode: libsql
migrate: "python -m alembic upgrade head"
env:
PYTHONUNBUFFERED: "1"
slug: marketing-site
type: web
runtime: node-slim
db:
mode: none
assets:
dir: dist
fallback: index.html
slug: cron-logger
runtime: python-slim
db:
mode: none
env:
PYTHONUNBUFFERED: "1"
crons:
- name: heartbeat
schedule: "* * * * *"
command: "python heartbeat.py"
timeout: 30

The web application writes pending rows to Managed SQLite. The cron processes those rows from a separate instance, so both processes share state through db.mode: libsql, not through the local filesystem.

slug: task-drainer
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
crons:
- name: drain
schedule: "*/5 * * * *"
command: "python drain.py"
timeout: 120
slug: photo-galleries
runtime: python-slim
command: "python app.py"
db:
mode: libsql
env:
PYTHONUNBUFFERED: "1"
assets:
dir: static
fallback: index.html
api: /api
slug: my-go-app
runtime: go-slim
command: "./app"
db:
mode: none

Keelson builds the Go source for Linux and places the binary at ./app during deployment.

slug: node-crud
runtime: node-slim
command: "npm start"
db:
mode: libsql
env:
NODE_ENV: "production"

CauseResolution
db or db.mode is missingDeclare db.mode as libsql or none
The retired databases key is presentRemove it; use Managed SQLite or declare disposable local SQLite explicitly
A local SQLite path points outside /tmp/Use /tmp/, :memory:, or Managed SQLite for durable data
assets.api does not start with /Use an absolute path prefix such as /api
Neither command, crons, nor static assets is presentAdd at least one executable surface
workers is declaredReplace it with crons; workers are rejected with workers_not_supported
crons[].timeout is greater than 600Reduce the timeout to the platform ceiling or lower plan limit
An env value is unquotedQuote every value, including booleans and numbers
An environment variable starts with KEELSON_Rename it; that namespace is platform-owned
A hybrid deployment omits assets.fallbackSet a fallback file such as index.html
command installs dependencies (command_installs_dependencies)Remove the install or build step; dependencies are installed automatically during the image build
A dependency manifest is missing (missing_dependency_manifest)For Go, put go.mod at the project root. For Python or Node.js, add the appropriate manifest if the app uses external libraries; a standard-library-only app does not need one

Environment variables

This page lists the environment variables that Keelson sets in your app container. Do not add any of them to env in keelson.yaml. Names beginning with KEELSON_ are reserved by the platform and cannot be used in env or secrets.

VariableDescription
PORTPort the app listens on. Read this value; do not set it
TZWorkspace time zone (for example, Asia/Tokyo)
KEELSON_MODEkeelson. Use this to determine whether the app is running on Keelson. Not set locally
KEELSON_APP_IDInternal app ID
KEELSON_WORKSPACE_IDInternal workspace ID
KEELSON_TENANT_IDCompatibility alias for KEELSON_WORKSPACE_ID (same value)
KEELSON_DEPLOY_IDInternal ID of the current deploy
KEELSON_APP_URLApp URL (https://<ws>--<app>.keelson.run). Use this when an absolute URL is required. Set when the host can be resolved
KEELSON_DIRECTORY_BASE_URLDirectory API base URL. Used by the Identity SDK. Set under the same condition as KEELSON_APP_URL (when the host can be resolved)

The former tenant-named variable remains accepted as a compatibility alias. No removal date is set.

VariableConditionDescription
KEELSON_DB_URLdb.mode: libsqlManaged SQLite connection URL
KEELSON_DB_AUTH_TOKENdb.mode: libsqlManaged SQLite authentication token
TURSO_DATABASE_URL / TURSO_AUTH_TOKENdb.mode: libsqlAliases for the two variables above (for compatibility)
KEELSON_FILES_BUCKET / KEELSON_FILES_PREFIXAlwaysStorage location used by the Files SDK. Do not use directly
KEELSON_INTERNAL_MEDIA_BASE_URL / KEELSON_APP_MEDIA_TOKENAlwaysUpload destination and authentication used by the Media SDK. Do not use directly
KEELSON_MEDIA_URL_PREFIXAlwaysMedia delivery URL prefix (/__keelson/media/)
KEELSON_DIRECTORY_TOKENAfter keelson apps directory enableDirectory API token used by the Identity SDK. Handle only on the server
  • NODE_ENV — Not set automatically. Declare "production" in env in keelson.yaml
  • External database connection details — Nothing is injected when db.mode: none. Pass them as secrets

These variables are used when running SDKs locally. They are not set on Keelson.

VariableDescription
KEELSON_FILES_DIRLocal storage location for the Files SDK (default: ./.keelson/files)
MEDIA_DIRLocal storage location for the Media SDK (default: ./media)
KEELSON_LOCAL_MODE=1Run the Identity SDK with dummy data
KEELSON_LOCAL_USER_ID / KEELSON_LOCAL_USER_EMAIL / KEELSON_LOCAL_USER_NAMEDummy user
KEELSON_LOCAL_WORKSPACE_ID / KEELSON_LOCAL_WORKSPACE_ROLE / KEELSON_LOCAL_APP_IDDummy workspace, role, and app

The former KEELSON_LOCAL_TENANT_ID and KEELSON_LOCAL_TENANT_ROLE names remain accepted as compatibility aliases. No removal date is set.

CLI commands

This is a list of keelson CLI commands. You usually do not need to memorize them all because an AI agent runs them through a Skill.

See the Quickstart to install the CLI. Run keelson upgrade to update it.

FlagDescription
--app <slug>Target app. If omitted, resolved from keelson.yaml in the current directory
--workspace <slug|name|id>Target workspace. Required when you belong to multiple workspaces
--jsonOutput the result as JSON. Use this from scripts and agents
--quietOutput a single line
--timeout / --retryTimeout and retry count for API calls
--no-browserDisplay the URL without opening a browser

When a command fails with --json, it returns {"error":{"code","message","hint","retryable"}} on standard output. Follow hint. If retryable is false, repeating the same command will not change the result.

The former --tenant flag and keelson tenants list command remain accepted as compatibility aliases. No removal date is set.

CommandDescription
keelson login [--no-browser]Log in through a browser and link this device
keelson logout [--all-devices]Delete the saved login credentials on this device and invalidate the server-side session. --all-devices logs out every device
keelson whoamiShow the current user and workspace
keelson workspaces list [--query <text>]List or search the workspaces you belong to
keelson doctor [--fix-path]Check PATH, login, and Skill status. --fix-path adds PATH to the shell configuration file
keelson install-agent <claude-code|codex|cursor> [--global]Install the Skill for an AI agent. With no argument and --yes, update all installed Skills
keelson upgradeUpdate the CLI to the latest version
keelson versionShow the version
keelson telemetry status|off|onShow, disable, or enable CLI usage telemetry
CommandDescription
keelson deployDeploy the app in the current directory and wait for completion
keelson deploy --new [--secrets-from-env-file <path>]Create an app and deploy it for the first time. Register values from an env file as secrets and exclude that file from the archive
keelson deploy --check [--json]Validate the configuration and display archive contents (excluded files and files that may contain secret values) without uploading
keelson deploy --ndjson --yesFor scripts. Stream progress as one JSON object per line and finish with {"result":"success"|"failed"}
keelson status [deploy_id]Show app or deploy status
keelson diagnose [deploy_id]Diagnose a failed deploy (with an ID), including stored startup logs when startup failed, or the running app (without an ID)
keelson deploys listShow deploy history
keelson rollback [deploy_id]Return to the previous successful deploy, or the specified deploy, without building

The NDJSON stream may include lines such as {"stage":"health_check","status":"progress",…} while a stage is running. Decide success or failure only from the line that contains result.

CommandDescription
keelson app curl <path> [-i] [--method] [--data] [--form] [--header]Send one authenticated request to the app. See Verify a deploy
keelson preview [--ttl] [--allow-writes] [--json]Issue a short-lived authenticated ticket
keelson app info / start / stop / restartShow app information, start, suspend, or restart the app
CommandDescription
keelson logs app <slug> [--mode live|history] [--severity] [--previous]Show the app’s standard output and standard error
keelson logs cron <slug>Show scheduled job execution logs
keelson logs accessShow access logs
keelson logs deploy [deploy_id] [--limit] [--since]Show deploy progress logs and stored failure details, including startup logs when the app exited during startup. Build output for a successful deploy is not retained, so the command explains when no logs are available

Pass values through standard input or an env file, not as command arguments.

CommandDescription
keelson secrets listShow configured names and update times
echo -n "<value>" | keelson secrets set <KEY> [--apply]Set one value. --apply also redeploys the app
keelson secrets set --from-env-file <path> [--apply]Set multiple values from an env file
keelson secrets unset <KEY>Delete a secret

Secrets take effect on the next deploy, or when you use --apply.

CommandDescription
keelson apps listList apps in the workspace
keelson apps duplicate <slug> --name <new> [--copy-secrets]Duplicate an app
keelson apps rename <new_slug>Change the slug (the URL does not change)
keelson apps delete [slug]Delete an app. Browser approval is required
keelson quotasShow plan limits and usage
CommandDescription
keelson crons list [--include-disabled]List jobs
keelson crons trigger <name> [--wait]Run a job now
keelson crons enable <name> / disable <name>Enable or disable a job
keelson crons runs list [--cron <name>]Show execution history
keelson dev cron run <name>Run a cron from keelson.yaml locally
CommandDescription
keelson db apply [file.sql]Run SQL against Managed SQLite. Browser approval is required
keelson snapshots listList restore points and in-flight exports
keelson snapshots createCreate a manual snapshot
keelson snapshots download <id> -o <file>Download a backup
keelson snapshots export --at <RFC3339>Export data at a specific point in time (PITR)
keelson snapshots restore <id>Restore a snapshot. Browser approval is required. The restore can be undone within 72 hours
keelson snapshots restore-statusCheck the progress and result of the latest restore
keelson db revoke-tokensRevoke DB authentication tokens (destructive)
keelson db recover / credential-statusRecover credentials and show their status
CommandDescription
keelson groups listList groups
keelson groups create <key> [--name] [--description] [--if-not-exists]Create a custom group
keelson groups members list|add|remove <key> <email>...List, add, or remove members
keelson access show --app <slug>Show the app’s view and manage groups
keelson access set --app <slug> [--view <key>...|--view none] [--manage <key>...]Set permissions. The manage group list cannot be empty
CommandDescription
keelson apps tokens create --name <n> --scope <api|webhook> [--allowed-ip <cidr>]Issue an app token (shown only once)
keelson apps tokens list / rotate / deleteList, rotate, or revoke tokens
keelson apps directory enableIssue a Directory API token and register it as KEELSON_DIRECTORY_TOKEN (redeploy required)
keelson domains add|list|verify|remove <hostname>Manage custom domains (Team)
keelson dev email --to ... --subject ...Send a test inbound email to the local app

Deleting an app, running SQL, and restoring a snapshot cannot be completed with the CLI alone. The command displays an approval URL and exits with confirmation_required. After you approve the operation in a browser, it runs on the server. You do not need to run the command again. In an interactive terminal, use --wait to wait for completion. Approval expires after 15 minutes.

VariableDescription
KEELSON_TOKENToken used instead of login (for CI)
KEELSON_NO_UPDATE_CHECK / KEELSON_NO_SKILL_SYNCDisable update checks or automatic Skill updates
KEELSON_NO_TELEMETRYDisable CLI usage telemetry
KEELSON_CONFIG_DIRConfiguration file location

Error codes

Errors are returned with a code and a hint that explains how to resolve them. You can pass them directly to an AI agent for resolution. When a workspace-named code replaces an older tenant-named code, error.aliases contains the old code for compatibility; no removal date is set. This page groups a human-readable list by where each error occurs.

These findings are reported before upload and can also be checked with keelson deploy --check. Errors stop the deployment; warnings do not. A missing dependency manifest is an error for Go, but only a warning for Python and Node.js.

CodeCauseResolution
missing_dependency_manifestNo dependency manifest for the selected runtimeFor Go, add go.mod to the project root. For Python or Node.js, add a manifest if the app uses external libraries; no dependency manifest is required for a standard-library-only app
command_installs_dependenciescommand contains pip install / npm install / go buildRemove it and leave only the start command
runtime_command_mismatchThe languages of runtime and command do not match (for example, node with python-slim)Correct either one
assets_dir_emptyassets.dir is missing or empty, or the path uses the wrong letter caseRun the build and check the path
assets_fallback_missingThe assets.fallback file is not in assets.dirCheck the build output
db_wiring_fail (warning)db.mode does not match the database connection in the codeSwitch to a libSQL client that reads KEELSON_DB_URL
file_wiring_fail (warning)State files or uploads are written to local diskReplace this with the Files / Media SDK
CodeCauseResolution
invalid_keelson_configInvalid YAML, a db.mode other than libsql / none, db.migrate used with none, env beginning with KEELSON_, a path under /__keelson, an unknown runtime, more than 10 crons, or another configuration errorCorrect the issue described in message
config_missing_fieldA required field such as slug, runtime, or db is missingAdd it
env_value_not_stringAn env value or key is not quoted, a key is duplicated, or a merge key is usedQuote every value and key
description_too_longdescription is longer than 300 charactersShorten it
workers_not_supportedworkers: is presentRemove it and use crons
DB_DATABASES_REMOVEDdatabases: is presentRemove it. Use db.mode: libsql for persistent data
SQLITE_LOCAL_SQLITE_INVALID_PATHA db.local_sqlite.paths value is outside /tmp/Use /tmp/ or :memory:
CRON_TIMEOUT_EXCEEDS_STANDARD_LIMITA crons[].timeout value exceeds 600 secondsReduce it or split the operation
plan_cron_limit / plan_cron_min_interval / plan_schedule_timeoutThe number, interval, or timeout of crons exceeds the plan limitReduce it or upgrade the plan
reserved_path_conflictThe build output has an __keelson directory at its rootRename the directory
ARTIFACT_SQLITE_FILE_DRIVERA file-based SQLite client such as sqlite3 or better-sqlite3 was detectedMigrate to a libSQL client. For temporary use, declare it in db.local_sqlite
ARTIFACT_EXEC_NODE_CRON / _APSCHEDULER / _BACKGROUND_TASKS / _SET_INTERVAL (notice)An in-app scheduler was detectedMove it to crons

These are shown by keelson status / keelson diagnose.

CodeCauseResolution
deploy.config.invalidInvalid keelson.yaml contentCorrect the reported key and deploy again
deploy.config.secret_missingA secret declared in secrets.required is not setSet every secret with keelson secrets set <KEY> (the value is read from standard input), then deploy again
deploy.artifact.invalidArchive problem such as a missing lockfile or detection of a blocked itemCorrect the reported issue
deploy.build.failedBuild failed because dependency resolution failed, a build script produced an error, or the build exceeded 900 secondsCheck the deploy logs
deploy.build.image_too_largeThe image is too largeRemove unnecessary dependencies or files
deploy.runtime.start_failedThe app exited during startup or the container did not reach the running stateRead the stored startup logs with keelson logs deploy <deploy_id>, then verify PORT and 0.0.0.0
deploy.runtime.health_check_failedThe app may have exited during startup, or / (or health.path) returns 5xx or does not respond within 120 secondsRead the stored startup logs with keelson logs deploy <deploy_id>, then check the listening port and health.path
deploy.runtime.migration_faileddb.migrate exited with a nonzero status. The previous revision continues serving trafficCorrect the migration and deploy again
deploy.verify.failedA verify path cannot be fetched, or the Content-Type of JavaScript or CSS does not matchCheck the path and build output
deploy.plan.limit_exceededA plan limit was exceededReduce usage or upgrade the plan
deploy.plan.cron_count_exceededThe app declares more crons than the plan allowsReduce the number of crons or upgrade the plan
deploy.plan.cron_interval_too_shortA cron fires more often than the plan’s minimum intervalIncrease the interval or upgrade the plan
deploy.plan.schedule_timeout_exceededAn explicit crons[].timeout exceeds the plan’s ceilingLower the timeout or upgrade the plan
deploy.app.suspendedThe app is suspendedResume it before deploying
deploy.app.operation_in_progressThe deploy could not start because another operation was in progress for this appWait for the other operation to finish, then deploy again
deploy.platform.temporarily_unavailableTemporary outageWait briefly and retry
deploy.platform.errorPlatform errorContact support with the deploy_id
deploy_in_progress (409)Another deploy or app operation is in progressWait for the operation to finish, then run the same command again
build_rate_limit_exceeded / build_guardrail_exceeded (429)The limit for build count, build time, concurrent builds, or consecutive failures was exceededThe message states when the restriction clears. For consecutive failures, fix the cause and wait until the stated time. The trial build allowance is cumulative and does not reset by waiting; change plans in that case
saved_config_unsupported (409)The keelson.yaml for a redeploy or rollback does not conform to the current schemaCorrect it and perform a normal deploy
region_not_available (422)The selected region is not accepting new applicationsCreate the app in Japan (jp-tokyo) or wait for the region to open
region_unknown (422)The selected value is not a Keelson logical region keyUse jp-tokyo (Japan) or us-oregon (US West), not a cloud-provider region name
region_immutable (422)A deploy tried to change an existing application’s placement regionRemove --region and deploy again, or create a separate application
CodeMeaningResolution
not_logged_inNot logged inRun keelson login
multiple_workspaces / workspace_ambiguous / workspace_not_found / no_workspacesThe workspace cannot be identifiedUse --workspace <slug>. Search with keelson workspaces list --query
app_not_foundThe app was not foundCheck the slug. For a new app, use --new
app_manage_requiredThe app exists, but you do not have manage permissionAsk a current manager to add you to a group with manage permission. Workspace OWNERs and ADMINs can add themselves with keelson groups members add <group> <email>
app_deletingThe app is being deletedWait for completion
forbiddenYou do not have permission. CONSOLE_ONLY_OPERATION identifies an operation limited to the consoleCheck the role and app permissions
confirmation_requiredThe operation requires browser approvalOpen the displayed URL and approve it. Do not run the command again
confirmation_expired / confirmation_rejectedApproval expired after 15 minutes or was rejectedRun the command again
deploy_failedDeploy failedRun keelson diagnose <deploy_id>
sql_failedSQL in db apply failed and was rolled backCorrect the SQL
last_manage_binding_removed / no_effective_manager / self_lockoutaccess set would leave the manage group list empty or leave no effective managerRetain a manage group. To remove yourself, use --allow-self-lockout
transientTemporary network errorRetry with --retry
skill_outdated (meta.skill_outdated)The Skill is older than the CLIRun keelson install-agent --yes
DisplayStateResolution
Login screenNot logged inLog in with an account registered in the workspace
”Access denied” (403)The user is not a member, is blocked, or lacks app view permissionAsk an administrator to register the member or grant permission
”This application is only available from approved networks” (403)Rejected by IP controls. The page shows the source IPAsk an administrator to add an access source for that IP
”The app cannot start right now because no app slot is available.” (503)Limit on the number of apps that can run concurrentlyWait about 5 minutes. Consider priority startup or a plan change
”Starting the app”Starting from sleepWait a few seconds; the page reloads automatically
”This application has been suspended” (503)The app is suspendedAn administrator must resume it
”App not found” (404)The URL is wrong or the app was deletedCheck the URL
410 GoneThe app URL changed. The old URL is reserved for 30 daysDirect users to the new URL
504The app did not respond within 120 secondsShorten the operation or move it to a cron
501WebSocket is not supportedUse SSE or polling

When the same situation occurs through an API or XHR request, the response has the same status as JSON and the x-keelson-error header contains the reason, such as ip-restricted, app-view-denied, capacity-full, or app-suspended.

External integrations (app tokens / Webhook)

Section titled “External integrations (app tokens / Webhook)”

The detailed reason is returned in X-Keelson-Auth-Error; for compatibility, the JSON detail remains machine-forbidden or webhook-forbidden for IP allowlist denials.

machine-forbidden and webhook-forbidden intentionally group a missing app, a token belonging to another app, and a route that disappeared so callers cannot use the response to determine whether an app exists.

X-Keelson-Auth-ErrorCause
invalid-app-tokenThe token is invalid or revoked
machine-scope-deniedThe token scope (api / webhook) does not match the path
machine-endpoint-deniedThe path or method is not declared in auth.endpoints
machine-forbidden / webhook-forbiddenThe app is missing, the token belongs to another app, or the route disappeared
machine-ip-not-allowedThe request came from outside the app token’s allowed IP ranges
invalid-webhook-secretThe Webhook secret does not match
webhook-scope-deniedThe token does not have the webhook scope
webhook-ip-not-allowedThe request came from outside the Webhook token’s allowed IP ranges
webhook-endpoint-blockedThe path is reserved by the platform, such as /api/webhooks/email

CLI usage telemetry

Keelson uses CLI usage telemetry to understand which workflows succeed, find reliability problems, and improve the CLI. It is enabled by default.

  • Subcommand name
  • Exit code and, when available, the published failure code
  • Command duration
  • Names of flags used, but not their values
  • Whether the command ran in an interactive terminal (TTY)
  • CLI version
  • Type of agent that launched the CLI; it is recorded as unknown when it cannot be determined
  • For list commands, only whether the result contained zero, one, or multiple items
  • A unique identifier for each event, used to discard duplicates
  • The date and time the command ran
  • A session identifier. This is a random value created on the device and is replaced after 30 minutes without a recorded command. Stored event rows do not contain account, workspace, or app identifiers. Events are sent using your signed-in credentials, and submission counts are tracked per account, so the transmission itself is not anonymous.
  • Your app’s source code or the contents of deployment archives
  • The contents of logs, standard output, or standard error
  • Environment variable values or values passed to flags
  • File paths, workspace names, or app names
  • The contents of keelson.yaml
  • Conversations with an AI agent

To save your preference, run:

Terminal window
keelson telemetry off

To disable telemetry through the environment instead, set:

Terminal window
KEELSON_NO_TELEMETRY=1

The environment variable takes precedence over the saved preference. While it is set, keelson telemetry on cannot enable telemetry. Disabling telemetry deletes pending event records, and you can check the current state at any time with keelson telemetry status.

Disabling telemetry stops event recording and sending and removes the X-Keelson-Client header. The regular User-Agent header still includes the CLI version as keelson-cli/<version>.

Immediately before the first telemetry send, the CLI displays a one-line notice when it is running in an interactive terminal. It does not display this notice in non-interactive automation.

keelson feedback is a separate mechanism for sharing feedback you provide. It sends only when explicitly run and feedback sharing is enabled. Feedback sharing is disabled by default. Turning CLI usage telemetry on or off does not change the feedback-sharing setting.