# Apps

Deploy static sites, internal tools and backends (data, functions, realtime) from your project to stable URLs, with releases, machines, volumes, access control and Kortix sign-in built in.

Canonical page: https://kortix.com/docs/apps

An App is a deployment that one project owns. It has one stable URL, an access
policy and numbered releases. Use Apps to host a site, an internal tool or a
backend for the people in your project: Kortix signs them in before your code
runs.

## What an App is

An App is either static or a server. A static App is files that Kortix serves
itself, with no machine. A server App runs machines that Kortix builds from your
source or image.

| Part | What it is |
|---|---|
| Release | `vN`: an artifact (files, a build or an OCI image) plus its resolved config. A release never changes. A failed release never replaces live traffic. A rollback is a new release. |
| Process group | A named command. A server App without `processes` has one group, `app`. |
| Machine | Runs one process group of the live release. Its `machine_id` stays the same across releases. Each release replaces its root disk. |
| Service | A named HTTP port of a process group. Each service has its own host and its own `access`. |
| Volume | A persistent disk that one machine mounts. It outlives every release and every machine. |
| Label | A free-form tag, for example `backend`. No behavior depends on labels. |

A database, a queue or any other backend is a server App with a volume. A
template writes a ready-made App for you: see
[Template: Convex backend](#template-convex-backend).

Every App lists what it supports in `capabilities`, for example `machines` or
`volumes`. A call that needs a missing capability answers
`409 app_capability_unsupported`.

## Turn Apps on

Apps are off until a platform admin turns them on for a project. A project
member cannot turn them on: the write answers `403` `feature_operator_only`.

- **Kortix Cloud:** email [support@kortix.com](mailto:support@kortix.com) with
  the project id.
- **Self-hosted:** a platform admin (an email in `KORTIX_PLATFORM_ADMIN_EMAILS`)
  who is a project member runs `kortix projects features enable apps`. For any
  other project, the admin sends `PUT /v1/admin/api/projects/<project_id>/features`
  with `{"feature": "apps", "enabled": true}` and their sign-in JWT. The
  instance also needs `KORTIX_APPS_BASE_DOMAIN` and a wildcard DNS record: see
  [Self-hosting](/docs/host#configure).

While Apps is off:

- Every Apps route answers `403` with `code: "feature_disabled"`,
  `feature: "apps"` and the message `Apps is not enabled for this project. Contact Kortix to enable it.`
- `kortix apps <subcommand>` exits `1` before it calls the API.
- The project sidebar has no **Apps** entry. `/projects/<id>/apps` shows a gate
  screen.

Check the state with `kortix projects features`, or in **Customize → Settings →
Feature flags**. Both show `apps` only while it is on, as a locked row: origin
`kortix` in the CLI, **Managed by Kortix** in the web app.

## Deploy your first App

### Build the site
```bash
npm run build
```
### Deploy the output directory
```bash
kortix apps deploy dist --slug my-site --type static --spa
```
The command creates the App, uploads the files, and waits until the URL is
ready. It prints the URL. Without `--slug`, the slug is the directory name.
### Open it
A new App is `private`: only you can open it. Open the URL in a browser
where you are signed in to Kortix, or print a 5-minute sign-in link:
```bash
kortix apps access-link my-site
```
### Ship the next release
```bash
npm run build && kortix apps deploy dist --app my-site
```
Use `--app` (or a `kortix.yaml` block) for every later release. Without
it, the CLI creates a new App, and a slug that exists answers `409`.

The web app lists every App you may open at **Apps** in the project sidebar
(`/projects/<id>/apps`). The page shows live state, a preview and the access
controls. An App's detail lists its releases. A server App adds a **Machines**
tab, and an App with a volume adds a **Volumes** tab. `?app=<app_id>` opens one
App's detail. You create and deploy Apps
from the CLI, the SDK or an agent session.

## Sources

| Source | You deploy | Kortix |
|---|---|---|
| `static` | Files: HTML, a built SPA, a `dist/` | Serves the files. No machine, no cold start. |
| `bundle` | A package source | Runs the install and build commands, then serves the output directory from a machine |
| `dockerfile` | A directory with a Dockerfile | Builds the image, then runs `--command` on `--port` |
| `oci_image` | A public image reference (`--image`) | Runs `--command` on `--port` |

- Pick `static` for every UI you can build ahead of time. Build locally and
  deploy the output. A Next.js site with `output: 'export'` deploys `out/` as
  `static`.
- Pick `dockerfile` for server rendering, native packages or any service.
- `dockerfile` and `oci_image` need `--command` (a JSON argv array is
  unambiguous) and `--port`, or `http_service.internal_port` in `kortix.yaml`.
  A worker-only App (`processes` and no service) needs no port.

For a directory, the CLI picks the source when you omit `--type`: a
`Dockerfile` makes it `dockerfile`, else a `package.json` makes it `bundle`,
else `static`. The defaults:

| Setting | `static` | `bundle` |
|---|---|---|
| Install command | — | `corepack enable && pnpm install --frozen-lockfile` |
| Build command | — | `pnpm build` |
| Output directory | — | `dist` |
| SPA fallback (`--spa`) | off | on |
| Readiness path | `/` | `/` |

What the CLI uploads:

- It honors `.gitignore`, `.dockerignore` and `.kortixignore`.
- It never uploads `.git/`, `.kortix/` or `.env*`. It leaves out
  `node_modules` unless you pass `--include-node-modules`.
- A build-time value, such as `VITE_*` in `.env.production`, reaches a static
  App only through a build you run before the deploy.

Limits:

| Limit | Value |
|---|---|
| Archive, compressed | 50 MiB |
| Archive, extracted | 1 GiB |
| Files in an archive | 100,000 |
| Files in a static App | 20,000, at most 50 MiB each. A larger site fails with `invalid_site`. |

A static App runs no code, so it ignores `env` and `secrets`. A deploy that sets
them records an `environment_ignored` event.

A deployment moves through `queued`, `validating`, `building`, `provisioning`,
`checking` and `ready`, or ends `failed` or `cancelled`. `kortix apps deploy`
waits up to `--wait-seconds` (default `1200`). `--no-wait` returns once the
deployment is queued.

## Configure in kortix.yaml

An `apps.<slug>` block in a v2 `kortix.yaml` holds an App's deploy settings and
release config. Commit it, and every deploy uses the same settings.

```yaml
apps:
  docs:
    path: docs/dist
    type: static
    spa: true
  api:
    path: services/api
    type: dockerfile
    command: ["node", "server.js"]
    labels: [backend]
    display: { preview: none }     # an icon card, not a live thumbnail
    always_on: true
    uses: [docs]
    processes:
      app: ""                      # "" runs the block's command
      worker: "node worker.js"     # no service names it: a worker
    http_service:
      internal_port: 3000
      access: app                  # public | app | admins
      processes: [app]
      checks: [{ type: http, path: /health }]
    services:
      - { name: admin, internal_port: 3001, access: admins }
    mounts:
      - { source: data, destination: /data, initial_size_gb: 10, processes: [app] }
    vm:
      - { cpu: 1, memory_gb: 2 }
      - { cpu: 1, memory_gb: 1, processes: [worker] }
    deploy:
      release_command: "node migrate.js"
    env:
      PUBLIC_BASE_URL: https://example.com
    secrets:
      API_TOKEN: api_token         # runtime name: project secret identifier
```

| Field | Meaning |
|---|---|
| `path`, `type`, `image`, `dockerfile`, `command`, `root`, `output_dir`, `install_command`, `build_command`, `spa` | The source. `type` is `static`, `bundle`, `dockerfile` or `oci_image`. `command` is the image's argv. Paths are relative to `kortix.yaml`. |
| `port`, `readiness_path` | Shorthand for `http_service.internal_port` and one HTTP check |
| `always_on`, `idle_timeout_seconds`, `resources` | Run mode, and the App's default machine size (`cpu`, `memory_gb`, `disk_gb`) |
| `env`, `secrets` | Runtime values, and runtime names mapped to project secret identifiers. The deploy reads both from this block only. |
| `uses` | The Apps this App uses. The list replaces the App's list on every deploy. An App cannot use itself. |
| `labels` | At most 10, each lowercase letters, digits and `-` (`^[a-z0-9][a-z0-9-]{0,30}$`) |
| `display` | Where Kortix shows the App. Presentation only. See [Where an App shows](#where-an-app-shows). |
| `processes`, `http_service`, `services`, `mounts`, `vm`, `deploy` | The release config. See the next sections. |

Rules:

- `kortix apps deploy` with no arguments deploys every block: each App after
  the Apps it uses, then by name.
- `kortix apps deploy <path>` uses the block whose `path` is that path.
  `--manifest-app <slug>` names a block. A flag you pass wins over the block.
- `kortix validate` applies the same rules as the API. The API refuses a bad
  config with `400 app_config_invalid` and `issues: [{ path, message }]`.
- Any other key is an error. `backends` is an error: list used Apps in `uses`.
  `kind` is ignored, with a warning.
- A static App ignores the run mode, the machine size, `env`, `secrets` and the
  release config.

### Where an App shows

`display` decides where an App appears in the **Apps** tab. It never changes how
the App runs or who may open it.

| Key | Default | Effect |
|---|---|---|
| `listed` | `true` | `false` takes the App out of the grid. |
| `preview` | `live` | `live` frames the App on its card. `none` shows an icon. Use `none` for an App whose root page is not for people, such as an API or a backend. |
| `parent` | none | `parent: <slug>` makes the App a tab in that App's detail, with no card of its own. The tab label is the App's name without the `<parent>-` prefix: `backend-dashboard` becomes **Dashboard**. A tab whose parent does not exist stays a card. |

An App with `preview: none` opens on its first tab. Every deploy writes the
block's `display`; an absent `display` means the defaults. `kortix apps show`
prints it on the `shown` row. `PATCH /v1/projects/<project_id>/apps/<app_id>`
with `display` replaces it whole, and `{}` restores the defaults. An unknown
key, a `preview` other than `live` or `none`, or a malformed `parent` answers
`400`.

> **Warning**
> Never put access settings, passwords or member ids in `kortix.yaml`. Set them
> with `kortix apps access`. See [Access](#access).

The full `kortix.yaml` reference is [kortix.yaml reference](/docs/project/manifest).

## Processes, services and checks

These settings apply to server Apps.

- **Process groups.** `processes` maps a group name to a shell command. `""`
  runs the App's `command`. A group name is lowercase letters, digits, `-`
  or `_`, and starts with a letter.
- **Primary service.** `http_service` answers on the App's own host. Its
  fields: `internal_port`, `access`, `auto_stop_machines` (`off`, `stop`,
  `suspend`; `suspend` acts like `stop`), `auto_start_machines`,
  `min_machines_running` (0–100), `processes`, `checks`.
- **More services.** `services` lists at most 10 more HTTP services, each
  `{ name, internal_port, access, processes, checks }`. A service answers on
  `<app host>--<name>`. `http` is the primary service's name and is reserved.
- **Workers.** A group that no service names is a worker. It runs and never
  idle-stops.
- **Ports.** `internal_port` is 1–65535. Ports `7331` and `8080` belong to the
  App supervisor and are refused. Two services cannot share a port.
- **HTTP only.** A service with `protocol: tcp` is refused. Apps have no TCP
  services and no private network between Apps.

Service `access` gates the service's host:

| `access` | Who gets through |
|---|---|
| `public` | Anyone. The service checks sign-in itself. |
| `app` (default) | Whoever the App's access policy admits |
| `admins` | Whoever the App's policy admits and who holds `project.app.admin`. Others get `403 app_service_admins_only`. |

**Checks** run inside the machine. A release goes live only when every check of
every new machine passes.

| Check field | Default | Bounds |
|---|---|---|
| `type` | `http` when a `path` is set, else `tcp` | `http`, `tcp` |
| `path` | `/` | Starts with `/` |
| `interval_s` | 15 | 1–3600 |
| `timeout_s` | 5 | 1–600 |
| `grace_period_s` | 10 | 0–3600 |

**Deploy strategy** (`deploy.strategy`):

| Strategy | What happens | Default when |
|---|---|---|
| `bluegreen` | New machines start and pass their checks, then traffic moves. No downtime. | The release has no mount |
| `rolling` | One machine at a time. A volume moves from the old machine to the new one. Seconds of downtime. | The release has a mount |
| `immediate` | Every machine is replaced at once, without waiting for checks | — |
| `canary` | One new machine first. When it is healthy, the rest roll. | — |

A release with a mount refuses `bluegreen` and `canary`: they would put two
machines on one volume. A release that never gets healthy is abandoned, and
traffic stays on the previous release.

`deploy.release_command` (at most 2000 characters) runs once per release,
before any machine changes. It runs in a temporary machine with the new image,
`env` and `secrets`, and no volume. A non-zero exit fails the release with
`release_command_failed`, and nothing changes. Use it for a migration of a
database outside the App.

## Run mode, machines and scale

A server App runs in one of two modes:

| Mode | Behavior | Use it for |
|---|---|---|
| Always on (`--always-on`) | Runs 24/7. Kortix checks every 5 minutes and restarts it when it stops. | WebSockets, background loops, databases |
| On demand (`--on-demand`) | Stops after `idle_timeout_seconds` without requests. The next request wakes it. | Apps that only answer requests |

A new App is always on. Change the mode with `kortix apps set <app> --always-on`
or `--on-demand`, or with `always_on` in `kortix.yaml`. The change applies
within 5 minutes, without a redeploy. A static App has no runtime and ignores
the run mode.

Machine size:

| Setting | Default | Bounds |
|---|---|---|
| `cpu` | 1 | 1–32 |
| `memory_gb` | 2 | 1–128 |
| `disk_gb` | 10 | 1–500 |
| `idle_timeout_seconds` | 300 | 120–86400. On demand only. |

A value out of range answers `400 app_machine_out_of_range`. Kortix never
clamps it. The App's size is the default for every process group. On Platinum,
a `vm` entry in the release sizes one group. On Daytona and E2B, every machine
uses the App's size. A size change applies with the next release.

```bash
kortix apps machines api                          # id, group, state, release, size, volume
kortix apps machines restart api <machine>        # also start, stop, destroy
kortix apps scale count api 3 --process worker    # 0-20 machines per group
kortix apps scale show api
kortix apps exec api -- node scripts/report.js    # run one command in a machine
```

- A group runs 1 machine by default. `scale count` sets 0–20 per group and
  applies in the background. A new machine of a group with a mount gets its own
  new, empty volume.
- Destroying a machine keeps its volume. The group runs one machine fewer.
- `kortix apps stop` suspends a server App now. The next authorized request
  wakes it. `kortix apps start` wakes it before traffic arrives. A static App
  answers `409 static_app_no_runtime` to both.
- `exec` needs `project.app.admin` and is audited. It takes an argv of 1–256
  words, runs in a running machine of the live release (or `--machine <id>`),
  and times out after `--timeout` seconds (1–300, default 60). Each of stdout
  and stderr returns at most 1 MiB. The CLI exits with the command's exit code.
  A stopped machine answers `409 machine_not_running`.

Account limits:

| Code | HTTP | Meaning |
|---|---|---|
| `app_quota_exceeded` | 402 | The account is at its App limit. |
| `app_concurrency_limit` | 429 | The account runs its maximum number of Apps at once. |
| `app_account_unfunded` | 402 | The account cannot start compute. Its running Apps stop. |

A self-hosted instance without billing has none of these limits.

## Volumes and snapshots

Volumes need the Platinum sandbox provider. Deploy with `--provider platinum`
when Platinum is not your instance's default provider. A `kortix.yaml` block
has no provider key. A release with `mounts` on another provider answers
`400 app_volumes_unsupported`.

```yaml
mounts:
  - { source: data, destination: /data, initial_size_gb: 10, snapshot_retention_days: 5 }
```

- `source` is the volume name: 1–30 lowercase letters, digits or `_`.
- `destination` is an absolute path. It cannot be `/`, end in `/`, or sit
  under `/bin`, `/boot`, `/dev`, `/etc`, `/lib`, `/lib64`, `/proc`, `/root`,
  `/run`, `/sbin`, `/sys`, `/usr`, `/var` or `/kortix`. Use `/data`.
- Size is 1–100 GB, default 1. A volume grows and never shrinks
  (`400 volume_shrink_refused`).
- One machine writes a volume at a time, and a machine mounts at most one
  volume. Kortix enforces a single-writer lock.
- A volume outlives every release. A rollback changes code and config, never
  the data.

```bash
kortix apps volumes backend                             # name, group, size, attached machine, retention
kortix apps volumes create backend data --size 10 --process app
kortix apps volumes extend backend data --size 20
kortix apps volumes snapshots backend data              # daily, manual and final snapshots
kortix apps volumes snapshot backend data               # a manual snapshot now
kortix apps volumes restore backend data <snapshot>     # project.app.admin
kortix apps volumes fork backend data data_copy         # --snapshot <id> to copy a snapshot
kortix apps volumes destroy backend data --yes          # project.app.admin
```

| Operation | What it does |
|---|---|
| Daily snapshot | Kortix takes one per volume per day. `daily` and `manual` snapshots are kept `snapshot_retention_days` (1–60, default 5). |
| `snapshot` | A `manual` snapshot. On a mounted volume, the last ~1 s of writes can be missing. |
| `restore` | The machine stops, the volume returns to the snapshot, and the machine starts on it. The replaced contents stay in the volume's history. |
| `fork` | A new, unattached volume in the same App, made at once from the current contents or a snapshot |
| `create` | A volume made ahead of time. The next machine of its group with a mount of that name takes it. |
| `destroy` | Needs the volume's name as confirmation (`400 confirmation_required` without it). Refused with `409 volume_attached` while a machine mounts it. Kortix keeps a `final` snapshot for 7 days, then purges the data. |

## Access

An access mode decides who can open one App. It grants no permission that the
person's role denies.

| Mode | Who can open the App |
|---|---|
| `private` (default) | The creator |
| `project` | Everyone who can read the project |
| `restricted` | The members and groups you list (at most 100 of each, at least one) |
| `public` | Anyone. No sign-in. |
| `password` | Anyone with the password (8–256 characters) |

Project managers, account owners and account admins see and open every App of
the project, so a private App stays manageable after its creator leaves.

```bash
kortix apps deploy dist --app my-site --access project
kortix apps access my-site --mode restricted --members <member_id> --groups <group_id>
kortix apps access my-site --mode password --password '<password>'
kortix apps access-link my-site
```

- A person who can open the App signs in with their Kortix login. An access
  link (`kortix apps access-link`) works for 5 minutes and sets an 8-hour,
  host-only, secure cookie. Treat the link as a secret.
- A change to the policy revokes every App cookie and every viewer token the
  App holds. Everyone signs in again.
- Kortix stores only a password hash. The API, the CLI and the SDK never return
  it: the policy reports `password_configured`.
- The App list shows only the Apps you may open. An App you may not open
  answers `404`, never `403`.
- In the web app, `?open_app=<app_id>` on the Apps page opens the App through an
  access link.

## Know who is looking

The gate signs in every visitor before a request reaches your App. Your App
reads who they are without a login of its own. The viewer scope decides how much
it is told:

| `--viewer` | The App receives |
|---|---|
| `identity` (default) | The viewer's user id, email, name, picture, account role and groups, in a signed header on every request |
| `api` | The same, plus a 1-hour token that acts as the viewer on the Kortix API. The viewer's role caps it. |
| `off` | Nothing |

```bash
kortix apps access my-site --viewer api
```

A viewer exists when a person opens the App with their Kortix login or an access
link. A `password` App never has a viewer. A `public` App has one only for a
visitor who arrived through an access link.

**In the browser.** `GET /_kortix/viewer` on the App's origin returns the
viewer. Call the Kortix API through `/_kortix/api/v1/*` on the same origin: a
direct call to the API from an App origin fails CORS.

```tsx
import { createKortix, kortixAppViewerToken } from '@kortix/sdk';
import { useKortixAppViewer } from '@kortix/sdk/react';

const kortix = createKortix({ backendUrl: '/_kortix/api/v1', getToken: kortixAppViewerToken() });

function Header() {
  const { status, viewer } = useKortixAppViewer();   // 'loading' | 'anonymous' | 'viewer'
  return <span>{status === 'viewer' ? viewer.email : 'Signed out'}</span>;
}
```

`/_kortix/api/v1/*` answers `403 viewer_api_disabled` unless the scope is `api`,
`401 no_viewer_identity` without a viewer, and `403 cross_site_request` for a
request from another origin. `/v1/oauth/*` and MCP paths answer `404`.

**On the App's server.** Import from `@kortix/sdk/server`:

```ts
import { readAppViewer, createAppViewerKortix } from '@kortix/sdk/server';

const viewer = await readAppViewer(request);
// { userId, email, name, picture, groupIds, groups, role, projectId, accountId, appId, accessMode, token, expiresAt } or null
if (!viewer) return new Response('Not found', { status: 404 });

const kortix = await createAppViewerKortix(request, { backendUrl: 'https://api.kortix.com/v1' });   // scope api only
```

- `readAppViewer` verifies the signed header with `KORTIX_APP_VIEWER_SECRET`,
  which Kortix injects. A forged header fails.
- `createAppViewerKortix` builds a client that acts as the viewer, from the
  token the gate adds to each request. Build one per request. Never store or
  log the token.
- `createKortixAppGuard()` returns one resolver for both the gate header and
  your own Kortix sign-in. `requireViewer(request)` and
  `requireGroup(request, groupIds)` return `{ viewer }` or `{ response }`, never
  null. A group check keeps working when the App's access mode changes.
- A viewer with the `member` role needs an agent grant to start sessions on a
  project with agent permissions. Without it, the API answers
  `403 no_agent_access`.

An App on its own domain has no gate. Use
[Sign in with Kortix](/docs/sdk/auth) there.

## Apps that use Apps

An App can use other Apps of its project. Then its code gets sign-in tokens for
them and reaches them on its own origin.

```bash
kortix apps link crm --uses backend  # add; kortix apps unlink removes
kortix apps set crm --uses backend,files # replace; --uses= clears
```

`uses: [backend]` in the `kortix.yaml` block sets the same list on every deploy. An
unknown slug, or one you cannot see, answers `400 app_not_found` with `slugs`.

**Sign-in tokens.** Each project has one token issuer,
`https://<api host>/v1/projects/<project_id>`, with a public
`/.well-known/openid-configuration` and `/jwks.json`. A token is an ES256 JWT
that lives 15 minutes. Its `aud` is the id of the App it is for. It carries
`sub`, `email`, `name`, `picture`, `groups`, `group_ids`, `role`, `account_id`
and `project_id`. An agent's token carries `kind: "agent"` and no groups or
role.

| Who | How they get a token |
|---|---|
| A person in an App | `GET /_kortix/token?audience=<slug or id>` on the App's origin: the App itself (default) or an App it uses. `kortixToken({ audience })` in `@kortix/sdk`. |
| A member, a script, an agent session | `kortix apps token <app>` (`--json` adds `expires_at`), or `POST /v1/projects/<project_id>/apps/<app_id>/token`. An agent session gets a token that names its agent. |

Any other audience answers `403 app_not_linked`. A token proves who the person
is, not what they may do: check `groups` or `role` in every function whose data
is not for everyone the App admits. A person you remove loses access within one
token lifetime.

**Bindings.** `/_kortix/apps/<slug>/*` on an App's origin forwards HTTP and
WebSocket requests to the `http` service of an App it uses.
`/_kortix/apps/<slug>--<service>/*` reaches another service. Your browser code
needs one origin and no CORS. The used App checks the token itself.

```ts
import { kortixBinding } from '@kortix/sdk';

const backend = kortixBinding('backend'); // { url: <origin>/_kortix/apps/backend, token: a token fetcher }
```

| The used App or service | Answer |
|---|---|
| Not in `uses` | `403 app_not_linked` |
| A service with `access: admins` | `403 app_binding_forbidden` |
| A static App | `409 app_binding_unsupported` |
| No running release | `503 app_binding_unavailable` |
| No such service | `404 app_service_not_found` |

**Server to server.** A machine has no viewer cookie, so it skips the binding.
It calls `KORTIX_USES_URL_<SLUG>` with a token. A link change reaches that
variable with the next deploy, because a deploy makes new machines.

**Verify a token.** `verifyKortixToken(bearer)` from `@kortix/sdk` checks the
signature, expiry (60 s skew), issuer and audience. In a server App it needs no
options: it reads the injected `KORTIX_AUTH_*` values. Elsewhere, pass the App's
`auth.issuer`, `auth.audience` and `auth.jwks_uri`. To verify offline, for
example in a self-hosted Convex deployment, `kortix apps auth-env <app>` prints
`KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE` and `KORTIX_AUTH_JWKS` (the key set
as a `data:` URL) as dotenv lines. These values are public.

`readKortixMember(claims)` turns a token's claims, or a viewer, into one member
object, or `null`. `requireKortixMember(claims, { groups, roles })` returns the
member or throws `KortixMemberError` with code `unauthenticated` or `forbidden`.
Each list means "any one of these". A group matches by name or id. An empty list
admits nobody. Groups need the Enterprise plan; roles work on every plan.

## Agents and Apps

An agent session calls an App with its session credential, in
`Authorization: Bearer` or `X-Kortix-App-Authorization: Bearer`. Use the second
header when the App reads `Authorization` for its own key. Before the gate
forwards the request, it removes `X-Kortix-App-Authorization`, an
`Authorization` header that holds a Kortix credential, and the Kortix cookies.
Every other header and cookie reaches the App unchanged. App code never
receives a Kortix credential.

The gate judges an agent session as the agent, not as the person who started
it:

| App mode | The agent session gets in when |
|---|---|
| `public` | Always |
| `project` | The agent holds `project.app.read` |
| `restricted`, `private` | The App's slug is in the agent's `apps` list, and the agent holds `project.app.read` |
| `password` | Never |

In a project whose `kortix.yaml` declares no agents, the gate judges the
session as the person who started it. A refused request answers
`401 app_auth_required`. An agent session opens only Apps of its own project.

```yaml
agents:
  report-writer:
    kortix_permissions: [project.app.read]
    apps: [reports]    # App slugs, all, or none (the default)
```

Set the list in `kortix.yaml`, with `kortix agents scope report-writer --apps reports`
(also `all` and `none`; needs a v2 `kortix.yaml`), or in Customize → Agents →
the agent → **Apps**. The App's access dialog lists the agents granted to it.
From code, call `apps.access.agents(appId)`. See
[Agents & permissions](/docs/project/agents).

A script, a CI job or a service account calls a non-public App with a personal access
key (`kortix_pat_`) or a service account key (`kortix_sa_`), in `Authorization: Bearer` or
`X-Kortix-App-Authorization: Bearer`. The gate applies the same access decision
as for a signed-in browser, within the token's project binding. A `password` App
never accepts a Kortix credential.

An `openapi` or `http` connector whose base URL is an App of the same project
needs no Kortix credential. The connector gateway adds a signed, 60-second
assertion for the calling session. See [Connectors](/docs/connect/connectors).

## Environment inside an App

Kortix sets these variables in every machine:

| Variable | Value |
|---|---|
| `KORTIX_APP_SLUG` | The App's slug |
| `KORTIX_APP_URL` | The URL of the primary service |
| `KORTIX_SERVICE_URL_<NAME>` | The URL of each other HTTP service (`-` becomes `_`) |
| `KORTIX_USES_URL_<SLUG>` | The URL of each App this App uses (`-` becomes `_`) |
| `KORTIX_MACHINE_ID` | The machine's id, stable across releases |
| `KORTIX_PROCESS_GROUP` | The machine's process group |
| `KORTIX_AUTH_ISSUER`, `KORTIX_AUTH_AUDIENCE`, `KORTIX_AUTH_JWKS` | The values that verify the App's sign-in tokens |
| `KORTIX_APP_VIEWER_SECRET` | The key that verifies the signed viewer header |
| `KORTIX_APP_ACCOUNT_ID` | The account that owns the App |

Your own values come from `env` and `secrets` in the `kortix.yaml` block:

- At most 128 `env` values and 128 `secrets` mappings. A value is at most
  32 KiB.
- A name is uppercase letters, digits and `_` (`^[A-Z_][A-Z0-9_]*$`), at most
  128 characters.
- `kortix validate` and the deploy refuse reserved names: every name that starts
  with `KORTIX_` or `OPENCODE_`; `PORT`, `PATH`, `HOME`, `PWD`, `USER`,
  `LOGNAME`, `SHELL`, `HOSTNAME`, `TERM`, `TMPDIR`, `NODE_ENV`, `NODE_OPTIONS`,
  `LD_PRELOAD`, `LD_LIBRARY_PATH`; and `SLACK_SIGNING_SECRET`,
  `SLACK_BOT_TOKEN`.
- A name cannot be in both `env` and `secrets`.
- `secrets` maps a runtime name to a project secret identifier. The secret must
  have the `environment` exposure. Kortix resolves it when it creates the
  machine and never writes it into the image. See [Secrets](/docs/project/secrets).

## URLs and what visitors see

Kortix assigns the hostname when it creates the App and never changes it. On
Kortix Cloud it is `https://<env>-<slug>-<route-key>.apps.kortix.com`, where
`<route-key>` is 16 hex characters. Another service answers on the same label
plus `--<service>`. A service URL is `null` when the label would pass 63
characters: reach that service through a binding. A self-hosted instance serves
Apps on the wildcard domain in `KORTIX_APPS_BASE_DOMAIN`. See
[Self-hosting](/docs/host).

A static App serves files at once:

- HTML revalidates on every load (`no-cache`).
- Hashed build output (`_next/static/`, and hashed files under `assets/` or
  `static/js|css|media/`) is cached for a year. Every other file revalidates.
- With SPA fallback on, a page navigation to an unknown path gets
  `index.html`. A missing file gets your `404.html` with status `404`.
- `/docs` redirects `308` to `/docs/` when `docs/index.html` exists.
- Responses are `private` to shared caches unless the App is `public`.

An authorized request to a stopped server App wakes it, waits for readiness and
then serves that same request. While the App deploys or wakes, a browser gets a
status page with HTTP `202`, `retry-after: 3` and a 3-second refresh. Other
clients get `202` and JSON with a `code` that names the phase, for example
`app_deployment_building` or `app_starting`. Retry after 3 seconds.

| State | HTTP | `code` |
|---|---|---|
| Last deployment failed | 503 | `app_deployment_failed` |
| Last deployment cancelled | 503 | `app_deployment_cancelled` |

Account limits answer `402` or `429`: see
[Run mode, machines and scale](#run-mode-machines-and-scale).

## Releases, rollback and deletion

```bash
kortix apps releases my-site            # vN, status, strategy; marks the live one
kortix apps show my-site                # capabilities, links, run mode, releases
kortix apps logs my-site [deployment]   # runtime logs; --after N --limit N
kortix apps rollback my-site v3         # a new release from v3; waits unless --no-wait
```

- A rollback creates a new release from the target's artifact and config, and
  rolls it out like a deploy. Volumes keep their data. A target that is not
  `ready` answers `409 release_not_ready`.
- An App keeps its live release and the 5 newest other ready releases as
  rollback targets. Kortix retires older ones after each deploy. A self-hosted
  operator sets the number with `KORTIX_APPS_RETAINED_DEPLOYMENTS`.
- Runtime log lines carry a `source`: `app` (your process), `appd` (the App
  supervisor) or `caddy` (the access log, which never holds request headers).
- In the web app, open the App, then a release, to read its events and build
  log.

Delete one release, or the whole App:

```bash
kortix apps delete my-site --deployment v1 --yes   # one release and its image
kortix apps delete my-site --yes                   # the App, its machines and images
```

- The live release cannot be deleted (`409 deployment_live`). Roll back first.
- A release that is still building cannot be deleted
  (`409 deployment_in_progress`).
- A build that hits the provider's image limit fails with
  `app_image_quota_exceeded`. Delete releases you do not need.
- Deleting an App needs `project.app.write`. An App with volumes also needs
  `project.app.admin`. Each volume keeps a `final` snapshot for 7 days, then
  Kortix purges it. Export data you need first.

## Template: Convex backend

The `convex` template deploys a self-hosted Convex backend with Kortix sign-in:
a database, server functions, realtime queries, file storage, schedules and
search. It is an ordinary server App with a volume.

```bash
kortix apps launch --template convex   # --provider platinum, --no-deploy
```

Run it in the directory that holds your `kortix.yaml`. A slug that is already in
the file is refused: pick another `--name`. The backend mounts a volume, so it
needs Platinum: pass `--provider platinum` when Platinum is not your default
provider. `--no-deploy` writes `kortix.yaml` only.

`launch` writes two blocks into `kortix.yaml` and the dashboard's two files,
`apps/backend/dashboard/Dockerfile` and `apps/backend/dashboard/start.js` (a file that
already exists is kept). It deploys `backend`, then writes the backend's admin key to
the project secret `BACKEND_ADMIN_KEY` (the slug in capitals, `-` becomes `_`). It
reads the key from the machine and never prints it. The key belongs to the
backend just launched, so every launch writes it again. Then it deploys
`backend-dashboard`.

There are two Apps because Convex ships the backend and the dashboard as two
images, and a Kortix App runs one image. The **Apps** tab shows them as one:
the dashboard is the backend's **Dashboard** tab. The backend runs 24/7 with its
volume; the dashboard sleeps when idle.

| App | What it is |
|---|---|
| `backend` | The Convex backend, image pinned by digest. Service `http` on port 3210 (`access: public`, a `/version` check): the client URL, the sync WebSocket and the admin API. Service `site` on port 3211 (`access: public`): HTTP actions. Volume `data` at `/convex/data`, 10 GB. Always on, 1 vCPU / 1 GB / 10 GB disk. Label `backend`. `display: { preview: none }`. |
| `backend-dashboard` | The Convex dashboard, built from `apps/backend/dashboard/` (`type: dockerfile`). `access: admins`, on demand. It uses `backend` and reaches it through `/_kortix/apps/backend`. `display: { parent: backend }` makes it the **Dashboard** tab of `backend`. `secrets: { NEXT_PUBLIC_ADMIN_KEY: BACKEND_ADMIN_KEY }` signs it in. |

`start.js` runs Convex's dashboard on port 6792. On the App's port 6791 it
serves `/` as a page that frames the dashboard and answers its sign-in request
(Convex's embedding protocol) with the admin key; every other path goes to the
dashboard. Only project admins reach the App, so the page may carry the key.

Both backend services are `public` because Convex checks sign-in in each
function. Keep the backend at 1 machine: a second machine gets its own empty
volume, which makes a second, separate database.

Connect and push your Convex code, for example from `apps/backend/`. A session has
`BACKEND_ADMIN_KEY` in its environment:

```bash
cd apps/backend
npm install convex@1.46.0 @kortix/sdk
export CONVEX_SELF_HOSTED_URL=<backend App URL> CONVEX_SELF_HOSTED_ADMIN_KEY="$BACKEND_ADMIN_KEY"
kortix apps auth-env backend | while IFS='=' read -r k v; do printf '%s' "$v" | npx convex env set "$k"; done
npx convex deploy
```

Use Convex CLI `1.46.0`: it matches the pinned backend.

> **Warning**
> The admin key controls the backend's code and data. Grant `BACKEND_ADMIN_KEY` only to
> agents that deploy the backend. Never commit the key or ship it in a web App.

Kortix sign-in for the functions:

```ts
// convex/auth.config.ts
export default {
  providers: [{
    type: 'customJwt',
    issuer: process.env.KORTIX_AUTH_ISSUER!,
    applicationID: process.env.KORTIX_AUTH_AUDIENCE!,
    jwks: process.env.KORTIX_AUTH_JWKS!,
    algorithm: 'ES256',
  }],
};
```

```ts
// convex/members.ts
import { query } from './_generated/server';
import { readKortixMember, requireKortixMember } from '@kortix/sdk';

export const me = query({
  args: {},
  handler: async (ctx) => readKortixMember(await ctx.auth.getUserIdentity()),   // null when signed out
});

// In every function that touches non-public data:
// const member = requireKortixMember(await ctx.auth.getUserIdentity(), { roles: ['owner', 'admin'] });
```

In React, read the member with `useQuery(api.members.me)`. Do not call
`useConvexAuth()`: it throws under a plain `ConvexProvider`.

- **Dashboard.** Open **Apps** → `backend` → **Dashboard** (project admins). It opens
  signed in. `kortix apps access-link backend-dashboard` prints a 5-minute link to
  the same page.
- **A backend launched before the Dashboard tab** shows its dashboard as its
  own card, or asks for the key. Run
  `kortix apps launch --template convex --no-deploy` in an empty
  directory and copy its `apps/backend/dashboard/` files and both `kortix.yaml`
  blocks into your project. Then write the key and deploy the dashboard:

  ```bash
  kortix apps exec backend -- sh -c "cd /convex && ./generate_admin_key.sh" | tail -n 1 | kortix secrets set BACKEND_ADMIN_KEY=-
  kortix apps deploy --manifest-app backend-dashboard
  ```

  Add `display: { preview: none }` to the backend block; a deploy of `backend`
  applies it.
- **Before a migration, a bulk import or a backfill,** take
  `kortix apps volumes snapshot backend data`. Restore with
  `kortix apps volumes restore backend data <snapshot>`: the live data returns to the
  snapshot.
- **Keep your own copy** with `npx convex export --path backup.zip --include-file-storage`.
- **From outside**, `kortix apps connect backend` prints how to call the backend:
  its URLs and snippets for a sign-in token, an HTTP call and your own server.
  It prints no secret.

## Build an internal tool

An internal tool is a Convex backend App for data and logic, plus a static App
for the UI. Kortix signs the team in: the UI knows who is looking, and every
function knows who calls it.

```text
apps/backend/convex/        Convex functions: schema.ts, auth.config.ts, members.ts, ...
apps/crm/              Vite + React UI: src/convex.ts wires the binding
apps/backend/dashboard/     the dashboard App: Dockerfile + start.js (the template)
kortix.yaml            apps.backend and apps.backend-dashboard (the template), apps.crm
```

### Launch the backend
`kortix apps launch --template convex`.
### Write the functions
Write the schema and functions in `apps/backend/convex/`, with `auth.config.ts` from the template section. Call `requireKortixMember` first in every public query and mutation. Push with `npx convex deploy`.
### Wire the UI to the backend
Connect through the binding:

```ts
// apps/crm/src/convex.ts
import { ConvexReactClient } from 'convex/react';
import { kortixBinding } from '@kortix/sdk';

const backend = kortixBinding('backend');
export const convex = new ConvexReactClient(backend.url);
convex.setAuth(backend.token);
```
### Declare the UI App

```yaml
apps:
crm:
path: apps/crm/dist
type: static
spa: true
uses: [backend]
```
### Build, deploy and open it to the project

```bash
(cd apps/crm && npm run build)
kortix apps deploy apps/crm/dist
kortix apps access crm --mode project
```

Never use `public` for internal data. A `public` App has no signed-in member, so
every function that requires one refuses the call.

| What broke | Undo |
|---|---|
| The UI | `kortix apps rollback crm <vN>` |
| The backend's image or config | `kortix apps rollback backend <vN>`. The volume keeps its data. |
| Convex code | Check out the last good `apps/backend/convex` from git and run `npx convex deploy` again. |
| Data | `kortix apps volumes restore backend data <snapshot>`. Every later change is lost. |

## Build on Apps from TypeScript

`kortix.project(projectId).apps` in `@kortix/sdk` exposes every App operation.
The CLI uses the same calls.

Branch on an App's `capabilities`, never on how the App was made:

| Capability | The App has it when |
|---|---|
| `deployments`, `rollback`, `preview`, `member_tokens` | Always |
| `static` | The live release is static |
| `sleep`, `machines`, `scale`, `exec` | The live release runs a server |
| `volumes`, `snapshots` | The App has a volume |

A call that needs a capability the App lacks answers `409`:

```json
{ "error": "This App does not support exec.", "code": "app_capability_unsupported", "capability": "exec" }
```

The CLI checks first: it exits `1` with `App <slug> does not support <capability>.`
and sends nothing. The machine and volume routes of an App with no machines
answer `409 app_no_machines`.

```ts
import { createKortix, isFeatureDisabledError } from '@kortix/sdk';

const kortix = createKortix({ backendUrl: 'https://api.kortix.com/v1', getToken: async () => process.env.KORTIX_TOKEN! });
const apps = kortix.project(projectId).apps;

const app = await apps.create({ slug: 'docs', name: 'Docs' });
const artifact = await apps.artifacts.uploadArchive(tarGzBytes);   // register, upload, hash, finalize
const deployment = await apps.deployments.create(app.app_id, {
  artifact_id: artifact.artifact_id,
  source: { kind: 'static', spa: true },
});

for (;;) {
  const { deployment: current } = await apps.deployments.get(app.app_id, deployment.deployment_id);
  if (current.status === 'ready') break;
  if (current.status === 'failed' || current.status === 'cancelled') throw new Error(current.error ?? current.status);
  await new Promise((resolve) => setTimeout(resolve, 2000));
}
```

| Group | Calls |
|---|---|
| App | `list`, `create`, `get`, `update` (also `labels` and `display`), `remove`, `start`, `stop`, `rollback(appId, deploymentId)` |
| Artifacts | `artifacts.register` (`archive` or `oci_image`), `artifacts.uploadArchive`, `artifacts.finalize` |
| Releases | `deployments.create` (`artifact_id`, `source`, `provider?`, `environment?`, `secrets?`, `config?`), `deployments.list`, `deployments.get` (with events and build log), `deployments.logs`, `deployments.remove` |
| Machines | `machines.list`, `machines.start`, `machines.stop`, `machines.restart`, `machines.destroy`, `scale(appId, { worker: 3 })`, `exec(appId, { command: ['ls', '/data'] })` |
| Volumes | `volumes.list`, `volumes.create`, `volumes.update`, `volumes.destroy(appId, volumeId, name)`, `volumes.snapshots`, `volumes.snapshot`, `volumes.restore`, `volumes.fork` |
| Access | `access.get`, `access.update` (`mode`, `member_ids`, `group_ids`, `password`, `viewer_token_scope`), `access.session` (a 5-minute link), `access.agents` |
| Sign-in | `token(appId)`, `authEnv(appId)` |

`isFeatureDisabledError(error)` is `true` for the `403 feature_disabled` that a
project without Apps answers. React hooks in `@kortix/sdk/react`:
`useProjectApps`, `useAppDeployments`, `useAppDeployment`, `useAppAccess`,
`useAppMachines`, `useAppVolumes` and `useAppVolumeSnapshots`. Signatures and
types: [SDK reference](/docs/sdk/reference#the-project-handle) and
[React hooks](/docs/sdk/react#hooks).

## Permissions and CLI reference

| Permission | Allows |
|---|---|
| `project.app.read` | List and view Apps, releases, logs, machines, volumes, snapshots, the access policy and the granted agents. Create an access link. Mint a sign-in token. |
| `project.app.write` | Create, rename, resize and label Apps, set run mode and `uses`, change the access policy, delete a release, delete an App |
| `project.app.deploy` | Upload artifacts, deploy, roll back, start and stop. Start, stop, restart and destroy machines. Scale. Create, extend, snapshot and fork volumes. |
| `project.app.admin` | `exec`, restore or destroy a volume, open an `access: admins` service, delete an App that has volumes |

Every route also needs the project's Apps switch on. See
[Accounts & access](/docs/accounts) for roles and custom roles.

`<app>` is an App id or slug. Every subcommand takes `--project <id>`,
`--host <name>` and `--json`.

| Command | What it does |
|---|---|
| `kortix apps list` (`ls`) | The Apps you may open: slug, state, URL |
| `kortix apps create <slug>` (`new`) | Create an App without a deploy. `--name`, `--cpu`, `--memory`, `--disk`, `--idle-timeout`, `--always-on` or `--on-demand`, `--uses` |
| `kortix apps launch --template convex` | Write a template's blocks to `kortix.yaml` in the current directory and deploy them. `--name <slug>`, `--provider <name>`, `--no-deploy` |
| `kortix apps deploy [path]` | Deploy a directory, a `.tar.gz` or `--image`. Flags: `--app`, `--slug`, `--name`, `--manifest-app`, `--type static\|bundle\|dockerfile`, `--command`, `--port`, `--dockerfile`, `--root`, `--output-dir`, `--install-command`, `--build-command`, `--readiness-path`, `--spa` or `--no-spa`, `--include-node-modules`, `--provider daytona\|platinum\|e2b`, `--access`, `--password`, `--members`, `--groups`, `--always-on` or `--on-demand`, `--no-wait`, `--wait-seconds` |
| `kortix apps set <app>` (`update`) | Change `--name`, `--cpu`, `--memory-gb`, `--disk-gb`, `--idle-timeout`, `--always-on` or `--on-demand`, `--uses` |
| `kortix apps link <app> --uses <slugs>` | Add used Apps. `unlink` removes them. |
| `kortix apps show <app>` (`get`) | Capabilities, links, run mode and releases |
| `kortix apps releases <app>` | Releases, marking the live one |
| `kortix apps rollback <app> <vN>` | A new release from `vN`. `--no-wait` |
| `kortix apps logs <app> [deployment]` | Runtime logs. `--after N`, `--limit N` |
| `kortix apps start <app>`, `stop <app>` | Wake a server App, or suspend it now |
| `kortix apps machines [ls\|start\|stop\|restart\|destroy] <app> [machine]` (`machine`) | List machines or act on one |
| `kortix apps scale count <app> <n>` | Machines for a group (0–20). `--process <group>` (default `app`) |
| `kortix apps scale show <app>` | Machines per group |
| `kortix apps exec <app> -- <command…>` | Run one command. `--machine <id>`, `--timeout <s>` |
| `kortix apps volumes [ls\|create\|extend\|snapshots\|snapshot\|restore\|fork\|destroy] <app> …` (`volume`) | Manage volumes and snapshots |
| `kortix apps labels <app> [a,b]` | Show or replace labels. `--clear` |
| `kortix apps access <app>` | Read or set access. `--mode`, `--password`, `--members`, `--groups`, `--viewer off\|identity\|api` |
| `kortix apps access-link <app>` | A 5-minute sign-in link |
| `kortix apps connect <app>` | How to reach the App from code. No secret. |
| `kortix apps token <app>` | A 15-minute sign-in token that names you |
| `kortix apps auth-env <app>` | The `KORTIX_AUTH_*` values as dotenv lines |
| `kortix apps delete <app> --yes` (`rm`, `remove`) | Delete the App. `--deployment <vN>` deletes one release. |

The whole CLI: [CLI](/docs/cli). Every route: the REST reference at
`https://api.kortix.com/v1/docs`.
