# 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, or a prebuilt image. 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
echo '# Hello, world!' | ssh kedge.dev
```

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.

## 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 "$(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.

---

# 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 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.

The first push creates the app. The production branch defaults to `main` and
can be changed with top-level `x-kedge.production-branch`. For a single-service
app, any other branch is an isolated preview at
`https://myapp--<branch>.kedge.run`, with its own database and files.
Multi-service branch previews are not available yet. Deleted and abandoned
previews expire automatically. [Shared data](/docs/shared-data#previews-and-forks)
explains preview isolation.

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 [name] --image <ref>` deploys a public prebuilt image.
- Piped stdin and `scp` publish functions and file trees without creating a
  local repository first.
- The web editor commits each save into the same durable repository.

## 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 stable members 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` 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.

## 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.

## 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.

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 Compose service with `image` pulls that image.
2. A Compose service with `build` runs its Dockerfile settings.
3. A repository with a `Dockerfile` and no Compose file creates an app with one
   implicit service.
4. Otherwise Railpack detects supported source code and builds it.
5. A remaining file tree becomes a [site](/docs/sites); shebang files become
   [handlers](/docs/handlers).

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 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-member app by default. Use `kedge up --members` or `--volume-size` for
the member 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 "$(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.

## policies

| policy | verified identity |
|---|---|
| `required` / `app-users` | active app-scoped user |
| `owner` | active owning Kedge account |
| `google-domains` | exact signed Google Workspace `hd` claim |
| `github-orgs` | active GitHub organization membership |

Policy members are ORed. App users may sign in with email, passkey, Google, or
GitHub. Workforce identities pass an owner, domain, or organization rule
without creating an app-user record.

Policies cover every path by default. Selected paths use exact matches, `/**`,
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.

## 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"}
```

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.

## HTML apps

```html
<a data-when="!$viewer.verified" href="/_kedge/auth/login">log in</a>
<p data-when="$viewer.verified">Signed in as {{$viewer.name}}</p>
```

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#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`.

## 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

Authentication is currently declared by the top-level `x-kedge.auth` property:

```yaml
x-kedge:
  auth: required
```

A `compose.yaml` containing only `x-kedge` does not create a container service.
Use `owner` for an owner-only app. Use the structured form for paths and
workforce rules:

```yaml
x-kedge:
  auth:
    paths: ["/admin/**"]
    except: ["/admin/health"]
    allow:
      owner: true
      google-domains: [example.com]
      github-orgs: [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. 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.
- 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.

## 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`.

## 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.

## previews and forks

Previews get isolated shared data. Forking branches the source database and
files at one instant without copying all pages; unchanged content remains
shared underneath, while later writes stay on their own branch.

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

---

# 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.

## 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 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-member app by default:

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

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

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

Several volume paths for one member 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 stable member 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 member count. Outside
Compose, use `--members`:

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

## stable members

Kedge calls each stable copy of a service a **member**. Members have ordinals
starting at zero:

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

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

List members or move one to another region:

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

Kedge handles ordinary restarts and host recovery automatically.

## retention and deletion

Member 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 1.7 ms. An app can add an initialized instance in
2.7 ms.

## ready before demand

Kedge keeps clean sandbox runtimes and initialized app instances restored and
paused. A sandbox request gets a fresh, hardware-isolated runtime. App
scale-out resumes an instance with its networking and identity already
attached.

Neither path repeats a boot or framework startup.

## when capacity is cold

If no paused app instance is available, Kedge restores one from the deploy
snapshot on demand. Across Node, Rails, static, and Go apps, the fleet-wide
median is 35 ms and p90 is 45 ms. The fastest apps have a 12 ms median.

Cold restore still starts after application initialization, because the
[deploy snapshot](/docs/runtime#snapshots) is taken at that boundary. Memory,
image, filesystem, and shared-data pages load only as the instance reads them.

## see it on a request

App responses report whether that request waited for an instance:

```bash
curl -sD - -o /dev/null https://myapp.kedge.run
```

```text
X-Kedge-Restore: warm
X-Kedge-Restore-Ms: 2.7
```

`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.

## what the numbers measure

These are representative production-host medians, measured from a local
request for a VM until it is ready. They exclude client network latency and
application request time.

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; named bearer tokens authenticate the [REST API](/docs/api). App,
repository, data, and runtime operations enforce the owning account.

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 [Compose
reference](/docs/compose#service-x-kedge) 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 as stable members
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

Every 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.

## HTTPS and domains

Every 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

Compose `ports` publishes a service at the app's HTTPS hostname. `expose`
remains private. Services with persistent volumes stay private; put a public
service in front of them.

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 stable member |

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

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
stable member 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

Kedge runs workloads in eleven regions. Latency-aware routing sends each
client to a nearby edge; autoscaled instances follow demand across compute
regions.

The combined map shows the best measured round-trip time to any region. Select
a region to see its reach, or hover a city for exact 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 |
| `gru` | São Paulo, Brazil |
| `nrt` | Tokyo, Japan |
| `sin` | Singapore |
| `bom` | Mumbai, India |
| `syd` | Sydney, Australia |
| `cpt` | Cape Town, South Africa |

Static files serve at the edge. Dynamic requests cross the encrypted fleet
mesh when the nearest warm instance is elsewhere. Stable members run in one
region at a time and can move without changing identity.

## map data

The map uses WonderNetwork city-to-city ping data. The combined layer colors
each sampled city by its best measured round trip to a Kedge region. 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 then destroyed; 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 includes the [handler runtime](/docs/handlers) tools: `sqlite3`,
`jq`, `curl`, and `uptime`. Each run defaults to 256 MiB and 30 seconds; both
limits are configurable per call.

## network access

Evals and sandboxes have outbound network access by default. To run
untrusted code with no network at all, pass `-no-network` to `kedge eval` or
`kedge sandbox`; the VM then has 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 sandbox VM, and
`kedge shell -` does the same without naming a runtime. Either VM is destroyed
when you exit.
The [REST sandbox operation](/docs/api#sandboxes) has the same one-shot
boundary: submit a runtime and command, receive the command result, and leave
no execution resource or identifier behind.

There is no persistent sandbox resource. When files or processes must outlive
the session, create a normal workspace app with a
[persistent volume](/docs/volumes); give the app an expiry if it should clean
itself up later. It then uses the same shell, logs, metrics, migration, and
deletion behavior as every other stateful app.

[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 bills per second for the resources an app actually consumes while it
runs.

## 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.

## rates

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

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.

## 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.

## 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.

### private rows and aggregates

`me/` restricts rows to the current author. `$tally` exposes only a count:

```html
<span data-kedge="me/votes?story={{id}}&$tally">{{count}}</span>
```

`$tally=field` groups by an enumerated managed-form field.

```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.

```html
<span data-kedge="comments?story={{id}}&$count">{{count}}</span>
<span data-kedge="me/votes?story={{id}}&$tally">{{count}}</span>
```

These are the only nested read shapes. All rows are grouped into one query.

### audiences

`$verified` and `$owner` restrict a binding or write. Every use of one
collection must have a compatible audience and public/private spelling.
[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. It creates an
`INTEGER COUNTER NOT NULL DEFAULT 0`; each action adds one and 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 `aria-pressed`
and the viewer-owned row. `$return`, `$verified`, and `$owner` also apply.

## 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.

## 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`.

## Markdown shorthand

Markdown source accepts `title` and `style` front matter. Without `title`, the
first level-one heading supplies the browser title.

| 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.

---

# 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 route, a database, and a deploy history, whatever it is built from.

Every app is one or more of four types:

| type | source | runs |
|---|---|---|
| [site](/docs/sites) | ordinary files | at the edge |
| [HTML app](/docs/html-apps) | HTML with `data-kedge` bindings | server-rendered per request |
| [handler](/docs/handlers) | a file with a `#!` shebang | per request, in a VM |
| [service](/docs/builds) | source, a Dockerfile, or an image | as a long-lived process |

Sites and HTML apps need no server code. A service is the escape hatch when a
workload needs one; a [Compose](/docs/compose) file defines several services as
one app.

A **service** runs in a hardware-isolated VM. By default it uses disposable
**instances**: Kedge starts them near demand and removes them when idle. A
service with a [persistent volume](/docs/volumes) instead runs as stable
**members**, each keeping its identity and disk across deploys and host moves.

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.

## type marks

`kedge apps` and the web console mark each app with what it resolved to:

| mark | type |
|---|---|
| `▤` | site, files only |
| `▩` | site with data bindings, rendered per request |
| `◪` | site with handler routes |
| `ƒ` | a single handler owning every path |
| `⬡` | service on an instance pool |
| `▣` | service on stable members |

A second mark records how the content arrived: `⇡` git push, `⇅` GitHub,
`↧` import, `⇥` `ssh`/`scp`, `⑂` fork, `✎` browser edit.

---

# 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 |

### members and volumes

Choose pooled or stable execution and configure persistent storage.

| property | format | example | purpose |
|---|---|---|---|
| `members` | integer (≥ 0) | `3` | select stable members and their count; 0 explicitly selects an instance pool |
| `volume.path` | absolute path | `/data` | exclusive state mount; selecting it implies a member set |
| `volume.shared` | boolean | `true` | share one filesystem across pooled instances; false gives each stable member 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, or object | `{"allow":{"google-domains":["example.com"]}}` | require an app-user or configured workforce identity at 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 |

### 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 uses stable members.
`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
      members: 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 stable members when the service has a persistent
volume and pooled instances otherwise. A positive `x-kedge.members` value is
an alternative member count, so do not set both. `members: 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 stable member 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, member
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 |
| [`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.
The [app authentication guide](/docs/authentication) defines the `required` and
`owner` shorthands, structured path and workforce 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,
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.

## deploy a project

From a project directory:

```bash
kedge up --dry-run
kedge up
kedge up --volume /data --members 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/member 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.

## use SSH directly

The shortcut is optional. These are equivalent:

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

Raw SSH also accepts Git pushes, piped content, and directory uploads:

```bash
echo '# Hello' | ssh kedge.dev
scp -r public/ kedge.dev:my-site/
git push ssh://kedge.dev/$(kedge whoami)/myapp.git main
```

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 [name] [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

### 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 members <app> [migrate <ordinal> <region>]` — List or migrate stable app members
- `kedge volumes [rm <volume>]` — List or delete retained member 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 token [name]` — Create a personal API token
- `kedge billing` — View usage and balance
- `kedge version` — Show the server version
- `kedge setup` — Install or update the CLI and shell completion

---

# 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 named token with `kedge token`, then send it as a Bearer token:

```bash
curl -s https://kedge.dev/api/account \
  -H "Authorization: Bearer $KEDGE_TOKEN"
```

Revoke it with the account token operation. 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. Stable members are selected by ordinal
  beneath their owning app.
- Errors are JSON objects shaped as `{"error":"message"}` with an appropriate
  HTTP status.

## 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}'
```

Streaming endpoints intentionally have no short server deadline. Clients
should set their own connect and idle timeouts and reconnect SSE streams.

## endpoints

### account

| method | path | purpose |
|---|---|---|
| `GET` | `/account` | Get the current account |
| `GET` | `/account/events` | List events |
| `POST` | `/account/tokens` | Create an API token |
| `DELETE` | `/account/tokens/{name}` | 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}/members` | List members |
| `POST` | `/apps/{appID}/members/{ordinal}/migrate` | Migrate member |
| `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 sandbox command |

### 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 |
