Kiln runs on your own infrastructure: a single host with Docker for small installs, or a Kubernetes cluster with gVisor for production.
Requirements
| Component | Purpose |
|---|---|
| Node 22.13+ and pnpm 10 | Kiln's services are TypeScript on Node. |
| A data directory (a volume) | Runtime state (work items, runs, gates, scores, tokens, audit log) in the SQLite file kiln.db, and objects (videos, reports, snapshots) under objects/. See Data and backups. |
Docker, or Kubernetes with a gvisor RuntimeClass |
Sandboxes for agent runs. Required: Kiln never runs an agent on the host. |
| An egress proxy | Enforces each runner's egress host list. Included in docker-compose.yml and the Helm chart. See Sandboxes. |
| Optional: an OpenAI-compatible gateway (LiteLLM) | The LLM fallback for typed decisions and judges. |
Postgres, Temporal, NATS and S3 are not required in v1; see ADR-002, ADR-005 and ADR-015 for why and for the path to them.
Install
Try it locally
git clone https://github.com/fishuke/kiln && cd kiln
pnpm install
pnpm dev # API on :4000 with demo data, web app on :3000Open http://localhost:3000. Demo factories run on a git-backed local forge with scripted agents, so nothing needs an account.
Docker must be running: every run, the scripted demo agents included, starts in a Docker sandbox (KILN_SANDBOX defaults to docker). pnpm dev first starts the sandbox egress proxy (docker compose up -d --build egress-proxy, which also creates the kiln-egress network); if that fails it prints the command and carries on. Sandboxes reach the API on your machine as http://host.docker.internal:4000, through the proxy. Without Docker the web app still opens, and a run fails with Kiln needs Docker running to start a sandbox. The default sandbox image is node:22-bookworm, which has git but no agent CLIs. For real agents (KILN_AGENTS=real), build the runner image and point the API at it:
docker build -t kiln-runner-node runners/images/node
KILN_RUNNER_IMAGE=kiln-runner-node KILN_AGENTS=real pnpm devA sandbox gets only what Kiln passes to it: the run's secrets and model credentials, not the environment, files or logins of the machine the API runs on.
One machine with Docker Compose
cp .env.example .env # add your keys: CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`, or API keys
echo KILN_AGENTS=real >> .env # run Claude Code, Codex and OpenCode instead of scripted agents
docker build -t kiln-runner-node runners/images/node
docker compose up -d --build
pnpm healthThe API prints a bootstrap admin token on first start (also in the data volume as admin-token). Put it in .env as KILN_TOKEN so the web app can call the API, then restart web.
Then create your account: the first signup (POST /api/v1/auth/signup) becomes the owner of the default team, and existing factories move into that team. Invite everyone else. See Teams and users.
A server: Coolify or any Docker host
docker-compose.prod.yml runs Kiln on a public URL with sign-in: the API, the web app, the sandbox image (built on the server), the egress proxy, and an optional Caddy for HTTPS. Demo data and dev auth are off, agents are real, and runs use Docker sandboxes through the host's Docker socket. That socket is root on the host, so give Kiln a server of its own.
Set these variables:
| Variable | Value |
|---|---|
KILN_WEB_URL |
The web app's URL, for example https://kiln.example.com. |
KILN_PUBLIC_URL |
The API's URL, for example https://api.kiln.example.com. Webhooks from GitHub, Slack and the rest arrive here. |
KILN_OWNER_EMAIL |
Your email. Only this address can create the first account, which owns the server. |
KILN_SECRET_KEY |
A long random value (openssl rand -hex 32). It seals stored secrets and signs run tokens; keep it stable. |
CLAUDE_CODE_OAUTH_TOKEN |
From claude setup-token, or set ANTHROPIC_API_KEY, OPENAI_API_KEY or OPENROUTER_API_KEY. |
GITHUB_TOKEN and the other integration keys |
Optional; see Connect your systems. |
Coolify. Add a resource of type Docker Compose from this repository, with docker-compose.prod.yml as the compose file. Give the web service your web domain (port 3000) and the api service the API domain (port 4000), fill in the variables Coolify lists, and deploy. Coolify's proxy serves HTTPS, so leave the proxy profile off.
Any other Docker host. Point both domains at the server, then:
cp .env.example .env # set the variables above
docker compose -f docker-compose.prod.yml --profile proxy up -d --buildCaddy gets certificates for both domains. Open the web URL and sign up with KILN_OWNER_EMAIL, then invite everyone else.
Sandboxes join the internal network kiln-egress and reach the internet only through the proxy. If your platform renames networks, set KILN_EGRESS_NETWORK to the name it uses. Studio sandboxes and the API also share a second internal network, kiln-preview (KILN_PREVIEW_NETWORK), on the subnet KILN_PREVIEW_SUBNET (default 172.30.250.0/24; change it if it clashes with yours). The API proxies previews into it and refuses any connection from it. KILN_RUNNER_IMAGE picks another sandbox image; it needs git and the harness CLIs, like runners/images/node.
Run limits
These variables on the API limit runs in sandboxes on this server. Runs on self-hosted workers use the worker's own limits.
| Variable | Default | What it does |
|---|---|---|
KILN_MAX_CONCURRENT_RUNS |
No limit | How many runs go at once. More runs wait as queued and start in order when a run ends. A queued run can be cancelled. |
KILN_RUN_STALL_MINUTES |
30 |
Kiln stops a run when its agent sends no messages, tool calls or other events for this many minutes. |
KILN_MAX_RUN_MINUTES |
240 |
Kiln stops a run after this many minutes. |
A run stopped for time ends as error with infrastructure_timeout, and is not tried again automatically. When the agent does not end within 60 seconds of the stop, Kiln removes its sandbox. The /metrics endpoint includes kiln_runs_queued.
Studio previews
Studio serves each preview on its own host under a preview domain, such as sc-3f9a….preview.kiln.example.com. To turn it on:
- Set
KILN_STUDIO_PREVIEW_DOMAIN=preview.kiln.example.com. - Add a wildcard DNS record,
*.preview.kiln.example.com, pointing at the server. - Get a wildcard certificate for
*.preview.kiln.example.com. Wildcard certificates need a DNS challenge, so your proxy needs your DNS provider's API. - Send every host under the domain to the API on port 4000, with WebSocket upgrades. On Coolify, the
apiservice's Traefik labels indocker-compose.prod.ymlalready route every host underKILN_STUDIO_PREVIEW_DOMAINand request the wildcard certificate from the resolverKILN_TRAEFIK_CERT_RESOLVER(defaultletsencrypt), because Coolify's domain form takes no wildcard. Turn off Escape special characters in labels in the resource's General settings so the labels get the domain, and set up Traefik's DNS challenge (for Cloudflare:--certificatesresolvers.letsencrypt.acme.dnschallenge=true,--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflareandCF_DNS_API_TOKENin the proxy configuration). With your own Caddy, add a site block*.preview.kiln.example.com { tls { dns <provider> … } reverse_proxy api:4000 }(thecaddy:alpineimage indocker-compose.prod.ymlhas no DNS plugins).
Use a domain other than the web app's when you can, such as kiln-preview.example.net. Code in a preview runs on a subdomain of the preview domain, and a subdomain of the web app's domain can set cookies the web app receives. See Previews.
Kubernetes with Helm
kubectl create secret generic kiln-env --from-env-file=.env
helm install kiln deploy/helm/kiln \
--set publicUrl=https://kiln.example.com --set webUrl=https://kiln.example.com \
--set ingress.host=kiln.example.comThe chart creates the kiln-sandboxes namespace with the restricted pod security level, a gvisor RuntimeClass (handler runsc, which your nodes must provide), the egress proxy, a default-deny NetworkPolicy, and a Role that lets the API create sandbox pods there.
For Studio previews, add --set studio.previewDomain=preview.kiln.example.com and create the TLS Secret kiln-preview-tls with a wildcard certificate (or name another with studio.previewTlsSecret). The chart adds an Ingress for *.preview.kiln.example.com to the API and a NetworkPolicy that lets the API reach the dev-server ports of Studio sandboxes.
First factory
pnpm kiln init my-factory --template sdlc
# edit my-factory/factory.yaml: repositories, alias, integrations
pnpm kiln validate my-factory
KILN_URL=http://localhost:4000 KILN_TOKEN=... pnpm kiln apply my-factory
pnpm kiln send "Fix the typo in the README" --factory <alias>Or push the folder to a GitHub repo and connect it under Create factory; merges to its default branch then apply automatically.
Connect your systems
Public URLs
Kiln needs two URLs:
- Public URL: where agents in sandboxes reach the API. Kiln uses it for the Claude Code command-guard hook (
/api/v1/guard) and the Kiln MCP (/mcp). Without it, neither is installed in runs. - Web URL: where people open links. Kiln uses it in the links it posts, such as
<web URL>/inbox/<gate>and<web URL>/runs/<work item>.
Webhooks and secrets
Point each provider's webhooks at Kiln and set the matching secret. For the provider endpoints, Kiln reads the server environment variable with the secret's name (for example GITHUB_WEBHOOK_SECRET) first, then its secrets store, then KILN_SECRET_<NAME>, then the provider's connection. A custom webhook's secret comes from the secrets store or KILN_SECRET_<secretName>.
| Provider | Endpoint | Secret |
|---|---|---|
| GitHub | POST /webhooks/github |
GITHUB_WEBHOOK_SECRET |
| GitLab | POST /webhooks/gitlab |
GITLAB_WEBHOOK_TOKEN (X-Gitlab-Token) |
| Azure DevOps | POST /webhooks/azure-devops |
AZURE_DEVOPS_WEBHOOK_PASSWORD (basic auth) |
| Bitbucket | POST /webhooks/bitbucket |
BITBUCKET_WEBHOOK_SECRET |
| Slack | POST /webhooks/slack |
SLACK_SIGNING_SECRET |
| Microsoft Teams | POST /webhooks/teams |
Bot Framework token for TEAMS_APP_ID |
| Discord | POST /webhooks/discord |
Ed25519 with DISCORD_PUBLIC_KEY |
| Discord bridge | POST /webhooks/discord/events |
DISCORD_EVENTS_TOKEN (bearer) |
| Linear | POST /webhooks/linear |
LINEAR_WEBHOOK_SECRET |
| Jira | POST /webhooks/jira?token=<token> |
JIRA_WEBHOOK_TOKEN |
| Custom | POST /webhooks/w/<uid> (or /webhooks/<factory>/<name>) |
The webhook's secret |
Every provider endpoint refuses calls until its secret is set (HTTP 401, "webhook secret not configured"), and refuses calls whose signature or token does not match. Slack's URL verification challenge is the one call answered without a secret. For local development without secrets, set KILN_ALLOW_UNSIGNED_WEBHOOKS=1; the server refuses to start with it when NODE_ENV=production.
If Kiln fails while routing a delivery, it answers with an error and forgets the delivery ID, so the provider's retry is routed instead of being ignored as a duplicate. The delivery log records the error.
OAuth providers redirect to GET /oauth/<provider>/callback on the public URL; register that URL in each OAuth app. Every provider's variables are listed on Connections.
Admins can set secrets through the API; values are never returned, only names. See the API overview.
Model credentials
Runs use your own model keys. Store CLAUDE_CODE_OAUTH_TOKEN (a Claude subscription token from claude setup-token) or ANTHROPIC_API_KEY for Claude Code, a ChatGPT login (Sign in with ChatGPT under Team settings), OPENAI_API_KEY or CODEX_API_KEY for Codex, and the keys OpenCode's providers need. You can store them as Kiln secrets or set them as plain environment variables on the server. Agents can also name their own secret with harness.auth. See Harnesses.
Accounts and login
| Variable | What it does |
|---|---|
KILN_OPEN_SIGNUP=1 |
Anyone may sign up and join the default team. Otherwise, after the first owner, signup needs an invite. |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
Turn on GitHub login. The OAuth app's callback URL is <KILN_PUBLIC_URL>/api/v1/auth/github/callback. Login follows GITHUB_URL; GITHUB_OAUTH_URL overrides it. |
GITHUB_URL, GITHUB_API_URL |
A GitHub Enterprise Server for the forge, apps and login, such as https://github.acme.test. The API defaults to <GITHUB_URL>/api/v3. Unset: github.com. See GitHub. |
KILN_SECRET_KEY |
Seals team and personal secrets with AES-256-GCM, and signs run tokens. Set it, and keep it stable across restarts. To rotate it, see Secrets. |
KILN_ADMIN_EMAILS |
Comma-separated emails of server admins. They see every team's audit log, tokens and server secrets. Team owners and admins see only their own team. |
KILN_SMTP_URL, KILN_MAIL_FROM |
Email notifications, for example smtp://user:pass@mail.acme.test:587?from=kiln@acme.test. |
KILN_DEV_AUTH=1 |
Local development only: requests without a token act as dev@kiln.local, owner of the default team. |
KILN_ALLOW_UNSIGNED_WEBHOOKS=1 |
Local development only: provider webhooks without a configured secret are accepted unsigned. Refused when NODE_ENV=production. |
Identities
Gate assignees are people, such as po@acme.test. To let replies in Slack or on issues decide gates, people link their Slack, GitHub, Linear, Jira, Discord, Teams, GitLab or Azure DevOps accounts in user settings. See Linked accounts. The server option identities still works as a fallback. It is keyed by <provider>:<user>, for example slack:U024BE7LH → po@acme.test. See Who may decide from a reply.
Roles
Gates name approvers by role, such as role:po or role:code-owners. Kiln's server configuration maps each role to people. Kiln uses these roles:
| Role | Used by |
|---|---|
po |
Built-in spec and preview gates |
code-owners |
The built-in merge gate, and high-risk reviews |
admins |
Inbox items that have no other recipient |
Sandboxes
- Docker: create the
kiln-egressnetwork and run the egress proxy (the image indeploy/egress-proxy) on it askiln-egress-proxy, listening on port 3128. The proxy also needs a network with internet access. Kiln finds it by its image labelkiln.egress-proxy=1and sets each sandbox's rule withdocker exec. KILN_SANDBOX_URL: the API's URL as sandboxes reach it, when that is notKILN_PUBLIC_URL. The default for a localhost public URL on Docker ishttp://host.docker.internal:<port>;docker-compose.ymlsetshttp://api:4000.- Kubernetes: create the
kiln-sandboxesnamespace, install gVisor with a RuntimeClass namedgvisor, and deploy the egress proxy with the labelapp.kubernetes.io/name: kiln-egress-proxyon port 3128. - Build runner images with the harness programs (
claude,codex,opencode), git and your toolchain installed.
Cloud identity federation
When a factory sets cloudProviders in factory.yaml, its runs get the federation settings as environment variables and exchange their own identity token for short-lived cloud credentials. Kiln never holds long-lived cloud keys. See Definitions as code.
Forge identity
Kiln talks to GitHub as a GitHub App (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID), minting a short-lived installation token for each call, or with a plain GITHUB_TOKEN. GitLab, Azure DevOps and Bitbucket use the tokens on their pages. It needs to read repositories, push kiln/* branches, open and update pull requests, comment, add labels and read check runs. Do not give it a branch-protection bypass. Agents never see this token; each run gets its own short-lived run token instead.
The merge gate merges with its own credential, KILN_MERGE_GATE_TOKEN: a token of a dedicated GitHub account that may merge pull requests on the repositories where a person turned the gate on. If branch protection requires reviews, that account needs to be allowed to merge without one. Never reuse the forge token or the GitHub App for it, because sandboxes get tokens from the App. Without it, every pull request goes to a person.
Timers
Kiln runs a timer pass that:
- sends gate nudges and applies gate expiry,
- fires due schedules,
- retries verify stages that wait for CI,
- promotes or rolls back canaries after 7 days,
- when self-improvement checks are switched on for the pass, runs the check for every factory.
Monitoring
GET /api/v1/healthreturnsok, the number of factories and the server time.- Each run records a trace ID, so you can match its events with your logs and traces.
- The audit log lists who applied definitions, decided gates, steered runs, issued tokens and set secrets.
Data and backups
The data directory (KILN_DATA_DIR, .data by default) holds everything Kiln keeps between restarts:
kiln.db(withkiln.db-walandkiln.db-shmwhile the API runs): runtime state in SQLite. One API process per data directory.objects/,storage/,forge/,sandboxes/: objects, team storage, the local forge and sandbox folders.admin-token: the bootstrap admin token.
Backups. Take a consistent copy of the database while the API runs with SQLite's backup command, then copy the rest of the directory:
sqlite3 .data/kiln.db ".backup '/backups/kiln-$(date +%F).db'"Or stop the API and copy the whole directory. Copying kiln.db alone while the API runs can miss recent writes that are still in kiln.db-wal. To restore, stop the API, put the backup in place as kiln.db, delete any kiln.db-wal and kiln.db-shm, and start it again.
Upgrading from kiln.json. Older installs kept runtime state in kiln.json. On the first start with SQLite, Kiln imports it into kiln.db in one transaction and renames it to kiln.json.imported; delete that file once you are happy. To stay on the JSON file for now, set KILN_STORE=file. That switch goes away in a later release.
Disk space
Every deploy rebuilds the runner images (kiln-runner-node, kiln-runner-studio, a few GB each) and the app images; the previous ones stay on the host as dangling or superseded images, with their build cache. Server admins clean them up on Server → Clean up disk (/admin/cleanup):
- The page is a dry run first: Docker's disk usage and the list of what would go. Clean up now removes it; after a first run, Clean up every hour keeps it that way.
- It removes only Kiln's own things: stopped sandbox containers and stopped containers of Kiln's Compose project older than a day, images of that project a newer build replaced (the newest 2 per image stay, never one a container uses), and build cache older than 7 days down to 10 GB. The project is read from the API's own container labels; set
KILN_COMPOSE_PROJECTto override. - It never removes a volume.
kiln-datavolumes no container uses are listed so you can decide. - Before a Docker sandbox starts, Kiln checks free space on its data volume. Below
KILN_DISK_MIN_FREE_GB(default 20) orKILN_DISK_MIN_FREE_PCT(default 15) it cleans up once, then refuses the sandbox with a clear error if space is still low.0turns a limit off. - Sandboxes and the Compose services keep at most 30 MB of logs each.
On Coolify, also turn on the server's own Docker clean-up (Servers → your server → Docker Cleanup) for images other resources leave behind.
Troubleshooting
| Symptom | Check |
|---|---|
| A definition push did not apply | The factory's last error shows the first validation error. Run kiln validate. |
| An automation never fires | The trigger's provider and event match the event name exactly, and every filter key matches (GitHub events carry both repo and repos). |
A work item is blocked |
Its blocked reason says why: an expired gate, a stage that failed too often, the budget, or an error. Answer the open question to resume. |
| A run fails at install | The runner's egress list is missing a package registry or another host the run needs. Repository hosts are always allowed. |
| Verify waits forever | CI checks on the branch are still pending. Kiln checks again on each timer pass. |
| Every command is blocked | The Claude Code hook cannot reach the public URL, so it blocks by design. |
| A Studio preview answers 502 | The API can't reach the sandbox: on Docker it isn't on kiln-preview; on Kubernetes studio.previewDomain is unset. Or the dev server stopped. |
| Webhooks return 401 | The secret in Kiln does not match the provider's, or (Teams, Discord, GitLab, Azure DevOps, Bitbucket) it is not set. |
An automation with teams or channels fails to apply |
The names did not resolve. Connect the provider, or set its directory. See Connections. |
OpenTelemetry
Set OTEL_EXPORTER_OTLP_ENDPOINT (OTLP over HTTP, for example https://otel.example.com) and Kiln sends traces, metrics and logs there. OTEL_EXPORTER_OTLP_HEADERS carries the collector's auth. Works with SigNoz, Grafana, Honeycomb and any OTLP collector; nothing is sent when the endpoint is unset.
| Service | What it sends |
|---|---|
kiln-api |
HTTP requests, outgoing calls (GitHub, model providers, webhooks), host metrics and every console line. OTEL_SERVICE_NAME changes the name. |
kiln-api (engine) |
One trace per task: a span for each stage and each agent run, with the run's status, cost and failure reason. Every run event (messages, tool calls, errors, guard verdicts) is a log record on its run's span. Other events (gates, pull requests, decisions) are log records in the task's trace. |
kiln-api (Studio) |
Spans for an App's slow steps (provision, install, resume, snapshot, dev server start, ready check, apply) inside the request that ran them. A failed App setup is an error log record and a preview that didn't start is a warning, with the dev server's last 40 output lines. |
kiln-web |
Next.js routes and rendering, its calls to the API (which continue in the API's traces), metrics and console lines. |
kiln-egress-proxy |
Every request a sandbox made, allowed or denied, as a log record. A denied host is a warning. |
Run event text and dev server output are redacted the same way as in the run view.
Trace ids in errors. A console line written while Kiln handles a request or a run carries that trace's id. API error responses carry it as traceId in the body and the x-trace-id header, a 5xx is also logged at ERROR in that trace, and the web shows it under every error message as "Trace 1a2b3c4d…" with a button that copies the whole id. Search for it in your tracing backend to see what failed. Studio's routes do this today; the other routes add traceId once the API's global error handler does. The proxy sends its log to the API over UDP (port KILN_EGRESS_LOG_PORT, default 5140); docker-compose.prod.yml wires this up when the endpoint is set.
Workers and monitoring
Agents can also run on machines you choose, such as a build server or a host in a private network: start kiln worker there and set workerHost on the agent. See Self-hosted workers. The API serves Prometheus metrics at /metrics (runs by state, tasks, open gates, workers and the worker queue); set KILN_METRICS_TOKEN to require a bearer token.