Docs
The CLI
Every command, what it talks to, and where your token is kept.
apps/cli is published to npm as @keelhosting/cli (binary keel). It is one client of the public API described in HTTP API: every command maps onto the generated @keel/api-client, nothing reaches the database or an internal service. The bundle is a single ESM file (dist/keel.js, built with esbuild) plus @napi-rs/keyring for the OS keychain.
npm install -g @keelhosting/cli
keel login
keel init
keel deploy
keel run --follow
Global flags
| Flag | Effect |
|---|---|
--json |
The command writes exactly one JSON document to stdout. Progress stays on stderr. Errors become {"error":{"exitCode","message",...}} on stderr. |
--org <id|slug> |
Sets the Keel-Organization header. Default: KEEL_ORG, then the profile's default organization (keel org use). |
--api-url <url> |
API origin. Default: the profile's apiUrl, then KEEL_API_URL, then https://api.keelhosting.dev. |
--profile <name> |
Configuration profile. Default: KEEL_PROFILE, then default. |
--api-key <key> |
Authenticate with an API key instead of the stored session. KEEL_API_KEY does the same for CI. |
Output conventions: results go to stdout, progress (docker output, build logs, sync notes) goes to stderr, errors are one line on stderr prefixed keel:. Secrets, registry passwords and tokens are never printed.
Configuration and credentials
The config file lives at $KEEL_CONFIG_DIR/config.json, else $XDG_CONFIG_HOME/keel/config.json, else ~/Library/Application Support/keel/config.json on macOS, %APPDATA%\keel\config.json on Windows and ~/.config/keel/config.json elsewhere. It is written with mode 0600 and holds profiles only:
{
"version": 1,
"profiles": {
"default": { "apiUrl": "https://api.keelhosting.dev", "issuer": "https://auth.keelhosting.dev", "clientId": "…", "projectId": "…", "defaultOrg": "acme" }
}
}
Tokens never touch the file. keel login stores { accessToken, refreshToken, expiresAt, issuer, clientId, scope } in the OS keychain under service keel, account <profile> (@napi-rs/keyring: macOS Keychain, Windows Credential Manager, Secret Service or keyutils on Linux). An expired access token is refreshed with the OIDC refresh grant before the request; a 401 from the API triggers one refresh and one retry, then the session is cleared and the command exits 3 with run keel login. A rejected API key exits 3 without touching the keychain.
Exit codes
| Code | Meaning | Examples |
|---|---|---|
| 0 | success | |
| 1 | generic failure | 403 insufficient_permission, docker exited non zero |
| 2 | usage error | unknown flag, invalid keel.yaml, 400 validation_failed, 413 |
| 3 | authentication required | no session, refresh failed, API key rejected |
| 4 | not found | 404 agent_not_found, unknown slug |
| 5 | conflict or plan limit | 409 slug_taken, 403 agent_limit_reached, 409 callbacks_not_in_plan |
| 6 | the run ended failed, timed_out or rejected |
keel run --follow, keel logs, keel runs steps --follow |
| 7 | network or server error | connection refused, 5xx, 501 not_configured, a non JSON response |
Commands
Session
| Command | Notes |
|---|---|
keel login [--no-browser] [--issuer <url>] [--client-id <id>] [--project-id <id>] |
RFC 8628 device authorization against Zitadel: discovers the endpoints from <issuer>/.well-known/openid-configuration (falls back to /oauth/v2/device_authorization and /oauth/v2/token), requests scopes openid profile email offline_access urn:zitadel:iam:org:project:id:<project>:aud urn:zitadel:iam:user:resourceowner, prints the verification URL and user code, opens the browser unless --no-browser, polls honoring slow_down, stores the tokens, then calls GET /v1/organizations: one membership becomes the default organization, several print a table and ask for keel org use <slug>. When the flags, the environment (KEEL_ISSUER_URL, KEEL_CLIENT_ID, KEEL_PROJECT_ID) and the profile leave any of the three unset, the CLI asks the public GET /v1/auth/config of the API for the issuer, the keel-cli client id and the project id (the API publishes them from AUTH_ISSUER_URL, AUTH_CLI_CLIENT_ID and AUTH_AUDIENCE). Explicit values win over discovered ones. The result is remembered in the profile. |
keel logout |
Deletes the keychain entry of the profile. |
keel whoami |
Profile, API url, subject or API key id, selected organization and the memberships. |
keel org list |
Organizations of the caller. |
keel org use <slug> |
Persists defaultOrg after checking the membership. |
Project
| Command | Notes |
|---|---|
keel init [--template agent|bot|api-node|api-python|api-go|static|node|python|webhook-bot] [--name <name>] [--slug <slug>] [--force] [--dockerfile] |
Without --template, a terminal (stdin and stdout are TTYs) shows a numbered picker between agent, bot, api-node, api-python, api-go and static with one line on each, accepts a number or a name (Enter picks agent), re-asks up to three times and exits 2 when stdin closes; a pipe or CI never prompts and scaffolds node. agent is the node scaffold under its product name. api-node, api-python and api-go write the same files as examples/api-node, examples/api-python and examples/api-go (a mode: service manifest with ingress: { port: 8080 }, a Dockerfile, the source, a test and, for Node, a package.json named after the slug), and static writes keel.yaml with a site: { slug, dir: public } block plus public/index.html, public/about.html, public/404.html and public/styles.css from examples/static-site; a test keeps each scaffold byte-equal with its example. For the agent templates it writes keel.yaml, Dockerfile, .dockerignore and a minimal entrypoint (agent.mjs + package.json, or agent.py + requirements.txt) that writes /keel/output/result.json. --template bot scaffolds a Telegram bot in Python (bot.py, standard library only) that long polls getUpdates, keeps its update offset in the workspace and needs no ingress; the manifest is mode: service with a 1 GB workspace, api.telegram.org as an egress host and the TELEGRAM_BOT_TOKEN secret. --template webhook-bot is the same bot behind a tiny HTTP server on port 8080 with ingress: { port: 8080 }; on start it registers <KEEL_INGRESS_URL>/telegram as the Telegram webhook (see Bots). The Dockerfile sets SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS, AWS_CA_BUNDLE, CURL_CA_BUNDLE, PIP_CERT and GIT_SSL_CAINFO to /keel/ca/bundle.pem, the path where the supervisor writes the Keel CA bundle (the gateway notes). The API scaffolds ship a Dockerfile, so keel deploy builds with it; delete it and keel deploy generates one from the project instead (Builds without a Dockerfile), which keel deploy --print-dockerfile shows without deploying. keel init --dockerfile skips the templates and the picker and writes the Dockerfile keel would generate for the project in the current directory (plus .dockerignore when missing); it refuses when a Dockerfile already exists. Refuses to overwrite existing files without --force. |
keel deploy [--file keel.yaml] [--image <ref>] [--via-build [--commit <sha>]] [--tag <tag>] [--skip-triggers] [--print-dockerfile] |
See below. --print-dockerfile prints the Dockerfile the local build would use (the project's own, or the generated one with the detection summary on stderr) and exits without deploying; --json gives { dockerfile, dockerfilePath, generated, detection }. |
keel.yaml:
name: Invoice Reconciler
slug: invoice-reconciler
mode: run
build:
dockerfile: Dockerfile
context: .
platform: linux/arm64
runtime: node
packageManager: pnpm
buildCommand: pnpm run build
startCommand: node dist/server.js
nodeVersion: 22
process:
command: ["node", "agent.mjs"]
resources:
cpu: 512
memoryMb: 1024
ephemeralStorageGb: 20
limits:
wallClockSeconds: 600
maxSteps: 200
maxTotalSeconds: 600
egress:
customHosts:
- erp.internal.example
- host: files.example.com
mode: tunnel
env:
LOG_LEVEL: info
secrets:
- OPENAI_API_KEY
- name: erp-token
env: ERP_TOKEN
capabilities:
checkpoints: false
trace:
capture: full
workspace:
mountPath: /keel/workspace
sizeGb: 1
ingress:
port: 8080
service:
kind: agent
replicas: 2
healthCheck:
path: /healthz
intervalSeconds: 10
timeoutSeconds: 5
unhealthyThreshold: 3
startPeriodSeconds: 30
trigger:
kind: schedule
schedule: { expression: "*/15 * * * *", timezone: Europe/Berlin }
triggers:
- kind: webhook
callbackUrl: https://app.example.com/hooks/keel
Everything except name and slug is optional. build.dockerfile has no default any more: when it is absent and the context has no Dockerfile, keel deploy generates one from the detected runtime, and build.runtime, packageManager, buildCommand, startCommand, nodeVersion, pythonVersion and goVersion steer that detection (Builds without a Dockerfile). The generated image listens on ingress.port (default 8080) through PORT. Values the file omits come from the manifest the platform derives from the plan (defaultManifestForPlan), or from the agent's latest revision once one exists. mode is run (default) or service; a service is kept up by the platform, restarted when it crashes and billed per replica-month, pro-rated by the hour. service.kind says what the service is and therefore what a replica costs: agent (default; an AI agent or bot, 49 EUR per replica-month on Business and Enterprise) or app (a regular backend such as an API, a web app or a worker, 19 EUR per replica-month). The api-node, api-python and api-go templates write kind: app, the bot and webhook-bot templates kind: agent. The kind lives on the agent (serviceKind on POST /v1/agents and PATCH /v1/agents/{id}), not in the runtime manifest, so changing it needs no new image; when the file declares service.kind, keel deploy aligns the agent with it before building and stops with a usage error (exit 2, no revision created) when the service is running with another kind (409 service_kind_locked), because a kind change only takes effect while the service is stopped: keel service stop <slug>, deploy, keel service start <slug>. A file without service.kind leaves the agent's kind alone (a new agent starts as agent), so a kind chosen in the dashboard survives deploys from a file that does not mention it. workspace mounts a persistent volume at mountPath (default /keel/workspace) of sizeGb (at most the plan's workspaceGbPerAgent); ingress gives the service a public https hostname that forwards to port. Both need the services (and ingress) plan features; both are kept from the previous revision when the file omits them and removed with an explicit workspace: null or ingress: null. service also sets the replica count of a service (replicas, default 1, clamped to the plan's maxReplicasPerService, each replica billed at the kind's price) and an HTTP healthCheck the supervisor probes on the agent container (path is required; port defaults to the ingress port and is required without ingress; the other fields default to the values shown). With a health check a replica only receives traffic once a probe passed, and it is restarted after unhealthyThreshold consecutive misses; without one, an ingress replica is ready when its port accepts a TCP connection. keel service scale overrides replicas without a new revision. See Services and APIs.
keel deploy does, in order:
- Reads
keel.yamland ensures the agent exists (GET /v1/agentsby slug,POST /v1/agentswhen missing, withcallbackUrlas the default callback and the file'smode). An existing agent whose mode differs is switched withPATCH /v1/agents/{id}; that is refused (409 mode_change_blocked, exit5) while a run is active or the service is running. - Gets an image registered:
--image <ref>:POST /v1/agents/{id}/imageswithcreateRevision: falseand a reference inside the tenant repository; the answer carries the resolvedimage.digestand repository.- default: resolves the Dockerfile (
build.dockerfile, else<context>/Dockerfile, else one generated into a temporary directory next to aDockerfile.keel.dockerignorewhen the context has no.dockerignore; the temporary directory is removed after the push),POST /v1/registry/push-credentials, thendocker build --tag <repositoryUri>:<tag> --file <dockerfile> [--platform <platform>] <context>,docker login <registry> --username <user> --password-stdin(the password only travels on stdin),docker push, thendocker logout <registry>whether or not the push succeeded so the short-lived credential does not linger in the Docker credential store, the digest is parsed from the push output andPOST /v1/agents/{id}/imagesregisters<repositoryUri>@<digest>without a revision. Docker output streams to stderr. --via-build:POST /v1/agents/{id}/buildsfrom the connected repository, followsGET /v1/builds/{id}/logsuntilGET /v1/builds/{id}is terminal. The read is passive: the API's build poller (REAPER=inprocesslocally, the worker in production) is what moves the build along, so a deployment without a poller never finishes.
- Assembles the runtime manifest from
keel.yamlon top of a base: the agent's latest revision when it has one, otherwise the plan defaults fromGET /v1/organizations/me/plan(defaultManifestForPlanwith the plan limits, features and the current verified endpoint list version). It validates the result withruntimeManifestSchemaand callsPOST /v1/agents/{id}/revisions, so onekeel deployproduces exactly one revision. Fields the plan clamped are reported (resources.memoryMb clamped by the plan from 4096 to 1024). - Syncs the triggers declared in the file: one trigger per declared kind is created, updated (schedule or enabled flag) or left alone. A new webhook trigger prints its secret once.
- Prints the revision id and the egress summary:
Egress: deny-all, allowlist v12, 1 custom host. The--jsondocument carriesdetectedRuntime(node 22,python 3.12 (fastapi),go 1.23, ornullwhen a Dockerfile was used). For a service agent it adds one line: a running service is restarted by the platform on the new revision (a rolling restart, watch it withkeel service status <slug> --follow), a stopped one needskeel service start. The--jsondocument carriesagent.mode,agent.serviceKind,agent.serviceKindChangedandservice: { state, rollingRestart }; the progress line names the kind (created agent orders-api (...) in service mode as app,now billed as app).
Runs
| Command | Notes |
|---|---|
keel run [agent] [--payload <json|@file|@->] [--callback-url <https url>] [--follow|--no-follow] |
POST /v1/agents/{id}/runs. [agent] is a slug or id, default the slug in ./keel.yaml. --follow is the default in a terminal and streams GET /v1/runs/{id}/events, rendering 01 model bedrock.eu / anthropic.claude-sonnet 1.84s ok per step and ending with run <id> failed in 6.40s (tool_output_invalid); container stdout and stderr lines arrive on the same stream as log events and are printed dimmed underneath the step lines as 02:14:07.001 stdout text; exit 6 when the run ends failed, timed out or rejected. --no-follow prints the run id only. With --json the document is { run, steps, logs }. |
keel runs [agent] [--status <status>] [--limit <n>] [--follow [--interval <s>]] |
GET /v1/runs, newest first; --follow polls and prints new runs (not combinable with --json). |
keel runs get <run-id> |
Run plus latestAttempt. |
keel runs steps <run-id> [--follow] [--expand <sequence> [--kind input|output]] |
The step trace; --expand fetches the presigned payload URL and prints the (server side redacted) payload. A step whose bodies passed the plan's trace retention window prints payload expired: the input and output body passed the plan trace retention window under its line, and --expand on that side exits 4 with payload expired: ... instead of an empty payload (the gateway notes). |
keel runs cancel <run-id>, keel runs resume <run-id> |
|
keel logs <agent|run-id> [--since <when>] [--until <when>] [--grep <text>] [--level <levels>] [--replica <n>] [--stream stdout|stderr] [--limit <n>] [--follow] |
With an agent slug or id: searches the last 7 days of logs across every run and replica of the agent through GET /v1/agents/{id}/logs (Logs), the last hour by default (--since 15m, 1h, 24h, 7d or an ISO timestamp, --until likewise), printing the newest --limit lines (default 1000, max 10000) oldest first as 02:14:07.001 r1 stderr text (the replica column appears for services). --grep is the search query (one term matches as a substring, several terms match word prefixes, "quoted phrases" must be adjacent, -term excludes), --level a comma separated list of error, warn, info, debug. --follow then streams GET /v1/agents/{id}/logs/tail with the same filters until interrupted; another --follow for the same agent by the same user replaces it (exit 5), lost streams reconnect up to five times (exit 7 after). With --json every line is one NDJSON object ({ id, ts, stream, level, line, runId, attemptNumber, replicaIndex, ... }). Nothing matched prints a note on stderr and exits 0. |
keel logs <run-id> [--attempt <n>] [--follow] |
Without a search filter a run id keeps the chunk path: every line of the latest attempt (or --attempt) through GET /v1/runs/{id}/logs, printed as 02:14:07.001 stderr text; --follow keeps streaming log events from GET /v1/runs/{id}/events until the run ends (not combinable with --json); exit 6 when the run ended failed, timed out or rejected; with --json the document is { run, attemptNumber, lines }. With any filter the run id is searched like an agent, restricted to that run, and --follow tails the agent filtered to the run. |
keel callbacks list <run-id>, keel callbacks redeliver <callback-id> |
Delivery history with every attempt, and redelivery. |
Services and ingress
| Command | Notes |
|---|---|
keel services |
GET /v1/services: a table of every service agent with AGENT, STATE, REPLICAS (ready/desired), UP SINCE, RESTARTS and HOSTNAME (running services first). |
keel service start|stop|restart [agent] |
POST /v1/agents/{id}/service/<action>; prints Service <slug> is up (run <id>).. restart is rolling: every replica is replaced one at a time behind the hostname. Plan and state refusals are 409 (exit 5), for example service_not_running on restart of a stopped service. |
keel service scale [agent] --replicas N |
POST /v1/agents/{id}/scale: sets the desired replica count (1 up to the plan's maxReplicasPerService); the reconciler adds or drains replicas on its next pass. replicas_exceed_plan and concurrency_exhausted (not enough free run slots for N replicas) are 409 (exit 5); a non-numeric count is a usage error (exit 2). Also available as keel services scale. Each replica is billed as one service. |
keel service status [agent] [--follow] |
GET /v1/agents/{id}/service: state, replicas (ready/desired and the plan limit), rollout progress, up since, uptime, restarts, last exit code, next restart, current run, workspace usage against the plan size, hostname and whether ingress is connected, then one table row per replica (#, STATE, READY, UPTIME, RESTARTS, RUN). --follow prints one line per state change (service up 2/2 ready run <id>, service restarting (last exit code 1), service restarting rolling out <revision>) and the container log lines of the current run in between, re-attaching to the next run after a crash, until the service is stopped (not combinable with --json). Also available as keel services status. |
keel service metrics [agent] [--range 1h|24h|7d] [--replica all|<index>] |
GET /v1/agents/{id}/metrics: the range and its bucket size, the replicas seen, the restart count, then one row per series that has data with LAST, AVG, MAX and a sparkline of the last 40 buckets (unicode block characters, a space for a bucket without data). An unknown range or replica is a usage error (exit 2). With --json the API document is printed unchanged. Also available as keel services metrics. |
keel ingress [agent] [--rotate-alias] |
The public hostname, the alias and whether the service is connected (exit 4 for an agent without ingress). --rotate-alias calls POST /v1/agents/{id}/ingress/rotate-alias and prints the new alias; the previous one stops resolving. |
keel domains add <hostname> [--agent <slug|id>] [--site <slug>] |
POST /v1/agents/{id}/domains or, with --site (or a keel.yaml that has a site: block), POST /v1/sites/{id}/domains; prints the domain and the exact DNS records to create (TXT for ownership, CNAME for routing, A/AAAA for an apex). Invalid or reserved hostnames exit 2, a taken hostname or a plan refusal 5. |
keel domains list [--agent <slug|id>] [--site <slug>] |
GET /v1/domains (or the agent's or the site's list): HOSTNAME, STATUS, TARGET (the agent slug, or site <slug>), LAST CHECK, LAST ERROR. |
keel domains verify <hostname> |
POST /v1/domains/{id}/verify for the domain with that hostname; prints the status, the last error and the records again. Exit 0 when the domain is active, 1 when it is not verified yet, 4 when no such domain exists. |
keel domains remove <hostname> --yes |
DELETE /v1/domains/{id}; without --yes the command explains that removal stops serving immediately and exits 2. |
Static sites
| Command | Notes |
|---|---|
keel sites create <slug> |
POST /v1/sites; prints the public URL. 409 (exit 5) for a plan without sites, a taken slug or a hostname bound to a service. |
keel sites deploy [dir] [--site <slug>] [--no-activate] |
Packs dir (default site.dir in keel.yaml) as a tar.gz without dotfiles and symlinks, refuses it over the plan caps or without an index.html (exit 2), streams it to POST /v1/sites/{id}/deployments, creates the site first when the slug is unknown, checks the manifest hash the platform stored against the local one, then POST .../activate unless --no-activate. Progress goes to stderr; --json prints { site, deployment, activated }. |
keel sites list |
GET /v1/sites: SITE, URL, DEPLOYMENT, UPDATED plus the stored bytes against the plan. |
keel sites show [site] |
GET /v1/sites/{id}: URL, live deployment, settings summary and the deployment history. |
keel sites activate <site> <deployment>, keel sites rollback [site] |
Switch the live deployment; rollback is 409 (exit 5) when there is no previous deployment. |
keel sites open [site] [--print] |
Opens the public URL in the browser; --print (or --json) only prints it. |
keel sites archive [site] |
DELETE /v1/sites/{id}. |
[site] defaults to site.slug in keel.yaml. keel init --template static scaffolds a site project. Details in Static sites.
Agents, secrets, triggers, keys, usage
| Command | Notes |
|---|---|
keel status [agent] |
Lifecycle, mode, service state (for service agents), hostname, current revision and image, egress summary, default callback, last run outcome, triggers. |
keel pause [agent], keel resume [agent], keel agents archive [agent] |
Lifecycle transitions; illegal ones are 409 (exit 5). |
keel agents list, keel agents create <name> [--slug] [--callback-url] |
|
keel secrets set <name> [--value <v>|--from-file <path>|--stdin] |
Without a source the value is read from a hidden prompt. The value never appears in any output. |
keel secrets list, keel secrets rm <name> |
Names only. |
keel env list [agent] |
The agent's own environment variables (GET /v1/agents/{id}/env): name, kind, value (secrets masked), updated, by. Ends with a hint when a restart is pending on a running service. |
keel env set [agent] KEY=value [KEY=value...] [--secret] |
PUT /v1/agents/{id}/env. The agent defaults to keel.yaml; the first argument is treated as the agent when it contains no =. --secret stores every value in the tenant vault. Names are validated locally (POSIX, KEEL_* and PORT refused by the API). |
keel env unset [agent] NAME [NAME...] |
Deletes; names that do not exist are ignored. |
keel env pull [agent] |
POST /v1/agents/{id}/env/export: prints dotenv text with the secret values on stdout (keel env pull my-bot > .env) and a warning naming the secrets on stderr. Owners and admins only (403 otherwise); every call is audited. |
keel env push [agent] [--file .env] [--secret] |
POST /v1/agents/{id}/env/import. Reads the file (- for stdin), upserts every entry, deletes nothing. Parse errors name the line. |
keel triggers list [agent], keel triggers create [agent] --kind manual|schedule|webhook [--schedule <cron> --timezone <tz>] [--disabled], keel triggers rm <id>, keel triggers rotate-secret <id> |
Webhook secrets are shown once. |
keel api-keys create <name> --scope <scope...> [--expires-in-days <1..365>], keel api-keys list, keel api-keys revoke <id> |
The plaintext is shown once. Keys never expire unless --expires-in-days is given; the list shows EXPIRES and a state of active, expired or revoked. |
keel usage |
Current period against the plan, including an Agents and bots line and an App services line, each with the settled hours, the running count, the projected spend and the flat price per replica-month (services.agent and services.app in --json, next to the services.hours, running and projectedCents totals). |
keel billing |
Plan, subscription, billing interval, the term, the renewal date, the next invoice date and the billing profile. --plan starter|business [--interval monthly|annual] prints a Stripe Checkout link for that plan on that interval (default annual). --interval monthly|annual alone switches the current subscription and prints what is charged and when: to annual immediately with Stripe's proration, to monthly at the end of the paid term with no refund (Billing). Owners only. |
keel completion bash|zsh|fish |
source <(keel completion zsh). |
Data stores (keel db)
Managed Postgres and Valkey on plans that include data stores (data_stores_not_in_plan is 409, exit 5). Every store injects its connection URL as an organization secret; the CLI never prints a URL except from keel db url.
| Command | Notes |
|---|---|
keel db create <name> [--kind postgres|valkey] |
POST /v1/data-stores (default kind postgres). Prints the name, kind, status and secret name, then Reference <SECRET> under secrets: in keel.yaml, then keel deploy. Duplicate names are data_store_name_taken (409, exit 5). |
keel db list |
NAME, KIND, STATUS (provisioning, ready, deleting, failed), SIZE (sampled, not sampled before the first sample), SECRET, CREATED. The other commands take the store name from this list; an unknown name is a usage error (exit 2). |
keel db url <name> |
POST /v1/data-stores/{id}/connection: prints only the URL on stdout so it can be piped; a warning that it contains the password goes to stderr. --json prints { name, secretName, url }. Owners and admins only, every call is audited. |
keel db rotate <name> |
POST /v1/data-stores/{id}/rotate: new credentials, the secret is rewritten. Running services keep the old URL until keel service restart <slug>. |
keel db delete <name> [--yes] (alias rm) |
DELETE /v1/data-stores/{id}: removes the secret now, soft deletes the store and prints the purge date (seven days) or purged when it was purged immediately. Without --yes an interactive terminal asks you to type the store name; a non-terminal or --json run without --yes is a usage error. |
Secret naming: the name is a lowercase slug of at most 32 characters (letters, digits and hyphens, starting and ending with a letter or digit). A store named main becomes DATABASE_URL (Postgres) or REDIS_URL (Valkey); any other name is upper-cased with hyphens turned into underscores and prefixed, so job-queue becomes JOB_QUEUE_DATABASE_URL or JOB_QUEUE_REDIS_URL. Add that name under secrets: in keel.yaml and deploy; the generated keel.yaml carries a reminder comment above secrets:.
There is no keel db psql. Stores have no public port, so an interactive shell would need a tunnel, which is not offered. Use keel db url from a service that runs inside the platform, or a one-off keel run that reads the secret from its environment.
CI
- run: npm install -g @keelhosting/cli
- run: keel deploy --image $ECR/keel-tenants/$ORG:$GITHUB_SHA --json > revision.json
env:
KEEL_API_KEY: ${{ secrets.KEEL_API_KEY }}
KEEL_API_URL: https://api.keelhosting.dev
- run: keel run --follow --json > run.json
env:
KEEL_API_KEY: ${{ secrets.KEEL_API_KEY }}
Keys come from keel api-keys create ci --scope agents:write runs:trigger builds:write secrets:write (scopes in HTTP API). API keys cannot manage the organization, members or other keys. The key belongs to one organization, so --org is unnecessary.
Development
pnpm --filter keel build # tsc -b, then esbuild to dist/keel.js
pnpm --filter keel test # unit tests plus the in-process API suite
node apps/cli/dist/keel.js --help
apps/cli/test/integration.spec.ts boots the real API with the in-memory ports (apps/api/test/support/test-app.ts), a fake OIDC issuer for the device flow, an in-memory keychain, a recording browser opener and a fake docker, and drives runCli directly. test/package.spec.ts runs pnpm pack --dry-run and asserts the tarball holds only dist/keel.js, README.md and package.json.
Known gaps
GET /v1/agentshas no slug filter, so slug resolution pages through the list.- Whether the local
registry:2acceptsdocker loginwith the placeholder credentials the API returns forIMAGE_BUILDER=localwas not verified in this change. - The refresh grant assumes the
keel-cliapplication in Zitadel has refresh tokens enabled for the device grant;zitadel-bootstrap.tswas not changed here.