Skip to content
KortixKortix
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

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.

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

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

npm run build

Deploy the output directory

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:

kortix apps access-link my-site

Ship the next release

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.

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

The full kortix.yaml reference is kortix.yaml reference.

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.

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.

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

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

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:

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

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.

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.

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.

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.

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.

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.

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.

Releases, rollback and deletion

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:

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.

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:

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.

Kortix sign-in for the functions:

// 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',
  }],
};
// 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:

    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.

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:

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

apps:
  crm:
    path: apps/crm/dist
    type: static
    spa: true
    uses: [backend]

Build, deploy and open it to the project

(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:

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

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 and 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 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. Every route: the REST reference at https://api.kortix.com/v1/docs.

Was this page helpful?