DOCS / ENTERPRISE GUIDE

Korta Enterprise

The Korta manager: a web console and API that provision databases and applications on your own virtual machines, cloud instances and servers, over SSH, and plan, approve and audit Markdown roles across them.

Korta installs software natively under systemd from official repositories, with TLS, encrypted secrets and backups by default. Hosts need only SSH with root or passwordless sudo.

Available now:

  • PostgreSQL 14 to 17: roles, databases, TLS, network access rules, and pgBackRest backups to local disk or S3 with point-in-time restore.
  • MySQL 8.4 LTS and 9.7 LTS: users, databases, TLS, IPv4 network access rules, and encrypted daily dumps with binary-log point-in-time restore.
  • Node.js and Next.js applications from Git: built once, shipped to every instance, run under systemd with health-checked rollouts, automatic rollback, and database links.
  • Roles in Markdown with plan, approval and an audit log, and an AI role assistant.
  • Secrets in the encrypted vault, used by roles as {{ secret.NAME }}, never shown again and kept out of plans and logs.

HTTPS exposure through a reverse proxy is next. See the design.

Core and enterprise

The role engine, SSH client, OS detection and the korta CLI live in the core repository, github.com/nexusrun/korta. This repository imports them. For development, check both repositories out side by side:

git clone git@github.com:nexusrun/korta.git
git clone git@github.com:nexusrun/korta-enterprise.git

Use the korta CLI from the core repository against this manager:

korta login --server http://127.0.0.1:8080 --token-file ~/.korta-token   # a personal API token
korta instances list
korta roles create --file site.md --name site --label env=prod   # sends templates/ too
korta roles plan ROLE_ID
korta roles apply ROLE_ID --approve

korta --help lists every command; the ones under "need Korta Enterprise" talk to this manager.

internal/contract runs the core CLI against this manager's API, so a change on either side that breaks the other fails make check.

Run the manager

go run ./cmd/korta-server
cat ./data/admin-token

Open http://127.0.0.1:8080, choose Use the administrator token, and sign in with the token. The console covers everything in the API: SSH keys, instances, PostgreSQL deployments (create, edit, apply, delete), live status, connection credentials, backups, restores and operation history. Sign-in creates a 12-hour HttpOnly session cookie; the token is not stored in the browser.

Users and access. On the Access page, add yourself as an owner, open the setup link to choose a password, and sign in as that user from then on. Keep the administrator token for emergencies: it has full access and every use is recorded. Each user gets grants of an access level, either everywhere or only on instances whose labels match a scope:

Level Adds
viewer Read instances, deployments, roles, plans and operations
operator Check instances, open consoles, apply deployments, edit and plan roles
approver Approve role plans, restore databases, read connection credentials
admin Create and delete deployments; without a scope, also instances, SSH keys, secrets and the audit log
owner Manage users and grants (no scope)

Each level includes the ones above it. For example, operator on env=staging lets someone change staging hosts and nothing else; they do not see production. By default nobody can approve a role they last changed or planned (four-eyes approval); set KORTA_FOUR_EYES=false for single-person teams. Users can add an authenticator app and create personal API tokens (90 days by default) for the CLI and CI on their Account page. See Access control.

Roles. Reusable configuration for instances, written in Markdown: patching, packages, groups, users and SSH keys, directories, configuration files, services, sysctl settings, firewall ports, and custom scripts. Each heading says what its section manages and each bullet is one item, for example # Users followed by - test added to database group. Roles can use {{ variables }} with defaults in # Variables; each instance's labels supply its own values, and template files are uploaded with the role by korta roles create. See Roles in Markdown. The editor shows the resulting steps as you type and reports anything it cannot read with its line number. Roles can also be sent as JSON steps through the API. A role targets instances and labels (for example env=prod). Plan shows what would change on each host; Approve and apply runs exactly that plan, a batch of hosts at a time, stopping after a failed batch. Only the newest plan for the role's current version can be approved, and a plan expires after 24 hours or once applied. Custom scripts need an extra confirmation, and every approval is in the audit log.

Draft roles with AI. On the Roles page, Draft with AI lets you describe what hosts should look like ("patch prod weekly, one host at a time, reboot only if needed"). Claude proposes a role, Korta validates it with the same checks as hand-written roles, and you save it, review its plan and approve it; the AI never applies anything. Revise with AI on a role page edits an existing role the same way. Claude sees instance names, labels and operating systems, never addresses, keys or passwords.

SSH console. On an instance's page, Open console gives a full terminal in a new window, logged in as the instance's SSH user. It uses the vault key and the pinned host key, closes after 30 minutes without input (8 hours at most), and each session's opening and closing is shown on the instance page and in GET /api/audit.

Instance details. Each check records the operating system, kernel, CPU, memory, root disk usage and every global IP address with its interface. Run Check again to refresh them.

For remote access, put the manager behind a TLS reverse proxy that sets X-Forwarded-Proto: https, so session cookies are marked Secure.

Variable Default Purpose
KORTA_LISTEN 127.0.0.1:8080 API address. Put a TLS proxy in front for remote access
KORTA_DATA_DIR ./data State, vault and generated keys (mode 0600)
KORTA_ADMIN_TOKEN generated Break-glass administrator token, at least 32 characters. Changing it signs everyone out
KORTA_FOUR_EYES true Require role approvals from someone other than the role's last editor and the plan's author
KORTA_VAULT_KEY generated Base64 32-byte key that encrypts all secrets. Back it up
KORTA_ANTHROPIC_API_KEY unset Enables the role assistant (Claude). ANTHROPIC_API_KEY also works
KORTA_ASSISTANT_MODEL claude-opus-5-5 Claude model for the role assistant
KORTA_DATABASE_URL unset Store the manager's state and vault in PostgreSQL, such as postgres://korta:PASSWORD@db.internal:5432/korta?sslmode=verify-full. Unset, they are files in KORTA_DATA_DIR

PostgreSQL

Use PostgreSQL in production: the manager then keeps nothing on local disk and can run as a stateless container. Set:

export KORTA_DATABASE_URL='postgres://korta:PASSWORD@db.internal:5432/korta?sslmode=verify-full'
export KORTA_VAULT_KEY="$(openssl rand -base64 32)"     # once; store it in your secret manager
export KORTA_ADMIN_TOKEN="$(openssl rand -base64 32)"   # likewise
korta-server
  • KORTA_VAULT_KEY and KORTA_ADMIN_TOKEN are required with a database. They must not live on the container's disk, and the vault key is never stored in the database: losing it makes the vault unreadable. Back it up separately.
  • The manager creates and upgrades its tables on start; see Upgrading for upgrades, KORTA_MIGRATE=manual and the database privileges it needs. Make the database user the database's owner, or grant it CREATE ON SCHEMA public.
  • Run one manager per database. A second one refuses to start. If the manager loses the session that holds its lock (a failover or a terminated connection), it stops with exit status 1 so its supervisor starts it again.
  • GET /api/health returns 503 while the database is unreachable, for load balancer and container health checks.
  • Secrets and SSH keys stay encrypted with the vault key inside the database. Back the database up with your usual tools, such as pg_dump.

To move an existing file-based manager into PostgreSQL, stop it, then:

export KORTA_DATABASE_URL='postgres://...'
export KORTA_VAULT_KEY="$(cat data/vault-key)"
korta-server import ./data

Import checks that the key opens the vault, refuses a database that already holds a manager, and leaves ./data unchanged as a backup.

Korta was called Xela before. XELA_* variables still work when the matching KORTA_* variable is unset, and the server logs a reminder to rename them. Files, services and users that Korta creates on hosts keep their xela names (for example /etc/xela and xela-app-<name>), so hosts set up before the rename keep working unchanged.

Provision in one call

Give Korta a host, an SSH key and what to run. It registers the host, trusts its SSH key on first connection, adds the package repositories it needs, installs, configures and deploys, all as one operation:

jq -n --rawfile k ~/.ssh/korta_ed25519 '{
  instance:   {privateKey: $k, ssh: {host: "10.0.0.5", user: "ubuntu"}},
  deployment: {name: "orders-db", type: "postgres", spec: {
    roles: [{name: "orders"}], databases: [{name: "orders", owner: "orders"}],
    access: [{database: "orders", role: "orders", cidr: "10.0.0.0/16"}], backup: {}}}
}' | curl -H "Authorization: Bearer $TOKEN" -d @- http://127.0.0.1:8080/api/provision

The response contains an operation to follow. Sending the same request again converges: it reuses the host, key and deployment, and applies any spec changes. Use instance.id to target a registered host, or instance.ssh.secretID to use a stored key. For MySQL, set type to mysql and use users instead of roles:

{"name": "shop-db", "type": "mysql", "spec": {
  "users": [{"name": "shop"}], "databases": [{"name": "shop", "owner": "shop"}],
  "access": [{"database": "shop", "user": "shop", "cidr": "10.0.0.0/16"}], "backup": {}}}

Host keys. Without ssh.hostKey, Korta pins the key the host presents on first connection and refuses to connect if it later changes. After a known reinstall, call POST /api/instances/{id}/trust-host-key. Supply ssh.hostKey when you need verification from the very first connection.

Repositories. Korta adds the official PostgreSQL (PGDG) repository on every host, and on RHEL-family hosts with backups also EPEL and CRB (CodeReady Builder on RHEL), which pgBackRest needs.

Deploy an application

curl -H "$H" -d '{"name":"shop-api","type":"node","instanceIDs":["ins_a","ins_b"],
  "spec":{"repository":"https://github.com/acme/shop-api.git","ref":"main","port":4000,"healthPath":"/health",
          "env":{"LOG_LEVEL":"info"},"secretEnv":{"API_KEY":"..."},"token":"<read-only token for private repos>",
          "database":{"deployment":"orders-db"},"access":["10.0.0.0/16"]}}' $API/api/deployments

The database link names a PostgreSQL or MySQL deployment; user and database are optional and default to its first database and that database's owner. Korta checks them against the database deployment when you save. Use "type":"nextjs" for Next.js; enable output: "standalone" in next.config.js for the smallest releases. Korta builds on the first instance, copies the release and Node.js to the others, and activates one batch at a time. A release that fails its health check is rolled back on that instance and the rollout stops. Actions: status, logs ({"lines":200}), restart, rollback ({"release":"…"} optional). If the app uses the pg driver with a database link, give the database a DNS name in its tlsNames: pg cannot verify certificates for IP addresses.

Deploy PostgreSQL step by step

TOKEN=$(cat data/admin-token); API=http://127.0.0.1:8080
H="Authorization: Bearer $TOKEN"

# 1. Store an SSH private key in the vault.
jq -n --rawfile k ~/.ssh/korta_ed25519 '{name:"deploy", privateKey:$k}' | curl -H "$H" -d @- $API/api/ssh-keys

# 2. Register a host. Add "hostKey" to pin it up front instead of on first use.
curl -H "$H" -d '{"name":"db1","ssh":{"host":"10.0.0.5","user":"ubuntu","secretID":"key_..."}}' $API/api/instances

# 3. Create the deployment. Korta applies it and returns an operation.
curl -H "$H" -d '{"name":"orders-db","type":"postgres","instanceIDs":["ins_..."],
  "spec":{"roles":[{"name":"orders"}],"databases":[{"name":"orders","owner":"orders"}],
          "access":[{"database":"orders","role":"orders","cidr":"10.0.0.0/16"}],"backup":{}}}' $API/api/deployments

# 4. Follow it, then fetch the app connection URL and CA certificate.
curl -H "$H" $API/api/operations/op_...
curl -H "$H" "$API/api/deployments/dep_.../connection?role=orders&database=orders"

AWS instances

Roles can create EC2 instances and configure them in one run. Add your AWS accounts once under Settings > AWS (assume a role, or an access key; no restart), then write only what the machines are for:

# AWS instance
- name: web
- count: 2

# Firewall
- open 443/tcp from anywhere

# Packages
- nginx

Run (or korta roles run role_ID) plans and approves; Korta picks the official image, the network, an SSH key and a security group, launches the instances and configures them. Destroy terminates them. Admins can define networks under Settings > AWS for accounts without a default VPC. See AWS instances for the IAM policy and every option.

API

Method and path Purpose
GET/POST /api/ssh-keys, DELETE /api/ssh-keys/{id} Manage vault SSH keys
GET/POST /api/instances, GET/PUT/DELETE /api/instances/{id} Register, edit and remove hosts. Editing connection settings re-checks the host and keeps the pinned host key
POST /api/provision Register a host and deploy to it in one operation
POST /api/instances/{id}/check Reconnect and re-detect the OS
POST /api/instances/{id}/trust-host-key Re-pin the host key after a known reinstall
GET /api/instances/{id}/console (WebSocket) Interactive terminal. Binary frames carry terminal bytes; text frames carry {"type":"resize","cols":N,"rows":N}
GET /api/audit?target={id} Console sessions and role approvals, newest first
GET/POST /api/roles, GET/PUT/DELETE /api/roles/{id} Manage roles. Saving changed content creates a new version
POST /api/assistant/chats ({"roleID"} optional), POST /api/assistant/chats/{id}/messages, POST /api/assistant/chats/{id}/save Draft or revise a role with Claude, then save it for planning
POST /api/roles/{id}/plan Plan: what would change on each target, changing nothing
POST /api/roles/{id}/apply {"planID":"op_…","approve":true}; add "confirmCommands":true for roles with custom scripts
GET/POST /api/deployments, GET/DELETE /api/deployments/{id} Manage deployments
PUT /api/deployments/{id}/spec Change the spec and apply it
POST /api/deployments/{id}/apply Apply the current spec again
POST /api/deployments/{id}/actions/{action} status, backup ({"type":"diff"}), backups, restore ({"confirm":"<name>","target":"<RFC 3339>"})
GET /api/deployments/{id}/connection?role=&database= Connection URL with password, and the CA certificate
GET /api/operations, GET /api/operations/{id} Operation history and progress
GET /api/types, GET /api/ca, GET /api/health Deployment types, CA certificate, liveness
POST /api/login, POST /api/logout Console sessions (same-origin only, rate limited)

Mutations return 202 with an operation. A second mutation while one runs returns 409.

Develop

make check   # gofmt, vet, race tests

# Schema changes are new files in internal/persist/migrations, numbered
# 0002_name.sql, 0003_name.sql, … Never edit or renumber a released one: the
# manager refuses a database whose applied migration differs. Changes to the
# state or vault JSON add a step to stateUpgrades or vaultUpgrades.

# PostgreSQL storage tests, against a disposable database:
docker run -d --name korta-pg-test -e POSTGRES_PASSWORD=test -p 127.0.0.1:55499:5432 postgres:16-alpine
KORTA_TEST_DATABASE_URL='postgres://postgres:test@127.0.0.1:55499/postgres?sslmode=disable' make check

Live tests run against disposable systemd containers in testdata/livehost (Ubuntu 24.04 and Rocky Linux 9). See the live test guide.

BUILT WITH KORTAAll documentation ↗