keelson.yaml
Overview
Section titled “Overview”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.
Minimal example
Section titled “Minimal example”slug: my-appruntime: python-slimcommand: "python app.py"db: mode: noneTop-level fields
Section titled “Top-level fields”| Field | Type | Required | Default | Description |
|---|---|---|---|---|
slug | string | Yes | — | Application identifier; used when the application is created |
workspace | string | No | null | Default 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 |
description | string | No | null | A short description of the application, up to 300 characters |
icon | string | No | null | Image shown as the app’s icon in the console; a path relative to keelson.yaml. See icon |
type | string | Conditional | null | App type; only web is accepted. Required for static sites and SPAs without a start command |
runtime | string | Yes | — | Execution environment; see Supported runtimes |
command | string | list | Conditional | — | Start command; optional for scheduled jobs only, or static sites with type: web and assets |
env | map | No | {} | Non-secret environment variables |
db | object | Yes | — | Database and local SQLite policy |
crons | list | Conditional | [] | Scheduled jobs; required without command unless this is a static site with type: web and assets |
tasks | list | No | [] | Background tasks that the app enqueues with the SDK; see tasks |
mcp | object | No | null | Tools that AI clients use over MCP; see mcp |
storage | object | No | {} | Compatibility block that accepts only the deprecated disk_id field |
assets | object | No | null | Static asset delivery configuration |
health | object | No | null | Health check path used during deployment |
secrets | object | No | {} | Secret declarations and requirements |
auth | object | No | null | Endpoints that bypass interactive Keelson authentication |
verify | string | list | No | [] | Extra paths checked after a deployment becomes healthy |
region | string | No | null | Logical 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, tasks for work that runs after a request, 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.
This includes leading and trailing whitespace and letter case.
Unknown top-level keys such as build, port, and scaling, and unknown keys within objects, are rejected. This is not an extensible format for custom fields. null is rejected for crons, secrets, and storage; to omit one, leave out the key entirely.
Writing string values
Section titled “Writing string values”When in doubt, quote string values. This ensures the CLI and API read the same string.
The following strings require quotes or a block scalar (| / >). Unquoted values are rejected with string_value_not_portable:
- Values beginning with a digit, such as
slug: "123-app". - Values beginning with a non-ASCII character, such as
description: "Équipe commerciale". - Values beginning with
-, including command arguments:command: [python, app.py, "--port", "8080"]. - Words YAML interprets as booleans or null:
yes,no,on,off,true,false,null, and the case variants in Environment variable key constraints. - Values starting with other symbols not covered by the exceptions below, such as
":memory:".
Quotes may be omitted in the following cases, except for the boolean and null words above:
| Value type | Unquoted form allowed | Examples |
|---|---|---|
Identifiers such as slug, runtime, or secret names | Starts with an ASCII letter or _ | my-app, python-slim, OPENAI_API_KEY |
| Commands, descriptions, paths, and other strings | Starts with an ASCII letter, _, /, ./, or ../ | python app.py, /health, ./app |
A colon alone does not require quotes: description: https://example.com is valid unquoted. YAML syntax can still require quotes, for example around strings containing : (a colon followed by a space). Quoting does not change field-specific path or length constraints.
env values are an exception: every value requires quotes or a block scalar, regardless of its first character. See env.
The app identifier. It forms part of the app URL assigned at creation: https://<workspace-slug>--<slug>.keelson.run.
slug: my-appRules:
- 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, andwww.
public_slug is a deprecated compatibility field. It is ignored because the
public URL is derived from the application host.
description
Section titled “description”An optional one- or two-sentence explanation of who the application is for and what it does.
description: "Daily and weekly reports for the sales team, with automatic weekly summaries."After trimming surrounding whitespace, the value must be at most 300 characters (Unicode code points). Exceeding this limit fails deployment. The description appears in the console and is applied only if the app’s description is still empty. Deployment does not overwrite a description edited in the console.
An optional image that the console shows as the app’s icon in the app list and app header. Apps without an icon show their initial letter, and apps created from a template show the template’s icon.
icon: ./assets/app-icon.png- The path is relative to the directory that contains
keelson.yaml, not to the directory you run the command from. It must stay inside the project: absolute paths, paths that leave the project with.., backslashes, and symbolic links are rejected. - The image is uploaded with your source, so it must not be excluded from the archive (see Archive contents). For a static site without
command, put it insideassets.dir. - Accepted images: PNG, JPEG, or WebP, at most 256 KB, and 32 to 2048 pixels on each side. The format is detected from the file’s contents, not its extension. SVG is not accepted.
keelson deploy(including--check) checks the image before it creates the app or uploads anything, and stops with the path and the reason:icon.path(missing, unreadable, symbolic link, or not uploaded),icon.format,icon.size,icon.dimensions, oricon.corrupt(the image header cannot be read).
The icon is applied when the deployment succeeds, and the CLI prints one Icon: line when it finishes (icon_outcome in --ndjson output):
icon_outcome | Meaning |
|---|---|
applied | The image from keelson.yaml is now the app’s icon |
unchanged | The app already uses this image |
kept_console_icon | An icon uploaded in the console is kept; keelson.yaml does not replace it |
kept_cleared | The icon was removed in the console, so deployments do not add it back |
An icon set or removed in the console always takes precedence over keelson.yaml. To use the image from keelson.yaml after that, upload it in the app’s Settings in the console. Removing icon from keelson.yaml does not remove the app’s icon.
App icons are being rolled out. If a deployment is rejected with icon is not available yet, remove icon from keelson.yaml and deploy again.
The app type. Required for static sites and SPAs without a start command; optional otherwise.
type: webThe 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: webcannot be combined withcrons.- Omit
typefor apps that combine a web app and scheduled jobs.
runtime
Section titled “runtime”The execution environment. Required even for static-only deployments, but not used to build them. Static sites serve assets.dir exactly as built locally or in CI.
Supported runtimes
Section titled “Supported runtimes”| Runtime | Language | Intended use |
|---|---|---|
python-slim | Python | Lightweight applications, APIs, and text processing |
python-media | Python | Image and video processing, including Pillow, opencv, and ffmpeg-related libraries |
node-slim | Node.js | Lightweight Node.js applications |
node-media | Node.js | Image processing, including sharp |
go-slim | Go | Lightweight Go applications |
go-media | Go | Media-processing Go applications |
Start with a -slim runtime unless the application needs the extra media
libraries included in a -media runtime.
Dependency installation
Section titled “Dependency installation”Keelson installs dependencies during the build for container and hybrid deployments. Static sites (type: web and assets, without command) skip this step. By default, only files under assets.dir and keelson.yaml are uploaded for static sites; see Archive contents. Use command only to start the app. Including pip install, npm install, or go build causes the CLI preflight check to stop deployment with command_installs_dependencies.
| Runtime | Detected files | Build step |
|---|---|---|
python-* | requirements.txt | python -m pip install --user -r requirements.txt |
python-* | pyproject.toml with [project] or [build-system], only if requirements.txt is absent | python -m pip install --user . |
node-* | package-lock.json / package.json | npm ci when a lockfile is present, or npm install, followed by npm run build --if-present |
go-* | go.mod / go.sum | go mod download, then go build -o /workspace/app . (start it with command: "./app") |
Build constraints:
-
If both
requirements.txtandpyproject.tomlexist, onlyrequirements.txtis used. Dependencies and local packages declared only inpyproject.tomlare not installed; include everything required inrequirements.txt. -
An installable Python
pyproject.tomlmust be accompanied by a recognized lockfile:requirements.txt,poetry.lock,uv.lock, orPipfile.lock. -
Node.js builds use npm only. Apps declaring external packages, including development, optional, or peer dependencies, require
package-lock.json. Apps without external dependency declarations may omit it. Ifpackage.jsonexists without a lockfile, the build runsnpm install. If onlypnpm-lock.yamloryarn.lockexists, preflight stops deployment withlockfile_unsupported; these files may remain alongsidepackage-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+sshdependencies, and authenticated installs fail. -
Go builds use
CGO_ENABLED=0. -
The build target is
linux/amd64only. -
Secrets are not available during the build.
-
The build execution time limit is 600 seconds.
command
Section titled “command”The command executed when the application starts. Use either a shell string or an argument list.
# String form: executed with /bin/sh -ccommand: "python app.py"
# List form: executed directlycommand: - python - app.pyRules:
- Supply
command,crons, or a static site configured with bothtype: webandassets. - A cron-only application does not need a top-level
command. - A static-only
type: webapplication withassetsdoes not need a top-levelcommand. - Do not include dependency installation or build steps. Keelson runs them automatically during the image build.
- Empty strings and empty lists mean no start command. An ordinary web app requires a command.
crons[].commandcannot be empty.
Defines non-secret environment variables as key-value pairs. All values are strings.
Keelson sets PORT at runtime. A value declared in env is ignored.
Use quotes or a block scalar (| / >) for every value. 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. For example, K: 0o123 can become either "83" or "0o123". Quoting guarantees that both paths deploy the same string.
Environment variable key constraints
Section titled “Environment variable key constraints”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.
Declare sensitive settings in secrets and set their values through the console or CLI. When a secret has the same name as an env entry, the secret takes precedence; changing that env value has no effect.
Selects how the application handles relational data. The db block and its
mode field are required.
db: mode: libsql| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mode | string | Yes | — | libsql or none; legacy turso is normalized to libsql |
migrate | string | No | null | Migration command run during deployment; valid only with libsql |
auto_adopt | boolean | No | false | Accepted for compatibility but currently does nothing; it does not adopt an existing database |
local_sqlite | object | No | null | Explicit declaration of regenerable, ephemeral local SQLite files |
Database modes
Section titled “Database modes”mode | Meaning |
|---|---|
libsql | Keelson provisions Managed SQLite dedicated to the app and injects KEELSON_DB_URL / KEELSON_DB_AUTH_TOKEN (aliases: TURSO_DATABASE_URL / TURSO_AUTH_TOKEN) |
none | Keelson 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.
No mode makes file SQLite persistent (/data does not exist and cannot be created by the app). Code using file SQLite clients such as sqlite3 or better-sqlite3 is detected during deployment and rejected unless local_sqlite is declared.
Database migrations
Section titled “Database migrations”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 migrate.py"- Valid only with
mode: libsql; use a string. - Runs only for container and hybrid deployments. Static sites have no container, so a declared migration does not run.
- Runs in the deployment image after the candidate revision passes its health check and before traffic switches. App startup code must work with the schema before migration.
- A nonzero exit code fails the deployment with
deploy.runtime.migration_failed, and the previous revision remains active. - Runs on every deployment, so make it idempotent, for example with
CREATE TABLE IF NOT EXISTSandON CONFLICT DO NOTHING.
Ephemeral local SQLite
Section titled “Ephemeral local SQLite”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"| Field | Type | Required | Constraint |
|---|---|---|---|
policy | string | Yes | Must be ephemeral |
paths | list of strings | Yes | Non-empty; each entry must be under /tmp/ or be :memory: |
reason | string | Yes | Non-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 description: "Delete expired sessions" schedule: "0 3 * * *" command: "python cleanup.py" timeout: 60 enabled: true| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Unique job name, 1–63 lowercase letters, digits, or hyphens |
schedule | string | Yes | — | Valid five-field cron expression |
command | string | list | Yes | — | Command to execute |
timeout | integer | No | 300 | Timeout in seconds, from 1 through 600 |
enabled | boolean | No | true | Whether Keelson schedules the job |
description | string | No | — | One-line explanation of the job, shown in the console’s Scheduled jobs tab; see crons[].description below |
Rules:
- A configuration may contain at most 10 jobs. The plan may impose a lower limit.
- Job names must be unique.
crons[].descriptionis display only. Leading and trailing whitespace is removed, and an empty value counts as unset. A value longer than 100 characters after trimming is rejected withcron_description_too_long; it is not truncated. Changing it does not change what runs.type: webandcronscannot be combined.- A job runs in a separate instance; local files, including
/tmp, are not shared with the web app. Store shared state indb.mode: libsql. - 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 absolute timeout limit is 600 seconds. The default is 300 seconds, reduced to the plan limit if that is lower.
See Plans and limits for job counts, minimum intervals, and timeout limits by plan.
Cron expression examples
Section titled “Cron expression examples”| Expression | Meaning |
|---|---|
0 * * * * | At minute 0 of every hour |
*/15 * * * * | Every 15 minutes |
0 3 * * * | Every day at 03:00 |
0 9 * * 1-5 | Weekdays at 09:00 |
0 0 1 * * | The first day of each month at 00:00 |
* * * * * (every minute) is rejected because it is shorter than every plan’s minimum interval. See Scheduled jobs for execution behavior and design.
Declares background tasks. When the app’s code enqueues a task by name with the
SDK, Keelson runs the declared command once in a separate instance from the web
service and retries a failed attempt automatically. Unlike crons, a task is
never started by the clock. See Background tasks
for enqueueing, writing the command, and making it safe to run twice.
command: "python app.py"tasks: - name: generate-pdf description: "Generate the invoice PDF for an order" command: "python generate_pdf.py" timeout: 180 max_attempts: 5| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Task name, 1–63 lowercase letters, digits, or hyphens (same rule as crons[].name). Leading and trailing whitespace is removed and the name is lowercased, so Foo is accepted as foo |
command | string | list | Yes | — | Command to execute. A string runs with /bin/sh -c; a list runs directly |
timeout | integer | No | 300 | Time limit for one attempt in seconds, from 1 through 600 |
max_attempts | integer | No | 3 | Maximum number of attempts, including the first, from 1 through 5 |
description | string | No | — | One-line explanation of the task, shown in the console’s Background tasks tab; see tasks[].description below |
Rules:
- A configuration may contain at most 10 tasks on every plan.
- Task names must be unique, and a task cannot have the same name as a job in
crons(task_name_conflicts_with_cron). Names are compared after removing surrounding whitespace and lowercasing. - A configuration with tasks but neither
commandnorcronsis rejected withtasks_require_command_or_crons, because no code could enqueue the tasks. This includes a static site withtype: webandassetsbut nocommand. type: webandtaskscan be combined.tasks[].descriptionis display only. Leading and trailing whitespace is removed, and an empty value counts as unset. A value longer than 100 characters after trimming is rejected withtask_description_too_long; it is not truncated. Changing it does not change what runs.- Unknown keys are rejected. Concurrency cannot be declared; Keelson applies the plan’s value.
- The absolute timeout limit is 600 seconds. An explicit
timeoutabove the plan limit is rejected withplan_task_timeout; the default of 300 seconds is reduced to the plan limit if that is lower. - Each attempt receives one line of JSON on standard input with
attempt_no,payload,schema_version,task_id, andtask_name. Exit code 0 means success. A task runs in a separate instance; local files are not shared with the web app.
See Plans and limits for timeout limits by plan.
Declares the app’s tools so that AI clients such as Claude and ChatGPT can use
the app over MCP. When a tool is called, Keelson calls the app’s
POST /api/mcp/<name>. Keelson handles login, the consent screen, and the
exchange with the AI client. See Use your app from AI clients (MCP)
for writing handlers, connecting, and limits.
command: "python app.py"mcp: enabled: true instructions: "Look up stock levels and record incoming deliveries." tools: - name: search_items description: "Search stock by item name." access: read input: type: object properties: query: type: string required: [query] - name: create_arrival description: "Record one incoming delivery." access: write permission: manage input: type: object properties: item_id: type: string quantity: type: integer minimum: 1 required: [item_id, quantity]| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | Yes | — | true publishes the tools. false keeps the declaration but stops calls (existing connections are not revoked) |
instructions | string | No | null | Description of the tools as a whole, passed to the AI client. Up to 4096 characters |
tools | list | Conditional | — | The tools. Required when enabled: true. When written, 1–40 entries regardless of enabled |
file | string | No | — | Separate file holding instructions and tools; see mcp.file. Requires a recent Keelson CLI |
tools[].name | string | Yes | — | Tool name matching ^[a-z][a-z0-9_]{0,63}$ (starts with a lowercase letter; lowercase letters, digits, and _ only; not normalized). Must be unique. The handler path is POST /api/mcp/<name> |
tools[].description | string | Yes | — | Description shown to the AI client. 1–1024 characters, not only whitespace |
tools[].access | string | Yes | — | read or write. write tools are shown separately on the consent screen, and users can approve read-only access |
tools[].permission | string | No | view | view or manage. A manage tool can be used only by people with the app’s manage permission |
tools[].input | object | Yes | — | JSON Schema (2020-12) for the arguments. The top level must be type: object |
Rules:
- Only apps that run server-side code (with
commandand withoutassets) can declaremcp. Static sites, SPAs, and hybrid apps are rejected withmcp is only supported for container apps. - Unknown keys under
mcpandtools[]are rejected. The handler path is fixed, sotools[].endpointcannot be written. - Write
inputwith JSON-compatible values only: at most 32 levels deep and 64 KB as JSON. Quote dates and any key that would otherwise not be read as a string (yes,on, numbers, and so on). - Do not use
$ref,$dynamicRef,$recursiveRef, orx-mcp-headerininput.$refandx-mcp-headerare rejected at deploy;$dynamicRefand$recursiveRefpass deployment but every call fails. - YAML anchors, aliases, and merge keys (
<<) cannot be used undermcp. - Even without
mcp:,/api/mcpand paths beginning with/api/mcp/are reserved for MCP in apps that run server-side code (see Reserved URL paths).
mcp.file
Section titled “mcp.file”When there are many tools, instructions and tools can live in a separate
YAML file. keelson.yaml then contains only enabled and file.
command: "python app.py"mcp: enabled: true file: mcp.yaml# mcp.yaml (in the same folder as keelson.yaml)instructions: "Look up stock levels and record incoming deliveries."tools: - name: search_items description: "Search stock by item name." access: read input: type: object properties: query: type: string required: [query]- At deploy time, the Keelson CLI inserts the separate file’s contents into
mcpinkeelson.yamlbefore validating and uploading. Your localkeelson.yamlis not rewritten. - Only
instructionsandtoolsmay appear at the top level of the separate file (both optional).mcp.filecannot be combined withmcp.instructionsormcp.tools. - The path is relative to the folder containing
keelson.yamland cannot point outside it (including through..or symbolic links). The extension must be.yamlor.yml, and the file must be 4 MiB or smaller. - Errors inside the separate file are reported with the file name and location,
such as
mcp.yaml: tools[3].input .... - Only a recent Keelson CLI reads
mcp.file. Older CLIs reject the deployment withmcp contains unsupported field(s): file; runkeelson upgradeto get the latest CLI. It cannot be used through paths that bypass the CLI.
workers (not supported)
Section titled “workers (not supported)”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.
For work that runs in response to a request, declare it in tasks and enqueue
it with the SDK (Background tasks).
For a periodic drain of pending work, define a scheduled job in crons. Use an interval allowed by the plan; process pending items and exit on each run.
# Invalid: deployment is rejectedworkers: - name: drain command: "python worker.py" every: 10m
# Valid: use cronscrons: - name: drain schedule: "*/5 * * * *" command: "python drain.py" timeout: 120storage
Section titled “storage”storage accepts only the deprecated disk_id field. The value is ignored;
it does not enable persistent storage or automatic synchronization of /data.
Remove this block if it remains in your configuration.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
disk_id | string | No | null | Deprecated and ignored identifier; 1–32 lowercase letters, digits, or hyphens |
assets
Section titled “assets”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| Field | Type | Required | Default | Description |
|---|---|---|---|---|
dir | string | Yes | — | Asset directory relative to the project root |
static_dir | string | No | null | Deprecated alias of dir; if both are present, they must match |
fallback | string | Conditional | null | SPA fallback, such as index.html, relative to dir. Required for hybrid apps with type: web |
api | string | No | null (effective value /api in hybrid apps) | Path prefix forwarded to the backend. Defaults to /api when both command and assets are present |
In hybrid apps, requests under api go to the backend; other requests serve static assets. The default prefix is /api; set this field only to use a different prefix.
Rules:
dircannot contain...apimust begin with/, cannot be/itself, and cannot be under/__keelson.apirequires a top-levelcommand.- A hybrid app with
type: web,assets, andcommandrequiresfallback. Iftypeis omitted, configuration validation allowsfallbackto be omitted. Set a fallback such asindex.htmlwhen serving an SPA.
Archive contents
Section titled “Archive contents”Static sites (type: web and assets, without command) upload only files under assets.dir and keelson.yaml by default. Container and hybrid apps also include source needed for the build. The exclusions below apply to both.
Avoid assets.dir: . and equivalent paths such as ./. They make the project root the public asset directory, potentially exposing every file in the archive. The CLI warns before deployment but does not reject this setting.
Automatically excluded files
Section titled “Automatically excluded files”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 the file selected by --secrets-from-env-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.
Customizing exclusions with .keelsonignore
Section titled “Customizing exclusions with .keelsonignore”Create .keelsonignore in the project root for project-specific exclusions. .gitignore is not read.
| Syntax | Meaning |
|---|---|
| One pattern per line | Exclude matching files or directories; the last matching line wins |
A line beginning with # | Comment |
/, trailing /, *, ?, **, [abc], [a-z] | Gitignore-like path patterns |
Leading ! | Re-include matching files or directories |
Negation can deliberately re-include an env file such as !.env.production, but cannot override built-in exclusions such as .git, include .keelsonignore itself, or include the file selected by --secrets-from-env-file.
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.
Checking before upload
Section titled “Checking before upload”Run keelson deploy --check --json to inspect exclusions and files that may contain secrets.
- Detected preview credentials or app tokens block upload.
- If only webhook signing keys are detected, the CLI warns and continues.
- Files whose names suggest sensitive content also produce warnings. Warnings do not exclude files; add exclusions to
.keelsonignoreas needed.
--check also validates major settings locally. However, the API validates unknown keys inside assets, crons, auth, and secrets, cron expression validity, and verify path formats. A deployment can still fail with deploy.config.invalid after --check passes.
Check output and scan coverage
Section titled “Check output and scan coverage”| JSON field | What it reports |
|---|---|
archive.excluded | Excluded files |
archive.excluded_env_files | Excluded env files |
archive.secret_like_files | Files suspected of containing secrets based on their names or other indicators |
archive.embedded_credentials | Paths and types of credentials detected inside files; values are never shown |
archive.unscanned_files | Files whose contents were not scanned, for example because of size limits |
archive.unscanned_binary_count | Number of files classified as binary and not content-scanned |
Files over 16 MiB, binary files, and files excluded from the archive are not content-scanned.
secret_like_files includes names such as credentials.json and *.pem, plus existing files selected by an earlier --secrets-from-env-file deployment that are not excluded this time. This includes names such as prod-secrets.txt that do not follow env-file naming rules.
To identify previously selected files, the CLI records their project-relative paths in .keelson-config/previous-secrets-files.json. The record contains no secret values and is automatically excluded from uploads.
Browser caching
Section titled “Browser caching”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.
After redeploying a static site (edge-static / edge-spa), new files can take up to about 60 seconds to reach the edge (measured 1–57 seconds, median about 30). Wait 60 seconds before concluding a file is missing.
health
Section titled “health”Sets the deployment health check path. Use a lightweight endpoint if / is expensive or depends on the database.
health: path: /health- Defaults to
/. - Must start with
/, contain no.., not start with/__keelson, and be at most 2,048 characters. - HTTP 2xx, 3xx, and 404 pass; other 4xx and all 5xx fail. Because 404 passes, success proves the process started, not that the route exists.
- The probe goes directly to the candidate revision’s internal URL, bypassing the public host and authentication. Use verify to check public paths.
secrets
Section titled “secrets”Declares the app’s secrets and required combinations. Set values separately through the console, CLI, or an env file during deployment; do not put values here.
secrets: items: - name: OPENAI_API_KEY description: "OpenAI API key" - name: ANTHROPIC_API_KEY description: "Anthropic API key" required: - any_of: [OPENAI_API_KEY, ANTHROPIC_API_KEY] message: "Set at least one AI provider key"Secret definitions
Section titled “Secret definitions”| Field | Type | Required | Default | Description |
|---|---|---|---|---|
items | list | No | [] | Secret definitions |
items[].name | string | Yes | — | Environment variable name: starts with an uppercase letter or _, contains only A-Z0-9_, and is at most 255 characters. KEELSON_ names are reserved |
items[].description | string | No | "" | Human-readable purpose of the secret |
required | list | No | [] | Rules checked against configured secrets |
required[].any_of | list | Conditional | null | Nonempty list of names; at least one must be set |
required[].all_of | list | Conditional | null | Nonempty list of names; all must be set |
required[].message | string | No | "" | Guidance displayed when requirements are unmet |
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.
Names are normalized to uppercase when values are saved, while required matching is case-sensitive. If you declare name: api_key, its value is saved as API_KEY, so the requirement does not match. Use uppercase names from the start. Names with hyphens, such as MY-KEY, can be declared but cannot have values set.
For a new app, keelson deploy --new --secrets-from-env-file <path> creates the app, registers secrets, and starts its first deployment in one command. Changes to an existing app’s secrets take effect on the next deployment.
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/stripe - path: /api/external/status methods: - GETauth.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.
Paths use prefix matching: /api/external/status also matches /api/external/status-extra. Methods are case-insensitive; omitting them allows every method. methods only applies to /api/external/, not /api/webhooks/. Restrict webhook methods in app code.
Bypassing interactive login does not bypass Keelson authentication. Declaring an endpoint alone is insufficient; callers must supply Keelson-issued credentials.
| Path | Required credentials |
|---|---|
/api/external/... | App token with the api scope in Authorization: Bearer |
/api/webhooks/... | App token with the webhook scope in X-Webhook-Secret, or a URL registered with the external service in the form /api/webhooks/<secret>/... |
Issue tokens with keelson apps tokens create. See External integrations for URL construction. A standard Stripe webhook carrying only a Stripe signature, or a client carrying only an app-defined API key, is rejected before reaching the app.
Requests to these paths carry X-Keelson-User-Id as an empty string (the header is always set, empty when there is no interactive user). Treat an empty value as “no user”. In addition to Keelson credentials, verify webhook signatures or API keys in the app.
verify
Section titled “verify”Adds paths to deployment verification. By default, only / is checked.
verify: - /dashboard - /settingsUse this to check entry points that cannot be reached from /, such as pages accessible only through client-side routing.
- A single path may be a string or a one-item list.
- At most 20 paths are allowed after trimming whitespace and removing duplicates. Each must start with
/, must not start with//, and must not contain..path components or control characters. - Container and hybrid deployments fetch paths from the candidate revision through the public host. Static sites and SPAs resolve paths against the release manifest before publication.
- 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 inverify; 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.
region
Section titled “region”Optionally selects the placement region when creating a new application.
region: jp-tokyoUse a Keelson logical region key, not a cloud-provider region identifier:
| Key | Display name | New apps |
|---|---|---|
jp-tokyo | Japan | Available (default) |
us-oregon | US West | Not yet offered (an explicit selection is rejected) |
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 modes
Section titled “Deployment modes”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.
Static sites and SPAs also require type: web.
| Name | Raw deploy_mode | command | assets | fallback | Description |
|---|---|---|---|---|---|
| Web application | container | Present | Absent | — | Ordinary application deployment |
| Static site | edge-static | Absent | Present | Absent | Static files only |
| SPA | edge-spa | Absent | Present | Present | Static files with a fallback |
| Hybrid | hybrid | Present | Present | Required with type: web | Static files plus a backend API |
Cross-field validation
Section titled “Cross-field validation”Some fields are valid only in specific combinations.
| Rule | Requirement |
|---|---|
| A start command, scheduled jobs, or a static site is required | Supply command, crons, or both type: web and assets |
| Tasks need code that enqueues them | When tasks is present, supply command or crons (tasks_require_command_or_crons) |
| Task and job names are separate | A tasks[].name cannot match a crons[].name (task_name_conflicts_with_cron) |
| Static sites cannot use scheduled jobs | Combining type: web and crons fails configuration validation. Omit type when using scheduled jobs |
| Static sites have no container | db.migrate does not run for static sites or SPAs. Use a container or hybrid deployment for migrations |
workers is retired | Replace every workers declaration with tasks (work that runs after a request) or crons (time-triggered work) |
| Managed migration requires managed data | db.migrate requires db.mode: libsql |
| Local SQLite is disposable | db.local_sqlite.paths accepts only /tmp/ paths and :memory: |
mcp requires an app that runs server-side code | mcp can be written only with command and without assets (mcp is only supported for container apps) |
assets.api requires a backend | Set a top-level command before using an API prefix |
Hybrid deployments with type: web require a fallback | Set assets.fallback when type: web, assets, and command are present. This check does not apply if type is omitted |
Complete examples
Section titled “Complete examples”Web application with Managed SQLite
Section titled “Web application with Managed SQLite”slug: crud-appdescription: "An internal CRM for managing customers and deals"runtime: python-slimcommand: "python app.py"db: mode: libsql migrate: "python migrate.py"env: PYTHONUNBUFFERED: "1"Static SPA
Section titled “Static SPA”Run a local build such as npm run build to create dist before deployment. Keelson does not build a static site.
slug: marketing-sitetype: webruntime: node-slimdb: mode: noneassets: dir: dist fallback: index.htmlScheduled jobs only
Section titled “Scheduled jobs only”slug: daily-reportruntime: python-slimdb: mode: libsqlenv: PYTHONUNBUFFERED: "1"crons: - name: generate schedule: "0 9 * * *" command: "python report.py" timeout: 120Web application with a background task
Section titled “Web application with a background task”The web application enqueues generate-pdf with the SDK when it receives a
request. Keelson runs the task’s command in an instance separate from the web
application. See Background tasks.
slug: pdf-reportsruntime: python-slimcommand: "python app.py"db: mode: libsqlenv: PYTHONUNBUFFERED: "1"tasks: - name: generate-pdf command: "python generate_pdf.py" timeout: 180 max_attempts: 3Web application with a scheduled drain
Section titled “Web application with a scheduled drain”To run work after a request without background tasks, store it in the database and process it with a scheduled job. Work waits until the next run. To start work right after a request, use background tasks instead.
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-drainerruntime: python-slimcommand: "python app.py"db: mode: libsqlenv: PYTHONUNBUFFERED: "1"crons: - name: drain schedule: "*/5 * * * *" command: "python drain.py" timeout: 120Hybrid static files and backend API
Section titled “Hybrid static files and backend API”slug: photo-galleriesruntime: node-slimcommand: "node server.js"db: mode: libsqlenv: NODE_ENV: "production"assets: dir: dist fallback: index.html api: /apiGo application
Section titled “Go application”slug: my-go-appruntime: go-slimcommand: "./app"db: mode: noneKeelson builds the Go source for Linux and places the binary at ./app during
deployment.
API receiving webhooks
Section titled “API receiving webhooks”slug: payment-hooksruntime: node-slimcommand: "npm start"db: mode: libsqlenv: NODE_ENV: "production"secrets: items: - name: STRIPE_WEBHOOK_SECRET description: "Stripe webhook signing secret" required: - all_of: [STRIPE_WEBHOOK_SECRET]auth: endpoints: - path: /api/webhooks/stripe methods: [POST]Include an app token with the webhook scope in the URL registered with Stripe: https://<host>/api/webhooks/<token>/stripe. methods does not apply to webhooks; reject methods other than POST in app code if needed.
Common validation errors
Section titled “Common validation errors”| Cause | Resolution |
|---|---|
db or db.mode is missing | Declare db.mode as libsql or none |
The retired databases key is present | Remove 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 |
| No start command, scheduled jobs, or static site configuration | Add command, crons, or both type: web and assets |
workers is declared | Remove it; workers are rejected with workers_not_supported. Use tasks for work that runs after a request and crons for time-triggered work |
crons[].timeout is greater than 600 | Reduce the timeout to the platform ceiling or lower plan limit |
tasks is present without command or crons (tasks_require_command_or_crons) | Add the command of the web app that enqueues the tasks, or a job in crons |
A task has the same name as a scheduled job (task_name_conflicts_with_cron) | Rename the task or the job |
tasks[].timeout is greater than 600 (TASK_TIMEOUT_EXCEEDS_STANDARD_LIMIT) | Reduce it to 600 or less, and to the plan limit (plan_task_timeout), or split the work into smaller tasks |
mcp.enabled is true but mcp.tools is missing | Declare at least one tool, or move the tools into a separate file with mcp.file |
A static site, SPA, or hybrid app has mcp (mcp is only supported for container apps) | Remove mcp, or make the app a command-only app |
mcp.tools[].input contains $ref or x-mcp-header | Write the schema out in full. Do not use $dynamicRef or $recursiveRef either |
mcp contains unsupported field(s): file | The Keelson CLI is too old. Run keelson upgrade to get the latest version |
An env value is unquoted | Quote every value, including booleans and numbers |
An environment variable starts with KEELSON_ | Rename it; that namespace is platform-owned |
A hybrid deployment with type: web omits assets.fallback | Set 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 |
runtime and the language in command differ (runtime_command_mismatch) | Choose the matching runtime or fix the start command |
Build fails with reserved_path_conflict | Rename the __keelson directory at the root of the build output |