# Kedge documentation

> A lightweight, globally distributed cloud platform. Deploy static sites, HTML apps with live data, shebang handlers, container services, and databases from a source tree, a Git push, a prebuilt image, or a task for a coding agent in a persistent workspace. Every app includes a replicated SQLite database and file tree, scales to zero, and runs in hardware-isolated VMs.


---

# quickstart

Source: https://kedge.dev/docs/quickstart

Publish a static page:

```bash
ssh kedge.dev publish '# Hello, world!'
```

Your SSH key identifies your account. Markdown becomes HTML.

## a function

Add a `#!` shebang to publish a script that runs on each request:

```bash
printf '#!/bin/bash \n uptime' | ssh kedge.dev
```

## the `kedge` shortcut

Install the optional `kedge` shortcut, a small wrapper around
`ssh kedge.dev`:

```bash
ssh kedge.dev setup | sh
```

It also adds `kedge up`, a Git commit-and-push helper for source directories.

## start with a task

Coding-agent sessions need a claimed account. Claim the SSH key once, then
describe the task:

```bash
kedge login
kedge agent "build a small status page"
```

Kedge creates **My workspace** on first use, gives the task its own project,
and starts an interactive session. A small project usually ends with a working
demo URL; the private session URL lets you continue in a browser. See [coding
agents & workspaces](/docs/agents) for follow-ups, named workspaces, and other
clients.

## a routed site

Deploy a directory, and the files become a route tree:

```bash
mkdir -p about
cat > index.html <<'EOF'
<h1>Field notes</h1>
<p>A tiny site with more than one route.</p>
<a href="/about/">How this site works →</a>
EOF
cat > about/index.html <<'EOF'
<h1>About this site</h1>
<p>The directory tree becomes the URL tree.</p>
<a href="/">← Back home</a>
EOF
kedge up
```

`index.html` serves `/`; `about/index.html` serves `/about/`. `kedge up`
derives a default app name from the working directory.

Switch branches for an isolated preview:

```bash
if git fetch -q origin refs/heads/tweak >/dev/null 2>&1; then
  git reset -q --hard
  git checkout -q -B tweak FETCH_HEAD
else
  git checkout -q -b tweak
fi
mkdir -p about
if [ ! -f index.html ]; then
  cat > index.html <<'EOF'
<h1>Field notes</h1>
<a href="/about/">How this site works →</a>
EOF
fi
cat > about/index.html <<'EOF'
<h1>Previewing a new route</h1>
<p>This only exists on the <code>tweak</code> branch.</p>
<a href="/">← Back home</a>
EOF
kedge up
```

The preview gets its own URL; `main` stays live.

## an HTML app

Add `data-kedge` to ordinary HTML for shared, live data without writing an
application server, SQL, or JavaScript.

```bash
cat > index.html <<'EOF'
<!doctype html>
<title>Clicks</title>
<h1>Clicks</h1>

<strong data-kedge="counters/home">{{clicks | plural:click}}</strong>
<button data-kedge="counters/home?$increment=clicks">add one</button>
EOF
kedge up
```

At deploy time, Kedge parses the bindings, creates the counter column, and
manages the underlying database. Increments merge without losing simultaneous
clicks, and bound values update live. See [HTML apps](/docs/html-apps) for a
more detailed example.

## a database and a filesystem

Every app has `/shared.db` and `/shared/`, ordinary local paths for state that
needs code. Two handlers, the same rollup:

```bash
cat > index.sh <<'EOF'
#!/usr/bin/env bash
agent=${HTTP_USER_AGENT//"'"/"''"}
printf 'Content-Type: text/plain\n\n'
sqlite3 -header /shared.db "
CREATE TABLE IF NOT EXISTS hits(
  id INTEGER PRIMARY KEY, dc TEXT, agent TEXT, at TEXT DEFAULT (datetime('now')));
INSERT INTO hits(dc, agent) VALUES('$KEDGE_DC', '$agent');
SELECT dc, count(*) AS hits, max(at) AS latest, agent
  FROM hits GROUP BY dc, agent ORDER BY hits DESC;"
EOF
cat > files.sh <<'EOF'
#!/usr/bin/env bash
echo "$KEDGE_DC $HTTP_USER_AGENT" >> /shared/agents.log
printf 'Content-Type: text/plain\n\n'
sort /shared/agents.log | uniq -c | sort -rn
EOF
kedge up
```

`/` groups the request log in SQL; `/files` appends a line and counts it with
`sort | uniq -c`. Both replicate across instances and regions. Use the database
when you want a query or an index. See [shared data](/docs/shared-data).

## an app built from source

Kedge detects common frameworks and builds them from source. This Rust service
reports its optimized binary and resident-memory sizes:

```bash
mkdir -p src
cat > src/main.rs <<'EOF'
use memory_stats::memory_stats;
use std::env::{current_exe, var};
use tiny_http::{Response, Server};

fn main() {
  let port = var("PORT").unwrap_or_else(|_| "8080".into());
  let srv = Server::http(format!("0.0.0.0:{port}")).unwrap();
  let bin = current_exe().unwrap().metadata().unwrap().len() / 1024;
  for (n, req) in srv.incoming_requests().enumerate() {
    let rss = memory_stats().unwrap().physical_mem / 1024;
    let body = format!("release binary: {bin} KiB\n\
      process RSS: {rss} KiB\nrequest: {}\n", n + 1);
    req.respond(Response::from_string(body)).unwrap();
  }
}
EOF
cat > Cargo.toml <<'EOF'
[package]
name = "quickstart-rust"
version = "0.1.0"
edition = "2021"
[dependencies]
memory-stats = "1.2"
tiny_http = "0.12"
[profile.release]
strip = true
EOF
kedge up
```

`Cargo.toml` is the build plan; Kedge compiles a release binary and supplies
`$PORT`.

## a prebuilt image

Publish an existing Docker image straight from a public registry:

```bash
kedge publish --app "$(kedge whoami)-quickstart-httpbin" \
  --image ghcr.io/mccutchen/go-httpbin:2.23.1
```

## a Dockerfile

Use a Dockerfile when the image itself is the point. This multi-stage Go build
compiles a static binary and copies it into an empty `scratch` image:

```bash
cat > main.go <<'EOF'
package main
import "net/http"
func main() {
  http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
    w.Write([]byte("Hello from scratch.\n"))
  })
  http.ListenAndServe(":8080", nil)
}
EOF
cat > Dockerfile <<'EOF'
FROM golang:1.24-alpine AS build
COPY main.go /
RUN CGO_ENABLED=0 go build -o /server /main.go
FROM scratch
COPY --from=build /server /
EXPOSE 8080
ENTRYPOINT ["/server"]
EOF
kedge up
```

See [builds & images](/docs/builds) for the full build order.

## clean up

Delete the example apps:

```bash
kedge delete 'hello-world-*' 'bash-*' \
  "$(kedge whoami)-quickstart*"
```

## beyond the defaults

Software that expects local disk, such as Postgres, a game server, or a dev
box, needs a [persistent volume](/docs/volumes):

```bash
kedge up --volume /data
```

Connecting a repository through [GitHub deploys](/docs/github) gives every pull
request its own preview.

---

# coding agents & workspaces

Source: https://kedge.dev/docs/agents

Agent sessions need a claimed account. Claim the SSH key once, then start with
one task:

```bash
kedge login
kedge agent "build a small status page"
```

Kedge starts its built-in app agent directly. For a small new site or app, the
agent usually publishes the first working demo and returns its URL. The
terminal stays at a `>` prompt for follow-up turns.

Continue an existing app with its current source and revision:

```bash
kedge agent --app myapp "add a leaderboard"
kedge agent --app myapp
```

The first form runs that turn before opening the prompt. The second opens the
prompt immediately for discussion, review, or changes. Publishing commits the
updated source and deploys the same app. If its revision changes concurrently,
the agent refreshes independent changes and resolves overlapping files before
retrying. It can also run the ordinary app-scoped Kedge commands and send
cookie-preserving GET and POST requests to the live app. Apps linked to GitHub
remain push-only.

You can also start a task from [/agents](/agents).

`make`, `build`, and `create` are aliases that read as the prompt's first
word, so `kedge make me a status page` needs no quoting. With raw SSH, use the
same explicit form: `ssh kedge.dev agent 'make me a status page'`.
`ssh kedge.dev agent --app myapp` opens the existing-app prompt without
requiring `-t`.
One-shots print the app URL on stdout, with sparse progress and any failures
on stderr. A new app must publish before the turn succeeds; an existing-app
turn can complete a database or other live operation without redeploying
unchanged source. Existing-app one-shots and interactive turns also show the
final reply on stderr; stdout remains only the app URL. Put `--verbose` after
the agent verb to stream tool activity. Use an explicit `publish` command when
those words are literal page content.

To use a connected subscription directly from the terminal, choose its CLI:

```bash
kedge agent --claude "review this project"
kedge agent --codex "fix the failing tests"
```

The first run signs in through the same terminal, then continues into the
agent. That sign-in also makes the provider available to sessions opened from
the workspace UI.

## continue a session

Type follow-up messages at the `>` prompt. The built-in agent keeps its
conversation and unpublished edits for that SSH connection; Ctrl-D ends it.
Published source remains with the app, and a later `--app` session loads the
latest revision. Pass `--verbose` to include its tool trace.

Subscription-agent sessions run in **My workspace**. Ctrl-D detaches without
stopping them. Their private browser URL is printed when they start, and
[/agents](/agents) lists sessions across all workspaces without waking them.

## projects and workspaces

Each top-level task in **My workspace** gets a separate Git project directory.
Sessions, installed tools, and the complete writable filesystem survive
restarts and host moves.

Projects in one workspace still share a VM and filesystem. Use a named
workspace when you need separate tools, files, or a stronger boundary between
projects.

## use a named workspace

Create a workspace, optionally connected to an existing app:

```bash
kedge workspace create design-lab
kedge workspace create api-work api-app
```

Run from a terminal, `create` leaves you in a shell inside the new workspace.
Scripted, it prints the name and returns.

Dispatch and manage tasks without opening the workspace first:

```bash
kedge workspace run design-lab "add keyboard navigation"
kedge workspace ps design-lab
kedge workspace stop design-lab <execution>
kedge workspace archive design-lab <execution>
```

Connecting an app checks out its code and makes deploys from that workspace
target it by default. You can change the connection from the workspace page.

## other ways to connect

The private workspace URL opens the full session UI in a browser. To connect
from a phone or desktop, open the workspace page and choose **pair a device**.
Enable the end-to-end encrypted relay, then scan the QR code or paste the
pairing link into the app. Kedge runs the relay, but it can see only
connection metadata—not session content—and stays off until you pair. Treat the
QR code and link as credentials. Start a workspace again before reconnecting if
you explicitly stopped it.

On a workspace page, **connect a provider** shows whether Claude and Codex are
connected. Sign in there once to make that provider available when starting an
agent session. After signing in, **start remote control** makes that workspace
available in the provider's native remote app; return to the same page to turn
it off. Hosted Codex sessions use Luna in fast mode and omit Codex plugin and
app catalogs to keep remote startup bounded.

If an agent is already running on your computer, install the [Kedge
skill](/.well-known/agent-skills/kedge/SKILL.md) to teach it how to deploy and
operate Kedge apps. That is separate from the hosted sessions above.

## usage and deletion

Platform model use has its own monthly allowance and appears in the
[billing ledger](/docs/billing#coding-agent-model-usage). Workspace compute and
storage use the ordinary resource rates.

Workspaces sleep after ten quiet minutes by default and wake on the next
request. Their page distinguishes starting, awake, sleeping, paused, stopped,
and failed workspaces, and the app logs record each sleep and wake with a
timestamp. Pairing a device or running a provider remote
control keeps the workspace awake; turn those off when you want to test or use
idle suspend. Storage remains durable and billable while compute sleeps.

Deleting a workspace stops it and leaves its volume orphaned until you remove
that explicitly with `kedge volumes rm`. Push or copy out anything important
first; retained volumes are not a source-control substitute.

---

# app model

Source: https://kedge.dev/docs/app-model

An **app** is the unit you name, deploy, and manage. Every app has a source
tree, a database, and a deploy history, whatever it is built from.

Every app runs as one of three things, marked by a glyph in `kedge apps` and
the web console:

| mark | type | you give it | it runs |
|---|---|---|---|
| `▤` | [site](/docs/sites) | files, some of which can execute | at the edge, [handlers](/docs/handlers) per request in a VM |
| `⬡` | [service](/docs/builds) | source, a Dockerfile, or an image | as a pooled process, scaled to demand and to zero |
| `▣` | [machine](/docs/volumes) | either, plus a persistent volume | as one VM per ordinal, kept for life |

A label beside the mark names the variation, such as `site · files + handlers`,
and a second mark records how the content arrived.

Most apps are sites, and a site needs no server code of its own. A service is
the escape hatch when a workload wants a long-lived process; a
[Compose](/docs/compose) file defines several services as one app.

## instances and machines

A service runs in a hardware-isolated VM. How those VMs are held is one choice:

| | [instances](/docs/runtime) | [machines](/docs/volumes) |
|---|---|---|
| identity | disposable | an ordinal, kept for life |
| storage | none of its own | a persistent volume |
| scaling | follows demand, down to zero | a fixed count, minimum one |
| address | one virtual address | one each, plus one for the set |

Instances are the default: Kedge starts them near demand and removes them when
idle. Declaring a [persistent volume](/docs/volumes) moves the app onto machines
instead, each keeping its identity and disk across deploys and host moves.

A machine always belongs to an app and is addressed by its ordinal within it.
There is no separate machine resource to create or destroy.

A [workspace](/docs/agents) is a machine you develop on rather than serve from,
carrying your checkout, tools, and build caches on the same durable disk. It
needs no service at all.

Every app includes [`/shared.db`](/docs/shared-data) and
[`/shared/`](/docs/shared-data#shared-files) for state shared across instances
and regions. Reach for a persistent volume only when software requires a
private local filesystem.

Apps are global resources. Public routes receive HTTPS, private services
resolve through account-local DNS, and an
[authentication policy](/docs/authentication) can gate any app type at ingress.

---

# deploy workflows

Source: https://kedge.dev/docs/deploy

Every source deploy is an ordinary Git history. `kedge up`, a manual push,
GitHub, the browser editor, and an import all feed the same build and cutover
pipeline. The files select a site, automatic source build, Dockerfile build,
or Compose deploy; each path creates or updates an app. See
[builds & images](/docs/builds).

## from a directory

From a project directory, the shortcut can initialize Git when needed, commit
the working tree, add a username-scoped `kedge` remote, and push the current
branch:

```bash
kedge up --dry-run
kedge up
```

The first push creates the app. The production branch defaults to `main` and
can be changed with top-level `x-kedge.production-branch`. Any other branch of
a single-service app is an isolated preview at
`https://<name>--<branch>.kedge.run`. Its managed database and files branch
from production once; redeploys retain preview changes. Multi-service previews
are not available. Previews expire automatically. See
[shared data](/docs/shared-data#previews-and-forks) for storage boundaries.

Identical Git trees reuse the prior build, so a retry or amended commit with
unchanged content redeploys in milliseconds.

Apps deployed by push keep their source durably: the app page shows a
`git clone` snippet, and the **edit code** button opens a browser
editor over the repo; saving a file commits and redeploys through the
same pipeline as a push.

## other ways in

- `kedge import <git-url> [name]` clones a public repository into an app you
  own and can continue pushing.
- `kedge publish --app <name> --image <ref>` deploys a public prebuilt image.
- Inline content, piped stdin, and `scp` publish documents, functions, and file
  trees without creating a local repository first.
- The web editor commits each save into the same durable repository.

## app names

`kedge up` derives the app name from the directory and prefixes your username.
The manual equivalent is a namespaced Kedge remote and a push:

```bash
git remote add kedge ssh://kedge.dev/$(kedge whoami)/myapp.git
git push kedge main
```

The namespaced path creates `<you>-myapp`, and no other account can create any
`<you>-*` name. A bare repo name (`ssh://kedge.dev/myapp.git`) instead claims
`myapp` first-come from the shared global pool.

## zero-downtime cutover

The current version keeps serving through the build. The new version boots,
is snapshotted, and must serve a request before traffic moves. If it cannot,
the deploy fails and the current version stays live.

`kedge rollback <app>` sends the previous version through the same gate.
Services with [persistent volumes](/docs/volumes) roll machines in place
because each volume has one writer.

[Runtime & scaling](/docs/runtime#readiness-and-health) explains the snapshot
and health checks behind this gate.

## GitHub deploys

The Kedge GitHub App connects selected repositories without a long-lived
personal access token. It deploys the production branch, creates pull-request
previews, and reports status to GitHub. See [GitHub deploys](/docs/github).

## update a Compose app

Redeploying `compose.yaml` reconciles its Kedge app. New services are added,
shared builds are reused, and generated secrets keep their values. Kedge
refuses edits that would implicitly discard persistent state. The exact model
and supported fields live in the
[Compose reference](/docs/compose).

## environment and secrets

Commit non-secret defaults under a service's `environment`. Store deploy-time
values with `kedge env <app>` or the app page. Standard Compose interpolation
can make a value mandatory:

```yaml
services:
  web:
    environment:
      API_KEY: ${API_KEY:?set with kedge env}
```

Compose `secrets` and `configs` deliver values as guest files. The `x-kedge`
generator can mint a secret once without putting the value in Git; see
[Compose secrets](/docs/compose#secrets-and-configs).

## time-limited apps

An app expiration deletes its route, compute, snapshot, and app record at the
deadline. Set it on the app page or with
`kedge expire <app> <48h|7d|timestamp|clear>`. Previews use the same cleanup.

---

# sites & routing

Source: https://kedge.dev/docs/sites

A site is a source tree with no server build. Ordinary files are published to
the edge CDN; files whose first line is a `#!` shebang become
[handlers](/docs/handlers) at the same hostname. This page covers file routing
and static delivery; the handlers guide covers executable routes. HTML or
Markdown files with data bindings become
[server-rendered HTML applications](/docs/html-apps) on the same site.

## routes from paths

```text
index.html             -> /
about.html             -> /about
guide.md               -> /guide
logo.png               -> /logo.png
api/visit.sh           -> /api/visit      (handler)
blog/[...].py          -> /blog/*         (handler catch-all)
```

`.html`, `.md`, and handler extensions are stripped. `index` is the directory index,
so `about/index.html` serves `/about/`. A bracket catch-all owns its subtree and
receives the remaining path as `PATH_INFO`. Two files claiming one route fail
the deploy.

The deploy output prints the resolved route table.

## Markdown and public files

Markdown renders to HTML at publish time. `index.md`, `_index.md`, or
`README.md` can be a directory index, making a documentation tree a static site
with no build step.

A `public/` directory is copied to the site root verbatim. Its files are served
as-is rather than Markdown-rendered.

## fallback routes

For a site with no services, a Kedge Compose extension can set an SPA fallback:

```yaml
x-kedge:
  static:
    fallback: /index.html
```

An unmatched path then serves that static file. Without a fallback, the edge
answers 404 without waking compute.

If repository scripts contain shebangs but are not web handlers, exclude their
directories explicitly:

```yaml
x-kedge:
  static:
    handler-excludes: [scripts, tools]
```

## publishing a tree

`kedge up` deploys a local directory through Git. For a one-off upload, `scp`
the tree to the SSH endpoint; a trailing remote directory names the app. A
single piped page is the smallest case in the [quickstart](/docs/quickstart).
See [deploy workflows](/docs/deploy) for Git history, previews, and other
source paths.

Static responses carry validators and edge-cache headers. Deploying updates the
route manifest and purges stale objects automatically; there is no cache
configuration step. [Network & domains](/docs/network#public-ingress-and-cdn)
describes the global serving path.

---

# HTML apps

Source: https://kedge.dev/docs/html-apps

An HTML app is a web page with `data-kedge` bindings. Kedge compiles its reads,
writes, and schema at deploy time and renders requests against the app's local
database replica. HTML apps also integrate with [app
authentication](/docs/authentication), account controls, and ownership-aware
writes.

## a guestbook

```bash
cat > index.html <<'EOF'
<!doctype html>
<title>Guestbook</title>

<form data-kedge="messages">
  <label>Message <input name="text" required maxlength="280"></label>
  <button>post</button>
</form>

<ul data-kedge="messages?$order=-created_at">
  <template><li>{{text}}</li></template>
  <li data-when=":empty">No messages yet.</li>
</ul>
EOF
kedge up
```

The form creates `messages` records. The list renders its `<template>` once per
record. Native input constraints are enforced on the server. Bound regions
update live; the form remains a normal POST and redirect without JavaScript.

Submitting applies in the page immediately: the new row renders locally,
marked pending until the server confirms it. Offline, writes queue in the
browser and replay on reconnect, and visited pages reload from a local cache.
The [reference](/docs/html-app-reference#local-first-writes) lists the limits.

## HTML and Markdown

Markdown directives are shorthand for the same HTML:

```bash
cat > index.md <<'EOF'
# Guestbook

:::form{bind="messages"}
:input[Message]{name=text required maxlength=280}
:button[post]
:::

:::each{bind="messages?$order=-created_at" as=ul}
{{text}}
::empty[No messages yet.]
:::
EOF
kedge up
```

Markdown lowers to HTML before compilation. Raw HTML remains available in a
Markdown file. The [HTML app reference](/docs/html-app-reference) lists both
forms.

## Kedger News

```bash
mkdir -p item
cat > index.md <<'EOF'
---
title: Kedger News
style: /style.css
---

# [⚓︎](/) [Kedger News](/) :account[login]{provider=github next="/" score="me/votes?author=$viewer&$via=story:stories,comment:comments&$tally"}

:::details[submit]{.submit}
:::form{bind="stories"}
:input[title]{name=title required maxlength=120}
:input[url]{name=url type=url required placeholder="https://…"}
:button[submit]
:::
:::

:::each{bind="stories?$order=-created_at" as=ol .stories}
:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine" .vote aria-label="upvote"}
[{{title}}]({{url}}) _({{url | host}})_

:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}
by :author[{{author.name}}] {{created_at | ago}} |
:value[{{count}} comments]{bind="comments?story={{id}}&$count" href="/item/{{id}}"}

::empty[No stories yet.]
:::

_served from {{$dc.metro}}_
EOF

cat > 'item/[id].md' <<'EOF'
---
title: Kedger News
style: /style.css
---

# [⚓︎](/) [Kedger News](/) [stories](/) :account[login]{provider=github next="/item/{{$url.id}}" score="me/votes?author=$viewer&$via=story:stories,comment:comments&$tally"}

:::record{bind="stories/{{$url.id}}" .story}
:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine" .vote aria-label="upvote"}
[{{title}}]({{url}}) _({{url | host}})_

:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}
by :author[{{author.name}}] {{created_at | ago}} |
:value[{{count}} comments]{bind="comments?story={{id}}&$count" href="#comments"}
:::

:::form{bind="comments?story={{$url.id}}" .submit .comment-form}
:textarea[]{name=text required maxlength=2000 aria-label=comment}
:button[add comment]
:::

:::each{bind="comments?story={{$url.id}}&$order=created_at" as=ol #comments .comments}
:button[▲]{bind="me/votes/self?comment={{id}}&$toggle" when="!:mine" .vote aria-label="upvote comment"}
:value[{{count}} points]{bind="me/votes?comment={{id}}&$tally" when=":mine"}
by :author[{{author.name}}] · {{created_at | ago}}
:button[delete]{bind="comments/{{id}}?$delete" when=":mine" .text-action}

{{text}}

::empty[No comments yet.]
:::
EOF

cat > style.css <<'EOF'
:root{background:#fff;color:#000;font:10pt Verdana,Geneva,sans-serif}
body{position:relative;width:85%;min-width:796px;min-height:calc(100vh - 16px);margin:8px auto;background:#f6f6ef}
h1{display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin:0;padding:2px;background:#0cf;font:inherit;line-height:20px}
h1>a:first-child{display:grid;box-sizing:border-box;width:18px;height:18px;border:1px solid #fff;color:#fff;place-items:center;line-height:1}
h1>a:nth-child(2){font-weight:bold}
h1>a:not(.account):nth-child(3):before{content:"| "}
a{color:inherit;text-decoration:none}
.account{display:inline-flex;align-items:center;gap:4px;margin-left:auto;white-space:nowrap}
.submit{margin:0 8px}
.submit>summary{position:absolute;top:0;left:112px;z-index:1;padding:2px;list-style:none;line-height:20px;cursor:pointer}
.submit>summary:before{content:"| "}
.submit>summary::-webkit-details-marker{display:none}
.submit label{display:grid;grid-template-columns:42px minmax(0,520px);align-items:center;margin:3px 0}
.submit input,.submit textarea{box-sizing:border-box;width:100%;padding:2px 3px;font:inherit}
.submit textarea{min-height:90px}
.submit form[aria-busy="true"]{opacity:.6}
.stories{margin:10px 8px 24px;padding-left:16px}
.stories li{padding:3px 0}
.stories li::marker{color:#828282}
:is(.stories,.story,.comments) p{margin:0}
:is(.stories li,.comments li)>p:first-child,.story>p:first-of-type{position:relative;padding-left:14px}
.stories li>p:first-child,.story>p:first-of-type{padding-bottom:2px}
.stories li[data-when=":empty"]{list-style:none}
:is(.stories li,.story)>p:first-of-type em{color:#828282;font-size:8pt;font-style:normal}
:is(.stories li,.story)>p+p,.stories+p{color:#828282;font-size:7pt}
.comments li>p:first-child{color:#828282;font-size:8pt}
:is(.stories li,.story)>p+p{padding-left:14px}
.stories+p{margin:0 8px 12px;padding-top:10px;border-top:2px solid #0cf}
.user[data-author^="anon-"]{color:#198754;font-weight:600}
.vote,.text-action{border:0;padding:0;background:none;font:inherit;cursor:pointer}
.vote{position:absolute;top:0;left:0;width:12px;color:#828282;text-align:center}
.vote[aria-pressed="true"]{color:#0cf}
.story{margin:10px 8px 14px}
.comment-form{margin-left:22px}
.comment-form label,.comment-form textarea{display:block}
.comment-form textarea{max-width:520px;margin:3px 0}
.comments{margin:0 8px 14px;padding:0;list-style:none}
.comments li{margin-bottom:8px}
.comments p+p{margin-top:2px;padding-left:14px;line-height:1.3;white-space:pre-wrap}
.comments .text-action:before{display:inline-block;content:"| ";text-decoration:none}
.text-action{color:inherit}
.text-action:hover{text-decoration:underline}
@media(max-width:796px){
  :root{background:#f6f6ef}
  body{width:100%;min-width:0;min-height:100vh;margin:0}
  h1{display:grid;grid-template:20px 20px/20px minmax(0,1fr) auto;gap:0 5px;min-height:40px;line-height:normal}
  h1>a:first-child{grid-row:1/3;width:20px;height:20px;align-self:center}
  h1>a:nth-child(2){grid-column:2;grid-row:1;font-size:15px;line-height:20px}
  h1>a:nth-child(3){grid-column:2;grid-row:2;font-size:12px;line-height:18px}
  h1>a:not(.account):nth-child(3):before{content:""}
  .account{grid-column:3;grid-row:1/3;align-self:center;font-size:12px}
  .submit{margin:0 4px}
  .submit>summary{top:20px;left:25px;font-size:12px;line-height:18px}
  .submit>summary:before{content:""}
  .submit label{grid-template-columns:1fr;gap:2px;margin:5px 0}
  .submit input,.submit textarea{width:90%;font-size:16px}
  .stories{margin:8px 8px 16px}
  .stories li,.story>p:first-of-type{font-size:11pt;line-height:14pt}
  :is(.stories li,.story)>p+p{font-size:9pt;line-height:normal}
  .story{margin:8px 4px 12px}
  .comment-form{margin-left:18px}
  .comments{margin:0 4px 12px}
  .comments li{margin-bottom:6px}
}
EOF
kedge up
```

Kedger News includes story submission, discussions, private votes, public
scores, ownership controls, GitHub login, and shared styling.

### how it works

The sections below break `index.md` and `item/[id].md` into the bindings for
stories, votes, discussions, and identity.

#### create stories

```kedge-markdown
:::form{bind="stories"}
:input[title]{name=title required maxlength=120}
:input[url]{name=url type=url required placeholder="https://…"}
:button[submit]
:::
```

The form creates a record and infers `title` and `url` from its controls.

#### render stories

```kedge-markdown
:::each{bind="stories?$order=-created_at" as=ol}
[{{title}}]({{url}}) _({{url | host}})_
by :author[{{author.name}}] {{created_at | ago}}
::empty[No stories yet.]
:::
```

The binding orders records; interpolations and formatters render each row.

#### tally private votes

```kedge-markdown
:button[▲]{bind="me/votes/self?story={{id}}&$toggle" when="!:mine"}
:value[{{count}} points]{bind="me/votes?story={{id}}&$tally"}
```

`me/votes` is private to the current author. `$tally` exposes only the count.

#### bind a discussion route

`item/[id].md` serves `/item/:id`:

```kedge-markdown
:::record{bind="stories/{{$url.id}}"}
# {{title}}
:::

:::form{bind="comments?story={{$url.id}}"}
:textarea[]{name=text required maxlength=2000 aria-label=comment}
:button[add comment]
:::
```

`{{$url.id}}` selects the story and is sealed into each new comment.

#### attach identity

```kedge-markdown
:account[login]{provider=github next="/"}
:button[delete]{bind="comments/{{id}}?$delete" when=":mine"}
```

The account control supplies login and logout. `:mine` gates the control; the
server enforces ownership. Use [app authentication](/docs/authentication) to
protect a route before it reaches the page.

## agents

An agent is a member of the app declared in the page. It runs when records
arrive, on a schedule, or when a viewer finds a record stale; it reads only what
the page grants and writes only the fields it is given, through the same
validators as a form. Its answer appears in the page as it is written.

```html
<template data-kedge="comments" data-agent="Helper"
  data-reads="dishes" data-creates="comments">
  Answer questions about this potluck from the dish list. If a comment is not a
  question for you, write nothing.
</template>
```

The answer is an ordinary record, so `{{author.name}}` says `Helper`, live
regions update when it lands, and the owner can delete it like any other row.
The [reference](/docs/html-app-reference#agents) lists the attributes, the
privacy bound, and the per-app budget.

## escape hatches

Use a SQLite view for a read beyond the binding grammar. Use a
[handler](/docs/handlers) for transactions or domain logic. Both use the
[shared database](/docs/shared-data).

Routes and assets follow the [site model](/docs/sites).

---

# handlers

Source: https://kedge.dev/docs/handlers

A handler is a source file whose first line is a shebang. In a [site](/docs/sites)
it owns the route derived from its path; by itself it becomes a function that
owns every path. The sites guide defines path routing; this page defines handler
metadata and the request contract.

```bash
#!/usr/bin/env bash
# kedge: methods=GET,POST memory=128MiB
printf 'Content-Type: application/json\r\n\r\n'
sqlite3 /shared.db \
  "SELECT json_object('now', datetime('now'))"
```

## inline properties

Handler comments use the same bare [configuration
properties](/docs/configuration#property-reference) as the rest of Kedge. The compact
form stays on one comment:

```bash
# kedge: route=/api/time methods=GET,POST apt=imagemagick memory=128MiB
```

For lists or longer declarations, use the structured form:

```bash
# /// kedge
# route = "/api/time"
# methods = ["GET", "POST"]
# apt = ["imagemagick"]
# memory = "128MiB"
# ///
```

The structured block changes value syntax only; it does not define another set
of keys. The same central registry validates handler and shared properties;
unknown or inapplicable properties fail the deploy.

The shebang chooses the interpreter; the extension is only a fallback hint.
The warm runtimes include `bash`/`sh`, the tree's language, and common tools
such as `sqlite3`, `jq`, and `curl`. Extra packages or mixed languages trigger
a small image build.

## request and response

Handlers use a CGI-shaped contract. The request arrives as environment
variables (`REQUEST_METHOD`, `SCRIPT_NAME`, `PATH_INFO`, `QUERY_STRING`,
`CONTENT_TYPE`, `HTTP_*`) and the body arrives on stdin.

Write optional headers, a blank line, then the body:

```text
Status: 201
Content-Type: application/json

{"ok":true}
```

With no header block, stdout is returned as `text/plain`. A non-zero exit is a
500. Python, Ruby, JavaScript, and TypeScript may instead define
`handler(req)` and return a string or `{status, headers, body}`.

## state and instance identity

Handlers can read and write [`/shared.db` and `/shared/`](/docs/shared-data)
like any service. Four trusted variables describe the instance serving the
request: `KEDGE_DC`, `KEDGE_INSTANCE`, `KEDGE_RESTORE`, and
`KEDGE_RESTORE_MS`. Client-supplied `X-Kedge-*` headers are stripped before a
handler sees them.

Every shape that serves one request at a time gets those as `KEDGE_*`
variables. A runtime that serves requests concurrently keeps per-request values
off the shared process environment, so the JavaScript and TypeScript
`handler(req)` shapes read them from `req.headers` (`x-kedge-dc`,
`x-kedge-restore`) instead.

The response reports the same restore facts to the client. [Instant sandboxes &
scale-out](/docs/performance#see-it-on-a-request) defines those values.

Each handler request runs in a [hardware-isolated VM](/docs/security).
[Runtime & scaling](/docs/runtime) covers snapshots and instance lifecycle.

---

# builds & images

Source: https://kedge.dev/docs/builds

Kedge chooses the smallest build path that matches the source. [Deploy
workflows](/docs/deploy) covers how source reaches this pipeline; this page
covers detection and image construction.

## the build order

1. A repository with a `Dockerfile` builds it and creates an app with one
   implicit service.
2. Otherwise Railpack detects supported source code and builds it.
3. A remaining file tree becomes a [site](/docs/sites); shebang files become
   [handlers](/docs/handlers).

A `compose.yaml` overrides this detection: each service pulls its `image` or
runs its `build` settings, and one file defines several services as one app.
Two services with the same build definition share one build result; change
`command` or `entrypoint` to run that image as a web process and a worker.

## the port contract

Every build path must end with one routable port, declared differently by
each:

| build path | port comes from |
|---|---|
| automatic source build | `$PORT`, which Kedge sets to `8080` |
| Dockerfile | `EXPOSE` |
| prebuilt image | the image's `EXPOSE` |
| Compose | `ports` for public, `expose` for private |

Bind the port Kedge supplies rather than a hardcoded one. A Dockerfile or image
with no `EXPOSE` and no Compose file fails the deploy with `no exposed port
detected`.

A Compose service that declares neither `ports` nor `expose` and has no
persistent volume is a worker instead: no route, and a floor of one instance.
Outside Compose there is no way to declare a worker, so a portless image needs
a `compose.yaml`.

## automatic source builds

Railpack recognizes common language and framework files, installs dependencies,
builds a release artifact, and sets a start command. A web process must listen
on `$PORT`.

A Rust repository needs no Kedge configuration: `Cargo.toml` declares the
build, and Kedge supplies `$PORT`. The [Rust
quickstart](/docs/quickstart#an-app-built-from-source) deploys a complete
example with `kedge up`.

Automatic builds are a good default when the repository already describes its
dependencies and needs no unusual system layout.

## Dockerfiles

Use a Dockerfile when image construction requires native system libraries, an
unusual compiler toolchain, a multi-stage artifact, a nonstandard entrypoint,
or a deliberately minimal final filesystem. The
[quickstart scratch image](/docs/quickstart#a-dockerfile) is the compact case:
compile in a Go image, then copy one static binary into `scratch`.

Dockerfile-only repositories can declare `EXPOSE`, [`VOLUME`](/docs/volumes),
`CMD`, `ENTRYPOINT`, and [`HEALTHCHECK`](/docs/runtime#readiness-and-health);
Kedge derives the corresponding runtime facts after the build. `VOLUME` creates
a one-machine app by default. Use `kedge up --machines` or `--volume-size` for
the machine count or volume limit; no project file or Compose file is needed.
For services defined in Compose, the Compose file describes their topology.

Builds run in isolated builder VMs with a shared BuildKit cache. Compose
supports `context`, `dockerfile`, `dockerfile_inline`, `target`, `args`, and
build labels. Builds target `linux/amd64`.

## prebuilt images

Deploy a public image without source:

```bash
kedge publish --app "$(kedge whoami)-server" --image ghcr.io/example/server:1.2.3
```

Pin a version or digest for reproducible deploys. Image `EXPOSE`, `VOLUME`,
commands, healthchecks, and supported `dev.kedge.*` metadata are read on import.
The [configuration reference](/docs/configuration) lists the equivalent
repository, image, Compose, and handler forms.

Every image is converted into a lazily loaded block format. [Runtime &
scaling](/docs/runtime#lazy-image-loading) explains how blocks are fetched and
shared when instances start.

## build and deploy output

The deploy report keeps the build log, resolved runtime plan, readiness result,
and cutover. `kedge up --dry-run` shows local Git actions; a Compose deploy also
prints the service, network, volume, and compatibility decisions before it
runs. Unsupported Compose fields fail with their exact path instead of being
ignored.

---

# app authentication

Source: https://kedge.dev/docs/authentication

Apps are public by default. Authentication can protect any static site, HTML
app, handler tree, container service, or selected path. Declare a policy in
Compose `x-kedge`, an HTML `meta` tag, or Markdown front matter; the same
fields work in all three.

## common policies

Only you:

```yaml
x-kedge:
  auth: owner
```

People you invite, each emailed a reusable sign-in link:

```yaml
x-kedge:
  auth:
    emails: [friend@example.com]
```

A GitHub organization, gating one HTML page:

```html
<meta name="kedge-auth-github" content="my-org">
```

## policies

| field | admitted identity |
|---|---|
| `required` / `app-users: true` | active app-scoped user |
| `emails: [friend@example.com]` | exact verified mailbox as an app user |
| `owner` | active owning Kedge account |
| `google: true` | any Google identity |
| `google: [person@gmail.com]` | exact verified Google account |
| `google: [example.com]` | exact signed Google Workspace `hd` claim |
| `github: true` | any GitHub identity |
| `github: [octocat]` | exact GitHub login or active organization membership |

Policy members are ORed. Provider selectors stay flat: a Google mailbox such as
`person@gmail.com` is an account, while `example.com` (or the legacy
`@example.com` spelling) is a hosted domain. A GitHub value matches the
authenticated login when equal and otherwise checks active organization
membership.

`emails` is provider-independent. A matching email link, or the same verified
address returned by Google or GitHub, creates an app user. Provider-specific
Google accounts and GitHub accounts or organizations remain workforce
identities and do not create an app-user record.

`auth` gates requests. `identity` leaves the page public and resolves an
optional viewer for `$verified`, `$viewer`, and authored data. All declarations
in one app must admit the same identities.

Compose auth covers every path by default. Auth declared in one HTML or
Markdown page defaults to that page's resolved route. Explicit paths use exact
matches, parameterized page routes, `/**`, or `/prefix/**`, with optional
exceptions.

An email suffix or public organization listing is not sufficient. Invalid
patterns, identities, fields, or an empty policy fail the deploy.
Private GitHub organization membership requires Kedge's verifier to have member
access to that organization. Organization owners control that access.

## request behavior

Protected browser navigations receive a `303` to
`/_kedge/auth/login`. Protected fetches receive status `401`:

```json
{"error":"authentication_required","login_url":"/_kedge/auth/login?next=%2Fadmin"}
```

The login route always shows the available methods.

Login, denied, and authenticated dynamic responses are `private, no-store`.
Sessions are scoped to one app and exact hostname. App-user sessions have a
30-day absolute and seven-day idle lifetime; workforce sessions last eight
hours.

## email invitations

After a successful deploy has a URL, Kedge emails each newly added `emails`
recipient. An unchanged recipient is not emailed again on later deploys. The
link can be reused while the address remains in the policy.

The login page also accepts an allowed address and sends another reusable link.
The response is the same for matching and non-matching addresses. Requests are
limited per address and source. A new request does not invalidate older links.

The token is carried in the URL fragment. The app-origin sign-in page removes
the fragment, redeems it with a same-origin request, sets the app session, and
opens the requested page. A fragment-free GET cannot redeem the token.
Removing a recipient revokes its outstanding links and app-user sessions.

Invitation and sign-in delivery uses the configured transactional email sender.
Pending messages are stored durably and retried by one fleet leader.

## HTML apps

Gate only the page containing this meta element:

```html
<meta name="kedge-auth" content="github">
<h1>Members</h1>
```

A gated page's generated reads, writes, actions, and live updates also require
its admitted viewer.

Use `identity` when the page itself stays public. This guestbook can be read by
anyone, but only GitHub users can post:

```html
<meta name="kedge-identity" content="github">

<a data-when="!$viewer.verified" href="/_kedge/auth/login">sign in to post</a>
<form data-kedge="posts?$verified">
  <textarea name="body" required></textarea>
  <button>post</button>
</form>
<ol data-kedge="posts"><template><li>{{body}}</li></template></ol>
```

Expanded meta properties do not require embedded JSON:

```html
<meta name="kedge-auth-owner" content="true">
<meta name="kedge-auth-emails" content="friend@example.com, teammate@example.net">
<meta name="kedge-auth-github" content="my-org, another-org">
<meta name="kedge-auth-google" content="person@gmail.com, example.com">
<meta name="kedge-auth-paths" content="/admin/**">
<meta name="kedge-auth-except" content="/admin/health">
```

Provider lists are trimmed, lowercased, and deduplicated. `true` admits any
identity from that provider. Unknown properties, `false`, and empty values fail
the deploy. `identity` supports the same identity properties but not `paths` or
`except`.

HTML apps expose `{{$viewer.id}}`, `name`, `verified`, and `owner`.
`$verified` and `$owner` restrict individual bindings. Markdown's `:account`
directive supplies login, identity, and logout controls.

`GET /_kedge/auth/me` returns `{"user":null}` or `id`, `name`, `email`, and
`provider`.

Ingress admission and HTML data ownership are separate. Protecting `/admin`
does not make its collections owner-only. See the
[HTML app reference](/docs/html-app-reference#viewer-identity).

## handlers

Handlers receive verified identity as environment variables:

```bash
#!/usr/bin/env bash
printf 'Content-Type: application/json\r\n\r\n'
jq -n --arg id "$KEDGE_AUTH_SUBJECT" --arg email "$KEDGE_AUTH_EMAIL" \
  '{id: $id, email: $email}'
```

The complete set is `KEDGE_AUTH_SUBJECT`, `KEDGE_AUTH_EMAIL`,
`KEDGE_AUTH_PROVIDER`, and `KEDGE_AUTH_ASSERTION`.

A runtime that serves requests concurrently keeps per-request identity off the
shared process environment. The JavaScript and TypeScript `handler(req)` shapes
therefore read the same identity from `req.headers`:

```js
module.exports = (req) => ({id: req.headers["x-kedge-auth-subject"] || null});
```

## services

Container services receive:

```text
X-Kedge-Auth-Subject: <stable identity>
X-Kedge-Auth-Email: person@example.com
X-Kedge-Auth-Provider: passkey | email | kedge | google | github
X-Kedge-Auth-Assertion: <short-lived Ed25519 JWS>
```

Kedge strips client-supplied `X-Kedge-*` headers and auth cookies before adding
these headers. Verify the assertion when identity crosses another internal hop.
The verification key is at `GET /_kedge/auth/jwks.json`.

## app-user operations

The owner-authenticated [REST API](/docs/api) lists, enables, and disables app
users, reports configured providers, and revokes app sessions. Disabling a user
revokes that user's sessions immediately.

Authentication is ingress authorization, not a role system. Enforce
operation-level authorization in HTML binding audiences or application code.

## Compose configuration

Compose uses the same shorthands and flat structure:

```yaml
x-kedge:
  auth: github
```

A `compose.yaml` containing only `x-kedge` does not create a container service.
Use the structured form to combine identities or select paths:

```yaml
x-kedge:
  auth:
    paths: ["/admin/**"]
    except: ["/admin/health"]
    owner: true
    emails: [friend@example.com]
    google: [person@gmail.com, example.com]
    github: [octocat, my-workspace]
```

`paths` defaults to `["/**"]`; `except` wins. Top-level auth is the default for
every Compose service. A service-level `x-kedge.auth` replaces it.

Optional identity resolution is also available without an ingress gate:

```yaml
x-kedge:
  identity:
    github: true
```

Markdown front matter uses that same structure. With no `paths`, inline auth
applies only to `/members`:

```yaml
---
auth:
  github: [my-org]
---
```

See the [Compose reference](/docs/compose#authentication).

---

# GitHub deploys

Source: https://kedge.dev/docs/github

Connect a repository with `kedge connect`. Pushes to its production branch
deploy automatically; pull requests from branches in the same repository
receive isolated previews.

## connect a repository

You need permission to install a GitHub App for the personal account or
organization that owns the repository.

1. Sign in to Kedge with GitHub.
2. Run `kedge connect`, or choose **connect a repo** from the apps page.
3. On GitHub, choose the repository owner and grant access to the repositories
   you want to use.
4. Back on Kedge, choose a repository and select **connect**.

Kedge imports the default branch and deploys it immediately. The app retains
its Git history, so local pushes, the browser editor, and GitHub webhooks all
feed the same [deploy pipeline](/docs/deploy).

If a repository is missing from the picker, adjust the GitHub App
installation's repository access.

## automatic deploys

After a repository is connected:

- a push to its production branch deploys a new version;
- a pull request from a branch in the same repository gets an isolated preview
  for a single-service app;
- new commits update that preview;
- closing the pull request removes it;
- checks and deployment statuses link to the build log and preview.

The production branch defaults to the repository's default branch. Set
top-level `x-kedge.production-branch` to override it.

Fork pull requests do not deploy because their code is not trusted with the
repository's deployment authority. Multi-service Compose apps deploy their
production branch but do not yet create branch previews.

A failed build or readiness check never takes production traffic. See
[zero-downtime cutover](/docs/deploy#zero-downtime-cutover) and [builds &
images](/docs/builds).

The same App installation can also run the repository's Actions jobs on Kedge.
See [GitHub Actions runners](/docs/github-runners).

## remove access

Removing a repository from the GitHub App installation stops future webhook
deploys. The app remains deployed; use `kedge delete <app>` to remove it.

---

# shared data

Source: https://kedge.dev/docs/shared-data

Every app includes:

| path | use |
|---|---|
| `/shared.db` | replicated SQLite |
| `/shared/` | replicated files |

Open them as local paths. Writes commit locally, converge across instances and
regions, and land durably in object storage. There is no database service or
primary endpoint to provision.

Use these before adding a volume. A [persistent volume](/docs/volumes) is for
software that requires a private local filesystem.

```bash
sqlite3 /shared.db <<'SQL'
CREATE TABLE IF NOT EXISTS notes(id TEXT PRIMARY KEY, body TEXT);
INSERT INTO notes VALUES('one', 'hello')
  ON CONFLICT(id) DO UPDATE SET body=excluded.body;
SELECT body FROM notes WHERE id='one';
SQL
printf 'generated once\n' > /shared/artifact.txt
cat /shared/artifact.txt
```

## multi-writer rules

- Replicated tables need a primary key. Bare `INTEGER PRIMARY KEY` and
  `AUTOINCREMENT` schemas are rewritten to assign sparse, collision-safe
  63-bit values. Recover them with `INSERT ... RETURNING id`, not
  `last_insert_rowid()`.
- An instance reads its own commit immediately. Other replicas converge
  asynchronously, normally in under a second.
- Conflicts resolve deterministically: when two instances update the same row,
  the later write wins whole-row, ordered by a hybrid logical clock. A
  nullable `UNIQUE` column gives the contested value to the later writer and
  nulls it on the losing row.
- `NOT NULL UNIQUE` values and DDL coordinate cluster-wide before the local
  commit, so they can fail with a retryable error while a region is
  unreachable. All other writes keep committing through disconnection and
  converge on reconnect.
- Committed writes reach peer instances within milliseconds and object storage
  typically within a second; losing a host loses at most that window of its
  latest local writes.
- Framework migrations work when each transaction contains one DDL statement
  plus bookkeeping. Split transactions containing several DDL statements.
- Tables whose names begin with `_` stay local by default. Set
  `persistence.replicate-underscore-tables=true` when creating the database to
  include them in replication; the database retains that policy afterward.
- Temporary tables and `:memory:` databases remain local.

The replication engine is [syzy](https://github.com/wjordan/syzy); its
documentation specifies the full conflict model.

## SQLite compatibility

`/shared.db` works transparently with programs that dynamically link the
system `libsqlite3`: Python's stdlib, Ruby's system SQLite gem, the CLI, Rails,
Django, and Go built with `-tags=libsqlite3`.

Bindings that statically embed SQLite cannot be intercepted. Examples include
`better-sqlite3`, Node's built-in `node:sqlite`, `modernc.org/sqlite`, and
`mattn/go-sqlite3` without the system-library build tag. Rebuild against
`libsqlite3.so`, or load the engine explicitly after opening:

```sql
SELECT load_extension('/usr/local/lib/syzy-engine.so', 'sqlite3_syzy_init');
```

## shared files

`/shared/` is a content-oriented POSIX mount for uploads, generated assets, and
small blobs. Advisory locks, `mmap`, and `O_DIRECT` are not supported; put
coordination in `/shared.db`.

Two instances writing one file merge by byte range under the same
last-writer-wins clock; creating the same new path twice keeps one winner.

## mount shared files elsewhere

A shared Compose volume mounts the app's `/shared/` tree at another path:

```yaml
services:
  web:
    volumes: [uploads:/srv/uploads]
  worker:
    volumes: [uploads:/work/uploads]

volumes:
  uploads:
    x-kedge: {shared: true}
```

Both mount points refer to the same replicated file tree. One service may
currently mount one shared volume path.

## named databases

An app's default database follows that app. A named database can outlive and
serve several apps:

```bash
kedge db create mydata
kedge db attach mydata myapp
kedge db attach mydata myworker
```

New instances attach immediately; existing instances pick it up when they
recycle. `kedge db detach myapp` returns that app to its private default. The
app page provides a SQL console for either kind. Run
`kedge db shell myapp 'SELECT …'` from anywhere, or omit `myapp` when
`KEDGE_APP` is set.

The database console gives each submitted statement its own transaction
boundary, and writes replicate atomically. Explicit `BEGIN`, `COMMIT`, and
savepoint wrappers are not supported. Put writes that must commit together in
one statement, such as a multi-row `INSERT` or a CTE.

## previews and forks

On object-backed deployments, a preview's first deploy branches the production
app's current database and files without copying all pages. Re-pushes keep the
preview's changes; deleting and recreating it branches production again. A
source with no stored state starts empty. Local servers without an object
bucket keep the empty-preview behavior.

The branch covers `/shared.db` and `/shared/`, including an attached named
database. Persistent volumes are separate. External database state is not
branched; inherited URLs can still point both apps at the same service.
Explicit forks use the same shared-data branch:

```bash
kedge fork myapp myapp-experiment
```

A static or HTML app published with the `forkable=true` property can be forked
by anyone, signed in or not, into an app they own; the copy is private and
carries the source's data as of that moment.

---

# persistent volumes

Source: https://kedge.dev/docs/volumes

Most apps do not need a volume. Use
[`/shared.db` and `/shared/`](/docs/shared-data) for state shared across
instances and regions.

A persistent volume is a private filesystem for software that expects local
disk, such as Postgres, Redis, or a development environment. Attaching one
makes the service stable: its identity and disk remain together across deploys,
restarts, and host moves.

A workspace uses its persistent volume for the complete writable filesystem.
Package installs and changes outside `/data` survive workspace restarts and
host moves. `/data` remains the conventional project path. See [coding agents
& workspaces](/docs/agents) for the workspace workflow.

## attach a volume

Attach a volume to an automatic source build:

```bash
kedge up --volume /var/lib/myapp --volume-size 20GiB
```

For a prebuilt image:

```bash
kedge publish --app my-database --image postgres:18 \
  --volume /var/lib/postgresql/data \
  --volume-size 20GiB
```

`size` is a sparse logical limit. The volume is seeded from the image path on
first boot.

## Dockerfiles and images

A Dockerfile `VOLUME` creates a one-machine app by default:

```Dockerfile
VOLUME ["/var/lib/postgresql/data"]
```

Use `--machines 0` when an image declares a volume the service does not need:

```bash
kedge publish --app myapp --image example/myapp:1.2 --machines 0
```

Several volume paths for one machine share its underlying store while remaining
separate directories. Dockerfile and image volume declarations seed those
paths unless `volume.nocopy` is set.

## Compose

Use a named volume for a service in a Compose app:

```yaml
services:
  db:
    image: postgres:18
    volumes: [data:/var/lib/postgresql/data]

volumes:
  data:
    x-kedge:
      shared: false
      size: 20GiB
```

`shared: false` gives each machine its own persistent volume. A
[`shared: true` volume](/docs/shared-data#mount-shared-files-elsewhere) mounts
the app's replicated `/shared/` tree instead.

With a persistent volume, `deploy.replicas` sets the machine count. Outside
Compose, use `--machines`:

```bash
kedge up --volume /data --machines 3
```

## machines

A service with a persistent volume runs on **machines**. Machines have ordinals
starting at zero:

- `<ordinal>.<service>.<app>.internal` reaches one machine.
- `<service>.<app>.internal` reaches the whole set.

Each machine has its own volume. Scaling down retains that volume; scaling up
reattaches the same ordinal.

A set runs one machine at minimum. Machines do not scale to zero, because a
machine keeps its address and volume for its lifetime. `min: 0` and
`scale.idle-cooldown` apply only to [pooled instances](/docs/runtime), which
have no persistent volume.

`idle-suspend` makes an idle machine cheap without removing it. Set a delay and
a machine with no inbound traffic pauses in place, keeping its address, volume,
and host; the next packet restarts it. A paused machine bills no CPU and no
memory.

```yaml
services:
  db:
    x-kedge:
      machines: 3
      idle-suspend: 10m
```

Delays under 2s are raised to 2s.

A machine is paused on inbound traffic and guest CPU alone, so a request that
goes quiet on both while it is still being served can be paused mid-flight. It
resumes within seconds and the request completes slowly rather than hanging. To
rule it out, hold a shared lock while you serve:

```bash
flock -s /run/kedge/awake ./handle-request
```

Shared locks do not serialize, so concurrent requests each hold one.

A paused machine wakes on traffic, so declare it only where traffic is what the
machine waits for. Nothing wakes one whose next action is its own timer. Kedge
wakes a paused machine periodically anyway so scheduled work still runs, and
steps its clock forward on each wake.

The same lock, taken exclusively, holds a machine awake through work that must
not be interrupted:

```bash
flock /run/kedge/awake ./nightly-compaction.sh
```

The machine stays up while the lock is held. The kernel releases it when the
process exits, so a job that crashes does not leave a machine awake forever.

List machines or move one to another region:

```bash
kedge machines my-database
kedge machines my-database migrate 0 nrt
```

Kedge handles ordinary restarts and host recovery automatically.

## durability

Volume flushes use the host's local disk (NVMe on production hosts). `fsync`,
ext4 journal commits, and database checkpoints do not wait for object storage.

Kedge publishes local writes to object storage in the background after 1s or
16 MiB. A controlled stop, machine migration, or fork waits until every accepted
write is published before it proceeds. If object storage is unavailable, that
operation fails and leaves the machine in place.

The local writeback limit is 4 GiB per volume by default. If publication cannot
keep up and the limit fills, new writes fail instead of consuming unbounded
host memory. Restarting the daemon on the same host replays the local journal.
After complete host loss, recovery uses the last published remote state.

Check publication state:

```bash
kedge volumes status my-database-0
```

`clean` means every write accepted by the volume backend is present in the
remote manifest. It does not include data still held in the guest filesystem's
page cache. Other states report pending writes, object-store errors, lease
fencing, or a full local journal.

## retention and deletion

Machine volumes are named `<app>-<ordinal>` and never disappear as a side
effect. App deletion, service removal, or scale-down leaves them retained.

List and explicitly delete retained volumes:

```bash
kedge volumes
kedge volumes rm my-database-0
```

Attached volumes and ancestors still used by forks refuse deletion. The
generated [REST volume operations](/docs/api#data) expose the same operations.

---

# instant sandboxes & scale-out

Source: https://kedge.dev/docs/performance

A fresh sandbox is ready in 0.6 ms. App scale-out from a ready warm-pool
instance takes 1.5 ms. When the warm pool is empty, cold scale-out is ready to
accept connections in about 16 ms.

These are production-host measurements from local requests. They exclude
client network latency and application response time.

## ready before demand

Kedge keeps clean sandbox runtimes and initialized app instances ready in warm
pools. They are parked in a paused state until needed. A sandbox request gets a
fresh, hardware-isolated runtime. App scale-out resumes an instance whose app
and network are already initialized.

Neither path repeats a boot or starts the app again.

## when the warm pool is empty

Kedge restores an initialized instance from the
[deploy snapshot](/docs/runtime#snapshots), so cold scale-out does not repeat
app startup.

## see it on a request

App responses report whether that request waited for an instance:

```bash
curl -si https://myapp.kedge.run | grep x-kedge-restore
```

```text
x-kedge-restore: warm
x-kedge-restore-ms: 1.5
```

`x-kedge-restore` is `warm` or `cold` when the request triggered a restore, and
`none` when an instance was already running. `x-kedge-restore-ms` appears only
for a restore and reports how long the instance took to become ready.

See [runtime & scaling](/docs/runtime) for the snapshot lifecycle or
[sandboxes](/docs/sandboxes) for one-shot and interactive use.

---

# security & isolation

Source: https://kedge.dev/docs/security

Every app instance, handler, sandbox, and GitHub Actions runner runs in a
hardware-isolated VM with its own Linux kernel. Container images package the
workload; the VM is the isolation boundary. Workloads do not share the host
kernel.

## VM boundary

An instance's memory and writable root are private to its VM and disappear
with the instance. Only explicit persistent state survives: `/shared.db`,
`/shared/`, or a [persistent volume](/docs/volumes).

Sandboxes and CI runners are destroyed after one run or job. App instances
restore from immutable deploy snapshots and keep changes in private
copy-on-write layers.

## network boundary

Apps join an account-scoped private network. Other accounts cannot initiate
connections to it. Compose `ports` publishes a service through Kedge's HTTPS
edge; `expose` remains private.

Public endpoints terminate TLS automatically. Cross-region private traffic
uses the encrypted fleet mesh. Sandboxes have outbound access by default;
`kedge eval -no-network` and `kedge sandbox -no-network` leave only loopback.
Anonymous sandboxes are always networkless.

See [network & domains](/docs/network) for ingress and private addressing.

## access and secrets

SSH keys authenticate CLI and Git operations. GitHub authenticates the web
console; expiring bearer tokens authenticate the [REST API](/docs/api) and can
be restricted to named apps and operations. App, repository, data, and runtime
operations enforce the owning account.

Coding-agent projects within one workspace share its VM and filesystem. Use
separate named workspaces when projects need isolation from each other.

An unknown verified SSH key starts a guest session. Run `login` there to link
that key to your web account and keep its apps.

An [app authentication policy](/docs/authentication) protects any static site,
HTML app, handler, or service at ingress and passes verified identity to the
workload.

Keep secrets out of source with `kedge env` or Compose secrets. Generated
secrets are stored outside Git and mounted under `/run/secrets`; see [secrets
and configs](/docs/compose#secrets-and-configs).

Report security issues to
[support@kedge.dev](mailto:support@kedge.dev).

---

# runtime & scaling

Source: https://kedge.dev/docs/runtime

Kedge does not boot your app to serve a request. It restores a snapshot taken
after the app finished initializing, so scaling from zero costs a restore
rather than a startup.

## autoscaling

Public services scale from zero by default. Kedge adds instances as requests
arrive and removes them after they become idle.

`scale.min`, `scale.max`, `scale.concurrency`, and `scale.idle-cooldown`
override that behavior. Recent demand also warms capacity ahead of bursts and
nearby regional forwarding. See the
[configuration reference](/docs/configuration) for exact fields.

## readiness and health

The [`listen()` boundary](#snapshots) proves a server reached its socket. A
Compose or image `HEALTHCHECK` adds an in-guest exec probe; deploy and
replacement wait for it before reporting ready.

[Deploy workflows](/docs/deploy#zero-downtime-cutover) explains how readiness
gates traffic cutover and rollback.

## workers and persistent services

A portless service is a worker and keeps a floor of one. It restarts work after
a deploy; durable jobs belong in its queue, not memory.

A service with a [persistent volume](/docs/volumes) runs on machines
instead of following request demand.

## warm and cold starts

- **Warm resume:** a pre-restored paused clone resumes; networking and identity
  are already attached.
- **Cold restore:** a new clone restores from the deploy snapshot on demand.
- **First boot:** a host runs the image and creates its local snapshot once.

Restore cost is mostly fixed platform work rather than workload startup.
Reading [`/shared.db`](/docs/shared-data) pages during wake can add faults from
object storage.

[Instant sandboxes & scale-out](/docs/performance) explains the measured
latency of the ready and cold paths.

## snapshots

Kedge boots a new service once and traps the moment it calls `listen()`. The
process has initialized but has not accepted a connection. The VM freezes at
that boundary as the deploy snapshot.

Later instances restore after framework startup, not before it. Memory and
disk are copy-on-write, so clones share clean pages.

## lazy image loading

Image blocks load only when the process reads them. A fresh host does not
download the whole image before boot. Blocks are cached per host and shared by
every clone, so untouched image data is never fetched.

---

# network & domains

Source: https://kedge.dev/docs/network

An app is reachable over HTTPS the moment it deploys, joins a private account
network, and serves its static files from every edge. None of that requires
configuration. Workers and private services are the exception: they keep the
account network and skip the public hostname.

## HTTPS and domains

A public app gets `https://<name>.kedge.run`; previews use
`https://<name>--<branch>.kedge.run`. Certificates are issued and
replicated automatically.

For a custom domain, add it on the app page or with `kedge domain add`, then
create the shown ownership TXT record and CNAME or apex A/AAAA/ALIAS records.
`kedge domain check` reports verification and DNS diagnostics. Kedge issues the
certificate when verification passes.

To register a new name rather than attach one you already own:

```bash
kedge domains search notes
kedge domains buy notes.example --app myapp
```

Registration draws on your credit balance and attaches the domain to the app.
New registrations auto-renew; `kedge domains autorenew <name> off` stops that,
and `kedge domains list` and `kedge domains renew` cover the rest.

## public ingress and CDN

Sites, handlers, and single-service apps are public at the app's HTTPS
hostname by default. In Compose, `ports` publishes a service there and
`expose` keeps it private. Services with persistent volumes are private by
default; put a public service in front of them, or set the
[`public` property](/docs/configuration) to route the app hostname to their
primary port.

Static site files and the static half of handler sites are cached at every edge
with `ETag` support. Known route misses return 404 without waking a VM. Deploys
purge changed paths automatically; `X-Cache` shows edge hits. See [sites &
routing](/docs/sites) for route-tree and fallback behavior.

## private networking

Apps join the account network by default. Sibling services resolve by bare
name, and services in your other apps resolve under `.internal`. Other accounts
and the public internet cannot initiate traffic.

| name | target |
|---|---|
| `<service>` | sibling service in the same app |
| `<service>.<app>.internal` | service in one of your apps |
| `<app>.internal` | single-service app |
| `<ordinal>.<service>.<app>.internal` | one machine |

Autoscaled services use a stable virtual address that wakes an instance on
connect. Machines keep one private address across pause, migration, and
re-home.

An autoscaled instance cannot yet open connections on the account network
itself. It resolves public app hostnames, not `.internal` names, so an
autoscaled service calling a sibling reaches it at the sibling's app hostname.
Machines and workspaces reach both. A service that must be called
privately by name should declare a persistent volume or `machines`, which
deploys it on machines.

Explicit project networks are not yet supported. Compose may omit `networks`
or attach every service to the reserved external network named `kedge`. See
the [Compose reference](/docs/compose#networks).

Static addresses, host networking, custom network drivers, and cross-account
networks are not exposed.

## global routing

Latency-aware DNS and stable anycast addresses route requests to a nearby edge.
Static files serve locally; handler and service requests use the encrypted
fleet mesh to reach warm capacity. Autoscaled instances follow demand. Each
machine runs in one region at a time and can move without changing
identity.

See [global coverage](/docs/coverage) for compute regions and measured network
latency.

---

# global coverage

Source: https://kedge.dev/docs/coverage

Latency-aware routing sends each client to a nearby edge; autoscaled instances
follow demand across ten compute regions, plus an edge point of presence in
Los Angeles.

The combined map colors each sampled city by its best measured round-trip time
to any region. Select a region to see its reach, or hover a city for its
measurements.

(An interactive latency map is available at /docs/coverage.)

## regions

| code | location |
|---|---|
| `fsn` | Falkenstein, Germany |
| `hel` | Helsinki, Finland |
| `vin` | Vint Hill, Virginia, US |
| `hil` | Hillsboro, Oregon, US |
| `lax` | Los Angeles, California, US (edge only) |
| `gru` | São Paulo, Brazil |
| `nrt` | Tokyo, Japan |
| `sin` | Singapore |
| `bom` | Mumbai, India |
| `syd` | Sydney, Australia |
| `cpt` | Cape Town, South Africa |

## map data

The map uses WonderNetwork city-to-city ping data, with historical samples
filling links the current scrape misses. Color between sampled cities is
estimated and fades to “no data” where measurements are sparse. This is
network geography, not a live browser test or latency guarantee.

See [network & domains](/docs/network) for HTTPS, private networking, and CDN
behavior.

---

# sandboxes

Source: https://kedge.dev/docs/sandboxes

One line of code, one fresh VM:

```bash
echo 'print(6*7)' | kedge eval python3
```

`kedge eval <runtime>` runs stdin in a fresh VM, streams stdout and stderr, and
exits with the code's status. The VM is destroyed afterward; nothing is deployed
or persisted.

Each run has the same [hardware isolation](/docs/security) as an app instance.
See [instant sandboxes & scale-out](/docs/performance) for restore latency.

## runtimes

| runtime | interpreter |
|---|---|
| `python3` | Python 3.12 |
| `node20` | Node.js 20 |
| `ruby` | Ruby 3.3 |
| `bash` | Bash |
| `deno` | Deno, TypeScript |

Every image also carries the [handler runtime](/docs/handlers) tools: `sqlite3`,
`jq`, `curl`, and `uptime`. A run gets 256 MiB of memory and 30 seconds; the
[REST eval operation](/docs/api#sandboxes) takes a `timeout` override.

## network access

Sandboxes have outbound network access by default. `-no-network` on `kedge eval`
or `kedge sandbox` leaves the VM with only a loopback interface. Anonymous runs,
including the front-page console, are always networkless regardless of what the
request asks for.

## interactive sandboxes

`kedge sandbox python3` drops you into a shell inside a fresh VM, and
`kedge shell -` does the same without naming a runtime. The VM is destroyed when
you exit.

## held sandboxes

To run several commands against one VM, open a sandbox and address it by ID:

```bash
id=$(curl -sX POST https://kedge.dev/api/sandboxes \
  -H "Authorization: Bearer $KEDGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"runtime":"node20"}' | jq -r .id)

curl -sX POST "https://kedge.dev/api/sandboxes/$id/exec" \
  -H "Authorization: Bearer $KEDGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cmd":["node","-v"]}'

curl -sX DELETE "https://kedge.dev/api/sandboxes/$id" \
  -H "Authorization: Bearer $KEDGE_TOKEN"
```

A sandbox expires 5 minutes after its last command; `ttl_seconds` at creation
sets a different deadline, up to an hour. One command is capped at 10 minutes,
and an account can hold only so many at once: past the cap a create fails with
a limit error rather than queuing. Expiry destroys the VM, so a forgotten ID
costs nothing.

An ID names the host holding the sandbox, so any node in the fleet answers a
call for it and `GET /api/sandboxes` lists your sandboxes fleet-wide. The VM
lives in that host's memory, so it does not survive that host's restart.

`POST /api/eval` and `POST /api/sandbox/exec` stay the shorthand when one command
is the whole job: they create, run, and destroy in a single call and leave no
identifier behind.

A sandbox has no ingress, and its disk is gone when it expires. Persistent
interactive work belongs in a [workspace](/docs/agents), which keeps its full
writable filesystem. [GitHub Actions runners](/docs/github-runners) apply the
same one-run boundary to CI jobs.

---

# logs & metrics

Source: https://kedge.dev/docs/observability

Logs, metrics, events, and metering are available for every app without an
agent or sidecar.

## logs

```bash
kedge logs myapp
```

The stream merges stdout and stderr from every instance and region. `-tail N`
and `-since 5m` scope it, `-no-follow` prints and exits, and a trailing term
filters records. The app page exposes the same live and archived stream.

## metrics

The app metrics page graphs requests, errors, latency percentiles, instance
counts, restores, CPU, memory working set, and egress.

```bash
kedge metrics myapp
kedge metrics myapp -window 15m -json
```

The REST metrics subtree is PromQL-compatible, so Grafana and other Prometheus
clients can query the same stored series. See the generated
[apps operations](/docs/api#apps).

## events and webhooks

The activity feed records deploys, failures, expiry, billing warnings, and
other account events. It can be followed live in the web UI or as an SSE
stream. Webhooks push the same curated events to an endpoint you control and
sign each delivery with HMAC.

## usage

The [billing page](/billing) and `kedge billing` show balance, live run-rate,
and recent usage. The [pricing guide](/docs/billing) explains what each meter
counts; the REST account operations expose the same data for automation.

---

# pricing & billing

Source: https://kedge.dev/docs/billing

Kedge meters the resources an app actually consumes per second. The public
preview includes a limited free trial; the rates on this page are estimates for
future paid service and may change.

## resource limits and billing

The `cpus`, `memory`, and `volume.size` settings are optional guardrails. They
set an upper limit on what an app may consume; they do not reserve capacity or
create a fixed-price instance. Billing measures runtime use beneath those
limits:

- **CPU:** time your code is scheduled on a core. Waiting for I/O accrues
  nothing.
- **Memory:** pages touched recently, not the configured limit. Resident memory
  is used when working-set measurement is unavailable.
- **Egress:** bytes leaving the app.
- **Storage:** bytes stored. Sparse databases and volumes bill written data,
  not their logical limit.

For example, an app limited to 4 vCPUs, 8 GiB of memory, and 100 GiB of storage
but averaging 0.25 vCPU, a 400 MiB working set, and 2 GiB stored is billed for
the latter amounts.

A scaled-to-zero app has no running CPU or memory usage, so it accrues only its
stored-data cost. A machine paused by [`idle-suspend`](/docs/volumes) is the
same: it accrues only stored data while paused.

## rates

| resource | rate |
|----------|------|
| CPU | $15 / vCPU-month ($0.0205 / vCPU-hour) |
| Memory | $5 / GiB-month ($0.0068 / GiB-hour) |
| Storage | $0.05 / GiB-month |
| Egress | $0.01 / GiB |

A vCPU-month is 730 vCPU-hours. Billing is per second; the monthly figure is a
reference scale.

## the free tier

Every account includes $5 of monthly usage, consumed before
paid credit.

## coding-agent model usage

Coding-agent sessions using Kedge's model include a separate
$2 monthly allowance. Input, cached input, and output tokens
are charged at the selected model's rate and itemized in the billing ledger.
Exhausting this allowance stops new model work without consuming the resource
credit that keeps apps running.

## credits and enforcement

Usage draws down your credit balance. The [billing page](/billing) shows the
balance, trailing-hour and trailing-day run rate, 30-day burn-down, and
transaction ledger.

Kedge notifies you below $1 and suspends the account at zero; apps stop and
data remains. During beta, email
[support@kedge.dev](mailto:support@kedge.dev) for more credit.

Check the same live summary from a shell:

```bash
kedge billing
```

For automation, use the generated [account REST operations](/docs/api#account).

---

# HTML app reference

Source: https://kedge.dev/docs/html-app-reference

HTML is canonical. `data-kedge` binds an element to the app database. Markdown
directives lower to the same HTML before compilation.

## page access

Gate one HTML page with a shorthand:

```html
<meta name="kedge-auth" content="github">
```

Omitted paths mean the current resolved route. Expanded properties mirror the
flat Compose policy:

```html
<meta name="kedge-auth-owner" content="true">
<meta name="kedge-auth-github" content="my-org, another-org">
<meta name="kedge-auth-google" content="example.com">
<meta name="kedge-auth-paths" content="/admin/**">
<meta name="kedge-auth-except" content="/admin/health">
```

Use `kedge-identity` or `kedge-identity-*` instead when the page stays public
but its `$verified` bindings need a signed-in viewer. Identity does not accept
path fields. A gated page automatically restricts its generated data
capabilities to the admitted viewer.

See [app authentication](/docs/authentication) for provider and Compose forms.

## bound regions

A collection binding repeats one direct child `<template>`:

```html
<ol data-kedge="stories?$order=-created_at&$limit=20">
  <template>
    <li><a href="{{url}}">{{title}}</a></li>
  </template>
  <li data-when=":empty">No stories yet.</li>
</ol>
```

A single-record binding uses the bound element's body directly:

```html
<!-- item/[id].html serves /item/:id -->
<article data-kedge="stories/{{$url.id}}">
  <h1>{{title}}</h1>
  <p data-when=":empty">Story not found.</p>
</article>
```

Lists require exactly one direct `<template>`; records must not contain one.
`data-when=":empty"` is a direct child and cannot contain another binding.

## binding grammar

```text
[me/]<collection>[/<id>][?filters&$modifiers]
```

Names use letters, numbers, and underscores and cannot start with a number.

| target | meaning |
|---|---|
| `stories` | public collection |
| `stories/abc` | public record with a literal ID |
| `stories/{{$url.id}}` | public record selected by a route or query parameter |
| `stories/{{id}}` | public record selected by the enclosing row |
| `me/votes` | records authored by the current viewer |
| `me/preferences/self` | one viewer-owned record for a fixed field tuple |

`{{field}}` is valid only inside a record template. `{{$url.name}}` comes from a
parameterized route or request query.

### filters

```text
stories?kind=link
comments?story={{$url.id}}
events?starts_at.gte=$now
```

Comparison suffixes are `.gt`, `.gte`, `.lt`, `.lte`, `.ne`, and `.in`; `.in`
accepts up to 32 comma-separated values. Values may be literals,
`{{$url.name}}`, `$viewer`, `$now`, or row-local `{{field}}` for equality.

URL parameters with an empty value are ignored. A missing `$viewer` suppresses
a viewer-dependent query rather than broadening it.

### list and pagination modifiers

| modifier | meaning |
|---|---|
| `$order=field` | ascending order |
| `$order=-field` | descending order |
| `$limit=n` | return 1 through 1000 rows |
| `$after=cursor` | continue after an opaque signed cursor |
| `$count` | return one record with `count` |
| `$count&$group=field` | return `field` and `count` for each group |

Ordering defaults to `id`, which is also the stable tiebreaker. A direct
`data-when=":more"` child receives `{{$next}}`:

```html
<ol data-kedge="stories?$order=-created_at&$limit=20&$after={{$url.after}}">
  <template><li>{{title}}</li></template>
  <li data-when=":more"><a href="?after={{$next}}">more</a></li>
</ol>
```

Dynamic `$order={{$url.sort}}` is accepted only when a `select`, radio group, or
button group named `sort` enumerates 1 through 32 allowed order values.

### viewer responses and aggregates

For shared ratings, votes, polls, reactions, and rankings, store items separately
from viewer responses. Use `me/responses/self?item={{id}}` so a later submission
replaces that viewer's response; a public response form or hidden item input
creates another row on every submit. `$tally` exposes a count, and `$tally=field`
groups by an enumerated managed-form field:

```html
<ul data-kedge="items"><template><li>{{name}}
  <form data-kedge="me/ratings/self?item={{id}}"><select name="score" required><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option></select><button>rate</button></form>
  <ul data-kedge="me/ratings?item={{id}}&$tally=score"><template><li>{{score}}: {{count}}</li></template><li data-when=":empty">No ratings</li></ul>
</li></template></ul>
```

```text
me/votes?author=$viewer&$via=story:stories,comment:comments&$tally
```

`$via` joins up to four `field:collection` pairs. It requires a filter and an
ungrouped tally.

Nested reads use one `field={{field}}` filter with public `$count` or private
`$tally`. All parent rows are grouped into one query.

### audiences

`$verified` and `$owner` restrict a read or managed write. Read and write
audiences are independent:

```html
<form data-kedge="posts?$verified">
  <textarea name="body" required></textarea>
  <button>post</button>
</form>

<ol data-kedge="posts?$order=-created_at">
  <template><li>{{body}}</li></template>
</ol>
```

Everyone can read `posts`; only signed-in viewers can submit the form. Every
read of one collection must use the same audience, and every write must use the
same audience. Public and `me/` spelling must also remain consistent.
[App authentication](/docs/authentication) is a separate ingress check.

## interpolation

| value | meaning |
|---|---|
| `{{field}}` | record field |
| `{{author.name}}` | managed author display name |
| `{{$url.name}}` | route or query parameter |
| `{{$viewer.id}}`, `{{$viewer.name}}` | current app identity |
| `{{$viewer.verified}}`, `{{$viewer.owner}}` | identity facts |
| `{{$dc.code}}`, `{{$dc.metro}}` | serving datacenter |
| `{{$page.path}}`, `{{$page.dir}}`, `{{$page.slug}}` | current page |
| `{{$now}}` | server time in UTC |

All values are escaped. Interpolation is rejected in `<script>`, `<style>`, and
event-handler attributes. A context value in `href` or `src` needs a fixed
relative, `http`, `https`, or `mailto` prefix.

| expression | result |
|---|---|
| `{{created_at \| ago}}` | relative time |
| `{{created_at \| date}}` | calendar date |
| `{{url \| host}}` | lowercase hostname without `www.` |
| `{{count \| number}}` | grouped integer |
| `{{count \| plural:reply}}` | count and singular/plural noun |
| `{{text \| truncate:80}}` | at most 80 characters plus an ellipsis |
| `{{body \| markdown}}` | sanitized stored Markdown |

`markdown` is element-content only. It drops raw HTML and unsafe URLs and does
not run app directives.

## conditional content

Outside rows, `data-when` accepts `$viewer.verified`, `!$viewer.verified`,
`$viewer.owner`, and `!$viewer.owner`. Record templates also accept:

| condition | meaning |
|---|---|
| `:mine` | current viewer authored the record |
| `!:mine` | current viewer did not author the record |
| `field` | field is non-empty |
| `!field` | field is empty |
| `field=value` | field equals the literal value |

`:empty`, `:more`, and `:error` are region and form states.

## managed forms

```html
<form data-kedge="comments?story={{$url.id}}">
  <textarea name="text" required maxlength="2000"></textarea>
  <button>comment</button>
  <p data-when=":error">{{$error}}</p>
</form>
```

Kedge supplies `method`, `action`, CSRF protection, and the write capability.
Do not set `action`; an explicit method must be POST.

| target | operation |
|---|---|
| `comments` | create a record |
| `comments/<id>` | update a record |
| `comments/{{id}}` | update the enclosing row |
| `me/preferences/self` | create or replace the viewer's fixed-tuple record |

Equality filters are sealed setters. Writes return to the current page;
`$return=/path` overrides the redirect. Native controls infer fields:

| control | inferred field |
|---|---|
| text, hidden input, or textarea | text |
| `type=url` | absolute URL |
| `type=number` | integer, or number when `step` allows fractions |
| select, radio, named button | enumerated value |
| `type=file` | replicated image URL |

Server-enforced constraints are `required`, `minlength`, `maxlength`,
`pattern`, `min`, `max`, `step`, and enumerated values. Forms set at most 32
fields; `id`, `created_at`, `updated_at`, and `author` are reserved.

File fields accept GIF, JPEG, PNG, or WebP values from 1 byte through 8 MiB.
The stored value works directly in `src="{{photo}}"`. All submitted field
values together may use at most 64 KiB, excluding file bytes.

Rejected submissions preserve values and render `{{$error}}` in the
`data-when=":error"` branch. Kedge adds the branch when omitted.

Authored records are mutable by their author. The app owner may delete any
managed record and update records with no author. Redirects wait briefly for
the committed database frontier.

## action buttons

```html
<strong data-kedge="counters/home">{{visits}}</strong>
<button data-kedge="counters/home?$increment=visits">add one</button>
```

`$increment` requires a literal public record and creates an
`INTEGER COUNTER NOT NULL DEFAULT 0`. Each repeatable action adds one;
concurrent increments merge.

```html
<button data-kedge="comments/{{id}}?$delete" data-when=":mine">delete</button>
```

```html
<button
  data-kedge="me/votes/self?story={{id}}&$toggle"
  data-when="!:mine">vote</button>
```

`$toggle` requires one `field={{field}}` filter. Kedge manages the viewer-owned
row and `aria-pressed`; `$return`, `$verified`, and `$owner` also apply. Inside a
repeated record, use this action for binary participation or the `self` form
pattern above for a score or choice.

## agents

A top-level `<template data-kedge="…" data-agent="Name">` makes a member of the
app that runs on a trigger, reads what the page grants, and writes through the
same validators as a managed form. The template text is its instruction;
`{{field}}` interpolates the attached record. Nothing in the render path calls a
model, and a form submission never waits on an agent.

```html
<template data-kedge="dishes" data-agent="Curator" data-writes="kind note">
  Classify each dish as main, side, dessert, or drink in kind. If it duplicates
  another dish, say which in note; otherwise leave note empty.
</template>
<template data-kedge="digest/today" data-agent="Planner" data-every="1d"
  data-reads="dishes" data-writes="body">
  One paragraph: what is covered, what is missing, who should bring what.
</template>
```

| attribute | meaning |
|---|---|
| `data-kedge="collection"` | run once for each new record; `field=value` filters and `$verified`/`$owner` narrow the trigger |
| `data-kedge="collection/id"` | a public record the agent keeps current |
| `data-kedge="me/collection/self"` | one record per viewer the agent keeps current |
| `data-agent="Name"` | the member name shown as author of records it creates; unique per app, at most 8 agents |
| `data-reads="binding https://…"` | space-separated bindings and `https://` sources it may read; a collection attachment defaults to its own binding |
| `data-writes="field field"` | fields it may fill on the attached record; a field only an agent writes is text |
| `data-creates="collection"` | collections with a managed form it may author records in |
| `data-every="1d"` | regenerate a record target on an interval, traffic or not |
| `data-max-age="1d"` | regenerate a record target on view when missing or older than this |

A record target needs exactly one of `data-every` and `data-max-age`; a
collection attachment takes neither. Intervals use `m`, `h`, or `d`, from `10m`
to `30d`. An agent attached to a public binding reads only public collections;
one attached to a `me/` binding also reads that viewer's `me/` collections. A
`https://` source is fetched by the platform before the run, `GET` only, from a
fixed public host; `{{field}}` in a source URL must come from an enumerated,
numeric, or `pattern` field. Output that violates a field constraint is rejected
and retried once; a failed run leaves the record as submitted. Agents never
delete, never run on records authored by an agent, and never see app settings
or environment.

Runs are metered to the owner's model allowance, capped per app by the
`agent.budget` property (`$1` a month unless set; `kedge up --agent.budget 5`
sets it on deploy) and at 60 runs an hour per agent; a spent budget pauses
agents while the app keeps recording. Each run
appears on the app's activity page with its cost. A just-submitted row is
`data-kedge-pending` until the server render replaces it, and a record target
renders its `:empty` branch until the first run lands; the live channel
delivers the result. While a run is answering, each granted field shows the
text so far to viewers whose live connection is on the node that took the
trigger, rendered through the same template; the committed value replaces it
everywhere. Nothing partial is stored.

`data-agent`, `data-reads`, `data-writes`, `data-creates`, `data-every`, and
`data-max-age` are reserved alongside `data-kedge` and `data-when`; any other
`data-*` attribute is yours.

## schema and storage

Managed tables receive `id`, `created_at`, `updated_at`, and `author` columns.
Schema inference is additive; deploy does not drop, retype, or tighten fields.

SQLite views are read-only and provide their own indexes. Handlers and services
can use the same [`/shared.db`](/docs/shared-data).

On a managed create, Kedge removes authored rows older than one year and rejects
the write when the collection already has 100,000 authored records. Rows
created directly through SQL are not part of those browser-write limits.

## viewer identity

A first authored write creates an anonymous app identity; reads and public
counters do not. `me/`, `:mine`, authored writes, and sign-in migration use it.
See [app authentication](/docs/authentication) for verified identity and
ingress.

## live updates

Read bindings use one signed event stream for server-rendered fragments.
Managed forms use `fetch` when available and POST/303 otherwise. Static assets
remain cacheable; rendered data pages are `private, no-store`.

## local-first writes

Submissions queue in the browser and replay in order when the app is
reachable. A create renders its row in the page at once, marked
`data-kedge-pending` until the server render replaces it; style pending rows
through the attribute. Each create carries a client-generated id, so a
replayed submission never duplicates a record. Visited pages are cached for
offline reload with their last-rendered data plus pending writes. Aggregates,
single-record pages, and templates that use fields the form does not set wait
for the server render. Erasing your data also clears the local cache and
queue.

## Markdown shorthand

Markdown source accepts `title`, `style`, `auth`, and `identity` front matter.
Auth and identity use the same shorthand or flat object as Compose. Without
`title`, the first level-one heading supplies the browser title.

```yaml
---
identity: github
---
```

```yaml
---
auth:
  github: [my-org, another-org]
---
```

| Markdown | canonical HTML |
|---|---|
| `:::record{bind="…"}` | `<article data-kedge="…">` |
| `:::each{bind="…" as=ol}` | bound `<ol>` with `<template><li>` |
| `:::form{bind="…"}` | `<form data-kedge="…">` |
| `:::details[summary]` | `<details><summary>…` |
| `::empty[text]` | direct `data-when=":empty"` list item |
| `:value[text]{bind="…"}` | `<span data-kedge="…">` |
| `:button[text]{bind="…"}` | managed `<button data-kedge="…">` |
| `:input[label]{name=…}` | native labeled `<input>` |
| `:textarea[label]{name=…}` | native labeled `<textarea>` |
| `:author[text]` | author span using `{{author.name}}` |
| `:account[login]{provider=… next=…}` | login, identity, and logout controls |

Attributes pass through. `.class` and `#id` set class and ID; `bind` and `when`
become `data-kedge` and `data-when`. Raw HTML remains available.

---

# configuration

Source: https://kedge.dev/docs/configuration

Kedge derives configuration from source, image metadata, and platform defaults.
Most apps need no Kedge-specific configuration:

```bash
kedge up
```

The properties below set Kedge-specific behavior or override detected values.

## setting properties

When the same property appears more than once, the higher row wins:

| location | example |
|---|---|
| deployed app | `kedge up --memory 1GiB` |
| source | Compose `x-kedge: {memory: 1GiB}` or `# kedge: memory=128MiB` |
| image | `EXPOSE 8080` or `LABEL dev.kedge.memory="1GiB"` |
| automatic | framework detection and platform defaults |

Deployed-app values persist across later deploys. Source and image values
travel with those artifacts and act as defaults. Use standard Compose and
Dockerfile fields where they apply; `x-kedge`, handler comments, and image
labels cover Kedge-specific properties.

The reference uses bare names such as `memory`, `scale.max`, and `volume.size`.
`kedge up` flags replace dots with hyphens, with `--volume` as shorthand for
`volume.path`. Environment names use `KEDGE_` plus uppercase words and
underscores; Git push options and image labels add `kedge.` and `dev.kedge.`
respectively. Compose uses nested YAML, omitting `volume.` or `secret.` when
the surrounding object identifies the resource.

Each of these sets the same deployment value:

```bash
kedge up --memory 1GiB
KEDGE_MEMORY=1GiB kedge up
git push -o kedge.memory=1GiB kedge main
```

A command-line value wins over an environment variable.

CPU, memory, and volume sizes are limits, not reservations. See [pricing &
billing](/docs/billing#resource-limits-and-billing).

## property reference

`format` describes the accepted value independently of where the property is
written. Booleans use `true` or `false`. Sizes accept units such as `MiB` and
`GiB`; a bare size is MiB. Durations use values such as `30s`, `5m`, or `1h`.
In environment variables, push options, image labels, and shorthand handler
comments, lists are comma-separated and objects are JSON. Compose and
structured handler blocks use YAML-style lists and objects.

### service resources and ingress

Optional resource limits and public routing for an application service.

| property | format | example | purpose |
|---|---|---|---|
| `cpus` | integer (≥ 1) | `2` | maximum vCPUs the service may use at once |
| `memory` | size | `1GiB` | maximum memory the service or handler may use |
| `port` | integer (1–65535) | `8080` | primary TCP port reached by the app route |
| `public` | boolean | `true` | give the primary port a public HTTPS route |

### machines and volumes

Choose pooled or stable execution and configure persistent storage.

| property | format | example | purpose |
|---|---|---|---|
| `idle-suspend` | duration | `10m` | idle delay before a machine suspends in place; unset keeps machines always running |
| `machines` | integer (≥ 0) | `3` | run the service on machines and set their count; 0 explicitly selects an instance pool |
| `volume.path` | absolute path | `/data` | exclusive state mount; selecting it implies machines |
| `volume.shared` | boolean | `true` | share one filesystem across pooled instances; false gives each machine a private volume |
| `volume.size` | size | `20GiB` | logical storage limit for each sparse volume |

### database persistence

Optional behavior for Kedge's replicated SQLite persistence.

| property | format | example | purpose |
|---|---|---|---|
| `persistence.replicate-underscore-tables` | boolean | `true` | include underscore-prefixed SQLite tables in replication |

### instance pools

Optional controls for disposable, scale-to-zero service instances.

| property | format | example | purpose |
|---|---|---|---|
| `scale.concurrency` | integer (≥ 1) | `8` | requests admitted per pooled instance |
| `scale.idle-cooldown` | duration | `30s` | idle delay before a pooled instance reaps |
| `scale.max` | integer (≥ 1) | `20` | maximum disposable instances |
| `scale.min` | integer (≥ 0) | `0` | minimum warm disposable instances |

### projects, sites, and secrets

Settings for reusable projects, static trees, generated secrets, and production branches.

| property | format | example | purpose |
|---|---|---|---|
| `auth` | required, owner, github, google, or object | `{"github":["my-org"]}` | require a configured identity at ingress |
| `identity` | required, owner, github, google, or object | `{"github":true}` | resolve an optional viewer identity without gating ingress |
| `inputs` | object | `{"color":{"default":"blue"}}` | fields requested by a shared-app deploy form |
| `production-branch` | string | `stable` | branch deployed to production |
| `secret.generate.bytes` | integer (≥ 1) | `32` | random bytes generated once and retained outside source |
| `static.fallback` | absolute path | `/index.html` | file served for unmatched static routes |
| `static.handler-excludes` | list | `scripts,tools` | directories whose shebang files remain static files |

### agents

Limits for agents declared in HTML app pages.

| property | format | example | purpose |
|---|---|---|---|
| `agent.budget` | dollars | `0.50` | monthly model spend cap for the app's agents; unset means $1 |

### sharing

Who may copy the app.

| property | format | example | purpose |
|---|---|---|---|
| `forkable` | boolean | `true` | let any account fork this app's source and data |

### handlers

Routing, runtime, package, and request-execution settings for handlers.

| property | format | example | purpose |
|---|---|---|---|
| `apt` | list | `imagemagick,jq` | Debian packages added when a handler needs a synthesized image |
| `methods` | list | `GET,POST` | allowed HTTP methods; omitted means all |
| `route` | absolute path | `/api/items/*` | handler route override |
| `runtime` | string | `python3` | interpreter override when the shebang is insufficient |
| `subprocess` | boolean | `true` | run each request in a separate process |

---

# Compose reference

Source: https://kedge.dev/docs/compose

Kedge accepts the
[Compose Specification](https://github.com/compose-spec/compose-spec/blob/main/02-model.md)
as its source format for container applications. `compose.yaml` defines
services, networks, volumes, configs, and secrets; Kedge deploys the project as
one app. It also recognizes `compose.yml`, `docker-compose.yaml`, and
`docker-compose.yml`, in that order.

This page documents the source format and supported fields. A single service
does not need Compose; use native Dockerfile metadata or
[`kedge up` properties](/docs/configuration).

## a runnable service

This is enough to publish a prebuilt server with bounded scale-to-zero:

```bash
cat > compose.yaml <<'EOF'
services:
  web:
    image: ghcr.io/mccutchen/go-httpbin:2.23.1
    ports: ["8080"]
    x-kedge:
      scale:
        min: 0
        max: 5
        concurrency: 8
        idle-cooldown: 30s
EOF
kedge up
```

`ports` gives the service an HTTPS route; `x-kedge.scale` controls its
autoscaled instances.

## a complete small stack

```yaml
name: myblog

services:
  web:
    build: .
    ports: ["8000"]
    environment:
      DATABASE_HOST: db
      DATABASE_PASSWORD_FILE: /run/secrets/db-password
    secrets: [db-password]
    depends_on:
      db: {condition: service_healthy}

  worker:
    build: .
    command: [python, worker.py]
    environment:
      DATABASE_HOST: db

  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db-password
    secrets: [db-password]
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s

volumes:
  pgdata:
    x-kedge: {size: 10GiB}

secrets:
  db-password:
    x-kedge: {generate: {bytes: 32}}
```

`web` and `worker` share one build. `db` is private and gets a
[persistent volume](/docs/volumes), so its service runs on machines.
`depends_on` orders the first rollout; `service_healthy` waits for the
[in-guest healthcheck](/docs/runtime#readiness-and-health).

## common Compose fields

| area | supported fields |
|---|---|
| process | `image`, `build`, `command`, `entrypoint`, `working_dir`, `user`, `stop_signal` |
| configuration | `environment`, `env_file`, `configs`, `secrets`, `labels` |
| serving | `ports`, `expose`, `healthcheck`, `depends_on` |
| resources | `cpus`, `mem_limit`, `deploy.resources.limits`, `deploy.replicas` |
| topology | `services`, named `volumes`; networks within the limits below |

Resource fields are limits, not reservations. See
[configuration](/docs/configuration) and [pricing & billing](/docs/billing).

Published ports map to the app's HTTPS hostname, not a host port. `expose` is
private. A portless service without a persistent volume is a worker kept at a
floor of one. A healthcheck runs inside the guest and gates readiness. See
[network & domains](/docs/network#public-ingress-and-cdn) for ingress behavior.

Kedge classifies every Compose field. Some local-container settings are
superseded by the platform (`restart`, `logging`, `init`); unsupported or
isolation-breaking fields fail with an explanation. Every set field is applied,
superseded with an explanation, or rejected.

## service x-kedge

```yaml
services:
  web:
    build: .
    ports: ["8000"]
    x-kedge:
      scale:
        min: 0
        max: 50
        concurrency: 8
        idle-cooldown: 30s
      memory: 1GiB
      machines: 0
```

Service `x-kedge` accepts the same application/service [properties used
throughout Kedge](/docs/configuration#property-reference). Nested keys preserve
the bare dotted name: `scale.max` above is the same property as
`KEDGE_SCALE_MAX`, `kedge.scale.max`, and `dev.kedge.scale.max`.

Use fixed `deploy.replicas` or dynamic `x-kedge.scale`, not both.
`deploy.replicas` counts machines when the service has a persistent
volume and pooled instances otherwise. A positive `x-kedge.machines` value is
an alternative machine count, so do not set both. `machines: 0` can accompany
`deploy.replicas` to select a fixed-size instance pool.

[Runtime & scaling](/docs/runtime#autoscaling) explains how these bounds,
concurrency, and cooldown affect an instance pool.

## volume x-kedge

| field | meaning |
|---|---|
| `shared` | `true` gives pooled instances one filesystem; `false` gives each machine its own persistent volume; omitted infers from usage |
| `size` | logical storage limit for the sparse volume |

One writable service at one effective replica infers `shared: false`. Several
services mounting the same volume infer `shared: true`. A service that may
scale past one instance must set the value explicitly. Anonymous mounts such
as `volumes: [/data]` are private.

The [persistent volumes guide](/docs/volumes) covers image seeding, machine
identity, and retention. [Shared data](/docs/shared-data#mount-shared-files-elsewhere)
covers `shared: true`.

All service and volume values also appear in the unified [configuration
property reference](/docs/configuration#property-reference), alongside their
single-service and image equivalents.

## networks

Compose normally supplies a project-scoped `default` network. Kedge instead
joins every service to the reserved account network when `networks` is omitted.
An explicit external network named `kedge` expresses the same attachment.
Other declared networks are shown in the deploy plan and rejected.

See [network & domains](/docs/network#private-networking) for DNS names.

## secrets and configs

Compose secrets and configs are top-level resources that services receive as
guest files. Kedge accepts repository `file` or inline `content` sources;
secret targets default to `/run/secrets/<name>`. Compose `environment` and
`external` sources are parsed but rejected until the account secret store is
available.

`x-kedge.generate.bytes` adds a platform source: Kedge mints the value once,
stores it outside Git, and preserves it across updates. See [starter apps](/docs/templates#deploy-inputs-and-generated-secrets)
for a complete generated-secret example.

## top-level x-kedge

| field | meaning |
|---|---|
| [`production-branch`](/docs/deploy#from-a-directory) | production branch; default `main` |
| [`inputs`](/docs/templates#deploy-inputs-and-generated-secrets) | deploy-form interpolation fields with `description`, `default`, `optional` |
| [`auth`](/docs/authentication#compose-configuration) | app-user or workforce admission policy |
| [`identity`](/docs/authentication#compose-configuration) | optional viewer identity without an ingress gate |
| [`static.fallback`](/docs/sites#fallback-routes) | SPA fallback path for a service-less site |
| [`static.handler-excludes`](/docs/sites#fallback-routes) | directories whose shebang files are not handlers |

Unknown fields inside `x-kedge` are errors. Precedence and equivalent
single-service, image, and handler declarations live in the
[configuration reference](/docs/configuration).

## authentication

Top-level auth protects a static or handler tree and is the default for every
service. A service-level `x-kedge.auth` replaces that default for the service.
`identity` resolves the same viewer without protecting requests. The
[app authentication guide](/docs/authentication) defines the `required`,
`owner`, `github`, and `google` shorthands, flat provider and path policies,
login behavior, and identity delivery to HTML, handlers, and services.

---

# starters & shared apps

Source: https://kedge.dev/docs/templates

The [starter gallery](/templates) is a curated set of apps you can deploy as
your own. Choose a card, confirm the app name and any requested values, and
deploy. Your app gets its own source checkout, database, route, and deploy
history.

Each starter is pinned to an exact Git commit, so two people choosing the same
card begin with the same code and initial data. The source repository carries
the application and its `compose.yaml`.

## share an app

The gallery is curated by Kedge. To share your own app, link directly to its
public Git source:

```text
https://kedge.dev/deploy?repo=https://github.com/acme/notes.git&ref=0123456789abcdef0123456789abcdef01234567&root=app
```

`repo` identifies the repository, `ref` selects a branch, tag, or commit, and
`root` selects an app directory within a monorepo. A full commit SHA gives
everyone the same revision and can use a prepared release.

A repository README can carry the same link as a badge:

```markdown
[![Deploy to Kedge](https://kedge.dev/button.svg)](https://kedge.dev/deploy?repo=https://github.com/acme/notes.git&ref=0123456789abcdef0123456789abcdef01234567)
```

Opening the link previews the source and Compose configuration before anything
is created.

## deploy inputs and generated secrets

A shared app can ask for per-deploy values. Top-level `x-kedge.inputs` names
Compose interpolation values shown by the deploy form:

```yaml
name: notes

x-kedge:
  inputs:
    TITLE:
      description: Site title
      default: My notes
    ADMIN_EMAIL:
      description: Initial administrator

services:
  web:
    build: .
    ports: ["8000"]
    environment:
      SITE_TITLE: ${TITLE}
      ADMIN_EMAIL: ${ADMIN_EMAIL}
```

Each input can set `description`, `default`, and `optional`. Required inputs
must be non-empty after interpolation.

Secret values do not belong in inputs or source:

```yaml
services:
  web:
    secrets: [session-key]

secrets:
  session-key:
    x-kedge:
      generate: {bytes: 32}
```

The value is generated once for each app, mounted at
`/run/secrets/session-key`, and preserved across deploys.

## fast repeat deploys

Kedge may cache a successful pinned revision: source, runtime snapshot, and
initial database state. A later app can reuse them instead of fetching,
building, booting, and seeding again. It still gets an independent source
checkout, database, route, and deploy history.

This covers pinned single-service web apps with the default database and
settings. Personalized apps and incompatible revisions use the normal build
path.

## source import or app fork?

A source import starts from a pinned repository revision and its initial seed
state. An app fork copies the current state of one of your running apps (or of any
static or HTML app published with `forkable=true`), including its latest
database contents, and may reuse its snapshot. Both become
independent apps after creation.

```bash
kedge fork myapp myapp-experiment
```

See [shared data](/docs/shared-data#previews-and-forks) and
[volumes](/docs/volumes). The generated [REST reference](/docs/api#apps)
provides the same operation for automation.

---

# GitHub Actions runners

Source: https://kedge.dev/docs/github-runners

Kedge runs GitHub Actions jobs in disposable
[hardware-isolated VMs](/docs/security):

```yaml
jobs:
  test:
    runs-on: kedge
    steps:
      - uses: actions/checkout@v6
      - run: make test
```

The repository only needs access to the runner integration; it does not need
to deploy as a Kedge app.

## enable runners

GitHub exposes different runner permissions for organizations and personal
accounts.

### organization repositories

Use the main Kedge GitHub App. Install it on the organization, select the
repositories that may use Kedge, and approve **Self-hosted runners: read and
write**. An organization owner must approve the installation and permission.

Enable the organization pool:

```bash
kedge runners enable my-organization
kedge runners status my-organization
```

Kedge creates a restricted `kedge` runner group. Its repository list tracks
the App installation's selected repositories.

### personal repositories

GitHub's repository-level runner API requires
[**Administration: read and write**](https://docs.github.com/en/rest/actions/self-hosted-runners).
Kedge keeps that permission in a separate, runner-only App.

Open [install the Kedge Runner App](/github/personal-runners/install), choose
the account, and select the repositories that may use Kedge. Enable each
repository by its full name:

```bash
kedge runners enable owner/repository
kedge runners status owner/repository
```

The main Kedge App is optional unless the repository also deploys to Kedge.
The user who installs the runner App and enables the repository must be able to
sign in to Kedge; other workflow authors do not need Kedge accounts.

## job lifecycle

GitHub queues matching jobs behind `runs-on: kedge`; Kedge starts one VM per
assigned job. Jobs remain queued while the configured Kedge concurrency is
busy.

Individual runners are ephemeral and may appear in GitHub only while a job is
assigned. For an organization repository, inspect **Settings → Actions →
Runner groups** at the organization level. The repository's **Self-hosted
runners** page may appear empty while the pool is working correctly.

## runner environment

Each job gets a fresh Ubuntu 24.04 x86-64 VM with 4 vCPUs, 8 GiB of memory, and
50 GiB of temporary scratch space. The image includes Git, Git LFS, `jq`,
common archive and build tools, Python 3, Docker, Buildx, and Docker Compose.
The `runner` user has passwordless `sudo`.

The VM runs one job and is destroyed. Checkouts, Docker layers, installed
tools, and scratch data do not carry into the next job. Store durable output
with GitHub artifacts, caches, packages, or another external service.

The image is not byte-for-byte compatible with GitHub's `ubuntu-latest`.
Declare or install required tools rather than relying on undocumented
preinstalled versions.

## network boundaries

Jobs can initiate public IPv4 connections. They cannot accept inbound
connections, reach Kedge's host or private workload networks, contact
link-local or cloud metadata addresses, or use IPv6. Service containers remain
reachable from other steps through the job's local Docker network.

Use a narrow authenticated public endpoint for private dependencies, or run
that job on a runner with the required network placement.

## untrusted code

Public repositories may use the pool, but a fork workflow consumes the
repository owner's Kedge capacity once GitHub allows it to run.

Configure **Approval for running fork pull request workflows from
contributors** under **Settings → Actions → General**. Workflow code can read
any secret GitHub supplies to the job, so retain GitHub's fork protections and
review privileged workflows.

GitHub documents
[fork-workflow approval](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/approve-runs-from-forks)
and
[Actions hardening](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions).

## verify

Run this workflow before moving a real CI pipeline:

```yaml
name: kedge runner smoke test

on:
  workflow_dispatch:

jobs:
  smoke:
    runs-on: kedge
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v6
      - name: Inspect runner
        run: |
          set -euxo pipefail
          test "$(uname -m)" = x86_64
          test "$(nproc)" -eq 4
          df -h / /home/runner/_work /var/lib/docker
          curl -fsS https://api.github.com/meta >/dev/null
      - name: Exercise Docker
        run: |
          docker version
          docker run --rm hello-world
```

Trigger it from the repository's **Actions** tab. While it runs,
`kedge runners status <organization>` or `kedge runners status
owner/repository` shows the active job. Run it again to confirm the runner and
filesystem are fresh.

## troubleshooting

**The job stays queued.** Confirm `runs-on: kedge`. For an organization, check
the `kedge` group under the organization's **Settings → Actions → Runner
groups**, including pending App permissions and repository access. For a
personal repository, check the runner App installation. Jobs also queue when
Kedge capacity is busy.

**`runners enable` cannot find an installation.** For an organization, finish
the main App installation while signed in to the same Kedge account. For a
personal repository, install the runner App on that repository and use its
full `owner/repository` name.

**A command exists on `ubuntu-latest` but not here.** Add the corresponding
`setup-*` action or an installation step.

**The job needs a private address or IPv6.** That traffic is blocked. Use a
public authenticated endpoint or a differently placed runner.

See GitHub's documentation for
[runner-group access](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access)
and
[workflow labels](https://docs.github.com/en/actions/how-tos/write-workflows/choose-where-workflows-run/choose-the-runner-for-a-job).

## disable runners

Disable the pool before removing App access:

```bash
kedge runners disable my-organization
kedge runners disable owner/repository
```

This deletes the GitHub-side scale set and stops local listeners and VMs.
Disabling runners does not disconnect repositories or change automatic
deploys.

If App access was removed first and cleanup fails, restore access, run
`runners disable`, then remove access again.

---

# CLI & SSH reference

Source: https://kedge.dev/docs/cli

Kedge's command line runs over SSH. Install the optional shortcut to use
`kedge` in place of `ssh kedge.dev`:

```bash
ssh kedge.dev setup | sh
```

Your SSH key signs you in. The installer also adds shell completion and the
Kedge skill to detected agent environments.

## deploy a project

From a project directory:

```bash
kedge up --dry-run
kedge up
kedge up --volume /data --machines 1
```

The first command previews the app name and Git push. The second deploys the
current branch, initializing the repository and remote when needed. Later runs
update the same app. See [deploy workflows](/docs/deploy) for branches,
previews, GitHub, and rollback.

The optional volume and machine flags are deployment settings. Kedge carries them
through the Git push and stores them on the app; they do not create a config
file. See the [configuration reference](/docs/configuration).

## work with an app

```bash
kedge apps
kedge status myapp
kedge logs myapp
kedge env myapp LOG_LEVEL=debug
kedge shell myapp
```

Commands accept an app name or ID. Inside a project directory with a Kedge Git
remote, commands can usually infer the app, so `kedge logs` is enough.
[Logs & metrics](/docs/observability) covers the streaming and query options.

## start an agent task

Build a new app or continue an existing one with the built-in agent:

```bash
kedge agent "build a small status page"
kedge agent --app myapp "add a leaderboard"
```

With a terminal, each command stays open for follow-up turns. `kedge agent
--app myapp` opens directly at the prompt. `make`, `build`, and `create` are
unquoted aliases: `kedge make me a status page`.

See [coding agents & workspaces](/docs/agents) for follow-ups, browser sessions,
project boundaries, and named-workspace commands.

## use SSH directly

The shortcut is optional. These are equivalent:

```bash
kedge apps
ssh kedge.dev apps
```

Open an app shell directly by using its name as the SSH user:

```bash
ssh myapp@kedge.run
```

Raw SSH also accepts direct content, exact input on stdin, Git pushes, and
directory uploads:

```bash
ssh kedge.dev publish '# Hello'
ssh kedge.dev agent 'make me a status page'
scp -r public/ kedge.dev:my-site/
git push ssh://kedge.dev/$(kedge whoami)/myapp.git main
```

One-shot publishes reserve stdout for the full app URL. Use an explicit
`publish --verbose` to add file, data-model, and timing details on stderr; agent
one-shots put sparse progress and failures there. Pass `--verbose` after the
agent verb for the full tool trace. Pipe a file to `publish` when its bytes need
to remain exact. Only `kedge up` is local; it packages the Git steps behind a
single command.

## command reference

Run `kedge` with no command to see the same list in your terminal.

### deploy and run

- `kedge up [--name <name>] [--dry-run] [--<property> <value>]` — Deploy the current directory
- `kedge publish [--app <name>] [content] [--verbose] [options]` — Deploy content or a prebuilt image
- `kedge import <git-url> [name]` — Deploy a public Git repository
- `kedge connect` — Connect a GitHub repository for automatic deploys
- `kedge eval <runtime> [-no-network]` — Run stdin once in a disposable runtime
- `kedge sandbox <runtime> [-no-network]` — Open a disposable runtime shell
- `kedge agent [--app <name>] [--verbose|--claude|--codex] [prompt...]` — Build or continue an app with an interactive agent (also: make, build, create)

### manage apps

- `kedge apps` — List your apps
- `kedge status <app>` — Inspect an app
- `kedge logs <app> [filter] [options]` — Stream an app's logs
- `kedge metrics <app> [options]` — Show an app's service metrics
- `kedge shell [app|-] [ordinal]` — Open an app shell or a disposable shell with -
- `kedge machines <app> [migrate <ordinal> <region>]` — List or migrate an app's machines
- `kedge workspace <create|list|run|ps|stop|archive> [workspace] [args]` — Create workspaces and dispatch agent tasks
- `kedge volumes [status <volume>|rm <volume>]` — List, inspect, or delete retained machine volumes
- `kedge fork <app> [new-name]` — Copy an app and its current state
- `kedge rollback <app>` — Return to the previous deploy
- `kedge delete <app-or-glob>...` — Delete one or more apps
- `kedge expire <app> <deadline|clear>` — Schedule or cancel automatic deletion

### configure apps

- `kedge env <app> [KEY=VALUE ...] [options]` — View or change environment variables
- `kedge domain <add|list|check|remove> <app> [domain] [options]` — Manage an app's custom domains
- `kedge domains <search|buy|list|renew|autorenew> [options]` — Search, register, and renew domains
- `kedge db [app-or-database|create|rename|attach|detach|rm] [args]` — Open a SQL console or manage named databases
- `kedge runners [status|enable|disable] [organization|owner/repository]` — Manage GitHub Actions runners

### account

- `kedge whoami` — Print your account namespace
- `kedge login` — Keep guest apps and link this SSH key
- `kedge token [name] [-apps a,b] [-actions read,deploy] [-expires 30d] | list | revoke <id> | show <token> | restrict <token> [flags]` — Create, inspect, narrow, and revoke API tokens
- `kedge billing` — View usage and balance
- `kedge version` — Show the server version
- `kedge setup` — Install or update the CLI, completion, and agent skill

---

# REST API reference

Source: https://kedge.dev/docs/api

The REST API backs the web console and SSH commands. The endpoint inventory
below and the machine-readable [OpenAPI 3.1 document](/api/openapi.json) are
generated from the public router; internal endpoints are excluded.

## authentication

Mint a token with `kedge token`, then send it as a Bearer token:

```bash
kedge token ci --apps blog --actions read,deploy --expires 30d
curl -s https://kedge.dev/api/account -H "Authorization: Bearer $KEDGE_TOKEN"
```

A token is a list of restrictions. Any holder can add more with `kedge token
restrict` and nobody can remove one; `kedge token show` reads them offline.
Without flags a token carries full account authority. Every token expires, in
90 days by default. `kedge token list` prints ids, and `kedge token revoke <id>`
revokes that token and everything narrowed from it.

Health and OpenAPI are the only unauthenticated operations in this reference.

## conventions

- Base URL: `https://kedge.dev/api`
- JSON request and response bodies, except SSE, NDJSON, logs, and proxies.
- Send `Content-Type: application/json` for JSON writes.
- Apps resolve by name or ID. Machines are selected by ordinal
  beneath their owning app.
- Errors are JSON objects shaped as `{"error":"message"}` with an appropriate
  HTTP status. A restricted token is refused with 403 naming what it lacks.
- Streaming endpoints have no short server deadline. Set your own connect and
  idle timeouts, and reconnect SSE streams.

## examples

Deploy a prebuilt image and follow its logs:

```bash
curl -sX POST https://kedge.dev/api/apps \
  -H "Authorization: Bearer $KEDGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"hello-nginx","image":"nginx:1.29"}'

curl -sN 'https://kedge.dev/api/apps/hello-nginx/logs?follow=true' \
  -H "Authorization: Bearer $KEDGE_TOKEN"
```

Run one line in a disposable sandbox:

```bash
curl -sX POST https://kedge.dev/api/eval \
  -H "Authorization: Bearer $KEDGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"runtime":"python3","code":"print(6*7)","network":false}'
```

## endpoints

### account

| method | path | purpose |
|---|---|---|
| `GET` | `/account` | Get the current account |
| `GET` | `/account/events` | List events |
| `GET` | `/account/tokens` | List tokens |
| `POST` | `/account/tokens` | Create an API token |
| `DELETE` | `/account/tokens/{nonce}` | Delete token |
| `GET` | `/account/usage` | Get usage |
| `GET` | `/account/usage/history` | Get history |
| `GET` | `/account/webhooks` | List webhooks |
| `POST` | `/account/webhooks` | Create webhook |
| `DELETE` | `/account/webhooks/{webhookID}` | Delete webhook |

### apps

| method | path | purpose |
|---|---|---|
| `GET` | `/apps` | List apps |
| `POST` | `/apps` | Create an app |
| `POST` | `/apps/imports` | Import a Git repository |
| `GET` | `/apps/imports/preview` | Preview a Git import |
| `DELETE` | `/apps/{appID}` | Delete app |
| `GET` | `/apps/{appID}` | Get app |
| `GET` | `/apps/{appID}/auth/providers` | List providers |
| `POST` | `/apps/{appID}/auth/sessions/revoke` | Create revoke |
| `GET` | `/apps/{appID}/auth/users` | List users |
| `POST` | `/apps/{appID}/auth/users/{userID}/disable` | Create disable |
| `POST` | `/apps/{appID}/auth/users/{userID}/enable` | Create enable |
| `DELETE` | `/apps/{appID}/database` | Detach an app database |
| `PUT` | `/apps/{appID}/database` | Attach an app database |
| `POST` | `/apps/{appID}/deploy` | Deploy an app |
| `GET` | `/apps/{appID}/deploys` | List deploys |
| `GET` | `/apps/{appID}/domains` | List domains |
| `POST` | `/apps/{appID}/domains` | Create domain |
| `DELETE` | `/apps/{appID}/domains/{domain}` | Delete domain |
| `GET` | `/apps/{appID}/domains/{domain}` | Get domain |
| `POST` | `/apps/{appID}/domains/{domain}/verify` | Verify domain |
| `GET` | `/apps/{appID}/env` | Get app environment |
| `PATCH` | `/apps/{appID}/env` | Merge app environment |
| `PUT` | `/apps/{appID}/env` | Replace app environment |
| `DELETE` | `/apps/{appID}/env/{key}` | Delete env |
| `DELETE` | `/apps/{appID}/expiry` | Clear app expiry |
| `PUT` | `/apps/{appID}/expiry` | Set app expiry |
| `POST` | `/apps/{appID}/fork` | Fork an app |
| `GET` | `/apps/{appID}/logs` | Get app logs |
| `GET` | `/apps/{appID}/machines` | List machines |
| `POST` | `/apps/{appID}/machines/{ordinal}/migrate` | Migrate machine |
| `GET` | `/apps/{appID}/metrics/{path}` | Query app metrics |
| `GET` | `/apps/{appID}/previews` | List previews |
| `POST` | `/apps/{appID}/previews` | Create preview |
| `DELETE` | `/apps/{appID}/previews/{ref}` | Delete preview |
| `POST` | `/apps/{appID}/previews/{ref}/deploy` | Deploy preview |
| `POST` | `/apps/{appID}/rollback` | Roll back an app |

### deploys

| method | path | purpose |
|---|---|---|
| `GET` | `/deploys/{deployID}` | Get deploy |

### builds

| method | path | purpose |
|---|---|---|
| `POST` | `/builds` | Create build |
| `GET` | `/builds/{buildID}` | Get build |
| `GET` | `/builds/{buildID}/logs` | Get logs |

### data

| method | path | purpose |
|---|---|---|
| `GET` | `/databases` | List databases |
| `POST` | `/databases` | Create database |
| `DELETE` | `/databases/{name}` | Delete database |
| `GET` | `/volumes` | List volumes |
| `DELETE` | `/volumes/{ref}` | Delete volume |

### domains

| method | path | purpose |
|---|---|---|
| `GET` | `/domains` | List domains |
| `POST` | `/domains` | Create domain |
| `GET` | `/domains/search` | Search domains |
| `POST` | `/domains/{domain}/autorenew` | Set auto-renew for domain |
| `POST` | `/domains/{domain}/renew` | Renew domain |

### sandboxes

| method | path | purpose |
|---|---|---|
| `POST` | `/eval` | Evaluate one-shot code |
| `GET` | `/runtimes` | List runtimes |
| `POST` | `/sandbox/exec` | Run a one-shot sandbox command |
| `GET` | `/sandboxes` | List open sandboxes |
| `POST` | `/sandboxes` | Open a sandbox |
| `DELETE` | `/sandboxes/{sandboxID}` | Destroy a sandbox |
| `GET` | `/sandboxes/{sandboxID}` | Get a sandbox |
| `POST` | `/sandboxes/{sandboxID}/exec` | Run a command in a sandbox |

### events

| method | path | purpose |
|---|---|---|
| `GET` | `/events` | List or follow events |

### system

| method | path | purpose |
|---|---|---|
| `GET` | `/health` | Check API health |
| `GET` | `/openapi.json` | Get the OpenAPI document |
