Korta design
Korta provisions databases and applications on hosts you already have: virtual machines, cloud instances and physical servers. The only requirement is SSH access with root or passwordless sudo. Korta does not need an agent, Docker or Kubernetes on the hosts.
Goals
- Enterprise teams declare what should run (a PostgreSQL server, a Node.js backend, a Next.js frontend) and Korta converges hosts to it.
- Software runs natively under systemd, installed from official package repositories.
- Secure defaults: TLS everywhere, SCRAM passwords, private CA, encrypted secrets and backups.
- Every change is an idempotent, recorded operation that can be retried.
Not goals for v1: container orchestration and autoscaling. Creating machines is limited to EC2, from roles (see AWS instances).
Architecture
web console / API clients
│ HTTPS: session cookie or bearer token
▼
┌──────────── korta-server ────────────┐
│ REST API · operations runner │
│ deployment types (postgres, ...) │
│ vault (AES-256-GCM) · private CA │
└───────┬───────────────────┬─────────┘
│ state │ SSH (pinned host keys)
▼ ▼
state.json / vault instances: sh -s as root via sudo
| Concept | Meaning |
|---|---|
| Instance | An SSH-reachable host with a vault SSH key. Its host key is pinned at registration or on first connection |
| Deployment | A typed workload (postgres or mysql) on one or more instances, with a validated spec |
| Operation | One recorded run (check, apply, backup, restore) with steps. One runs at a time |
| Type | Code that turns a spec into shell scripts and runs them. New types do not change the core |
One-call provisioning
POST /api/provision takes a host (or an instance ID), an SSH key (or a vault key ID) and a deployment. It runs one operation: trust the host key, detect the OS, then apply the deployment. Repeating the request converges, so the same call works for API clients, a CLI, CI pipelines and AI assistants (MCP).
Host key trust
Without a supplied key, Korta pins the key the host presents on first connection (trust on first use) and refuses any later change, which protects every connection after the first. Re-pinning after a reinstall is an explicit action. Supply the key up front, from the cloud provider or image build, when the first connection must also be verified.
Agentless execution
Each step opens an SSH session and sends a generated script on stdin to sh -s, through sudo -n for non-root users. Scripts never appear in process lists. stderr is merged into stdout, and the last 20 lines are attached to a failed step.
Scripts are idempotent: applying the same spec twice changes nothing except regenerated values noted below.
Deployment types
type Type interface {
Name() string
Normalize(spec, instances) (spec, secrets, error) // validate, default, extract secrets
Apply(ctx, env) error // converge every instance
Action(ctx, env, name, args) (any, error) // backup, restore, status, ...
}
Every spec value that reaches a shell script or SQL statement is validated against a strict pattern first. Secret values are passed only inside quoted heredocs.
PostgreSQL (v1)
One server per deployment, PostgreSQL 14 to 17 from the official PGDG repositories.
| Area | Behaviour |
|---|---|
| Hosts | Ubuntu 22.04/24.04, Debian 12/13, RHEL/Rocky/AlmaLinux 8 to 10, CentOS Stream 9/10; amd64 and arm64 |
| Repositories | PGDG on all hosts. On RHEL-family hosts PGDG uses its major-version paths (CentOS Stream has no minor releases), and backups add EPEL and CRB |
| Configuration | Managed xela.conf included last from postgresql.conf; memory tuned from host RAM; extra settings allowed |
| Restarts | Reload by default; restart only when PostgreSQL reports pending_restart |
| Roles | Login roles with generated 256-bit passwords. Only SCRAM verifiers are sent to the host |
| Databases | Created with an owner; CONNECT revoked from PUBLIC and granted to the owner |
| Network | Listens on localhost unless access rules exist. Each rule becomes a hostssl ... scram-sha-256 line plus a firewalld or ufw rule |
| TLS | Server certificate issued by Korta's private CA (397 days, renewed within 30 days of expiry). Apps use sslmode=verify-full with the CA |
| Backups | pgBackRest to local disk or S3-compatible storage, AES-256 encrypted, WAL archiving, full weekly and differential daily by systemd timers, first full backup on enable |
| Restore | Latest or a named backup set, or point in time. Requires confirm set to the deployment name |
Korta never drops roles or databases removed from a spec, and deleting a deployment leaves software and data on the host.
Example
curl -H "Authorization: Bearer $TOKEN" -d '{
"name": "orders-db", "type": "postgres", "instanceIDs": ["ins_..."],
"spec": {
"version": 17,
"roles": [{"name": "orders"}],
"databases": [{"name": "orders", "owner": "orders"}],
"access": [{"database": "orders", "role": "orders", "cidr": "10.0.0.0/16"}],
"backup": {"repo": "s3", "s3": {"endpoint": "s3.eu-west-1.amazonaws.com", "bucket": "acme-pg", "region": "eu-west-1", "accessKeyID": "AKIA...", "secretAccessKey": "..."}}
}}' http://127.0.0.1:8080/api/deployments
MySQL (v1)
One server per deployment, MySQL 8.4 LTS (default) or 9.7 LTS from Oracle's official repositories.
| Area | Behaviour |
|---|---|
| Hosts | RHEL, Rocky, AlmaLinux and CentOS Stream 8 to 10 on amd64 and arm64; Ubuntu 22.04/24.04 and Debian 12/13 on amd64 only, because Oracle publishes no arm64 Debian packages. Oracle's package names are installed explicitly and the version is checked, so a distribution MySQL is never used by mistake |
| Repositories | Oracle's apt or yum repository. The apt signing key is refreshed on every apply, merged with the keyserver copy, because Oracle extends the same key's expiry |
| Administration | Korta takes over root@localhost from the package's initial login (socket on Debian/Ubuntu, temporary password on RPM) and sets a generated password, kept in the vault and in /root/.xela-mysql.cnf (0600) |
| Configuration | zz-xela.cnf, included from the main my.cnf (Korta adds the include on Oracle's RPM, which lacks it). After a restart Korta checks that the running server uses its port, bind address and certificate |
| Users | Generated 256-bit passwords. Passwords reach the host in SQL over SSH stdin; MySQL redacts them from its logs. Owners get all privileges on their databases |
| Network | Listens on localhost unless access rules exist. Each rule creates 'user'@'cidr' (IPv4) requiring TLS, granted only the databases that user owns. Removing a rule drops that account |
| TLS | Server certificate from Korta's CA. Apps verify with ssl-mode=VERIFY_IDENTITY and the CA certificate |
| Backups | Daily encrypted (openssl enc AES-256, key in the vault) mysqldump of the application databases to a local directory, with binary logs kept for the retention period. Korta's own statements are not written to the binary log |
| Restore | Latest, a named dump, or a point in time: application users are locked and disconnected, the databases are dropped and reloaded from the dump, binary logs are replayed to the target, then users are unlocked |
MySQL backups stay on the host in v1; point the directory at a mounted network share for off-host copies. Off-host physical backups (Percona XtraBackup to S3) are on the roadmap.
Roles
A role is a named, versioned list of typed steps applied to instances selected by ID or label. The engine is the same agentless Go executor as deployments: each step renders an idempotent shell script from validated values (file contents and custom scripts travel base64-encoded) and reports ok, changed, skipped or reboot.
| Step | Manages |
|---|---|
packages |
present, absent or latest, with apt or dnf |
patch |
security or all updates, with exclusions; reboot never, if-required or always, then waits up to 10 minutes for the host |
file |
content, owner, group and mode, written atomically; or removal |
service |
started or stopped, enabled or disabled, optional restart after earlier changes; aliases such as sshd resolve to the real unit |
sysctl |
settings in /etc/sysctl.d/90-xela-<role>.conf |
user |
account, shell, supplementary groups and the exact authorized SSH keys; removal keeps the home directory |
firewall |
a port for given networks with firewalld or ufw |
command |
a custom root script, skipped when creates exists; flagged high-risk |
Plan, approve, apply. A plan runs every step in report-only mode on every target. A successful plan is recorded with the role's content hash. Applying requires approve, the ID of the newest plan for the current content (under 24 hours old), and confirmCommands when the role has custom scripts. Applying invalidates the role's plans and is recorded in the audit log. Hosts run batchSize at a time; after a failed batch the rest are skipped unless continueOnFailure is set.
Role assistant
Operators can draft or revise roles in conversation with Claude (Claude Opus 5.5 through the Anthropic API, high effort, with the API's server-side refusal fallback enabled).
- Claude has one tool,
propose_role, whose input is the role schema. It cannot run commands or reach hosts. - Korta decodes every proposal strictly and runs
Normalize, plus a check that instance IDs exist. Errors go back to Claude to fix (at most four rounds); only valid drafts reach the operator. - The first message carries the inventory as data: instance IDs, names, labels and operating systems, and existing role names. No addresses, keys, tokens or passwords.
- Conversations are kept in the manager's memory for 24 hours, append-only. Saving stores the draft as a role with
source: "ai"and an audit entry; applying it needs the same plan and approval as any role. - Without
KORTA_ANTHROPIC_API_KEY(orANTHROPIC_API_KEY) the feature reports itself as disabled.
Known limits: security-only patching needs advisory metadata, which CentOS Stream does not publish (use all); removing a sysctl or firewall step does not undo it.
SSH console
The console page (/console.html) runs xterm.js, vendored with checksums in internal/api/web/vendor/xterm. It connects to GET /api/instances/{id}/console over a WebSocket; the manager opens a login shell with a pseudo-terminal through golang.org/x/crypto/ssh, as the instance's SSH user and against its pinned host key (requesting that key type, since hosts offer several). Instances whose key is not yet pinned are refused.
- Only users with
instance.consoleon the instance (operator or higher), and only same-origin WebSocket handshakes. - Idle sessions close after 30 minutes and every session after 8 hours.
- Opening, closing and failures are recorded in the audit log; terminal contents are not recorded.
- The console page allows inline styles (xterm.js creates style elements); scripts stay limited to the manager's own files there too, and the rest of the console keeps a strict policy.
Applications (Node.js and Next.js)
Types node and nextjs deploy an application from a Git repository to 1-20 instances with the same CPU architecture and package family.
| Area | Behaviour |
|---|---|
| Source | HTTPS (or HTTP) Git URL and a branch, tag or commit. A private repository's token is sealed in the vault and reaches git as an http.extraHeader through environment configuration, never in a URL, argument or config file. Shallow fetch, with a full-fetch fallback for servers without smart HTTP |
| Build | On the first instance, as the app's own unprivileged account, with git and compilers installed there only, so native modules match the hosts. Default commands: npm ci (or npm install without a lockfile), npm run build when defined, npm prune --omit=dev. Next.js standalone output is packaged with its static files when present |
| Node.js | Official nodejs.org build of the chosen LTS major (22, 24 or 26; default 24), verified against SHASUMS256. Only the build host downloads it; other hosts receive the verified archive through the manager and need no internet access |
| Release | <UTC time>-<commit> archive with a SHA-256 checksum, copied through the manager and verified on every host, extracted to /srv/xela/<name>/releases/<release> |
| Runtime | systemd unit xela-app-<name> running as xela-<name> with NoNewPrivileges, PrivateTmp, ProtectSystem=full, ProtectHome. PORT, NODE_ENV=production and HOSTNAME (127.0.0.1, or 0.0.0.0 with access networks) are set; other variables come from /etc/xela/apps/<name>.env (0640, root and the app account) |
| Rollout | batchSize instances at a time: switch the current symlink, restart, and probe healthPath (any status below 500) for up to 90 seconds. On failure the instance returns to its previous release, the failed release is deleted, and the rollout stops. The newest keepReleases releases stay for rollback |
| Database link | Names a PostgreSQL or MySQL deployment, user and database; injects the URL (default DATABASE_URL), DATABASE_CA_FILE and NODE_EXTRA_CA_CERTS pointing at Korta's CA, so verified TLS works with common drivers |
| Actions | status, logs, restart and rollback; a rollback picks one release for every instance and stops at the first failure |
Access control
People sign in as users. Each user holds grants; a grant is an access level, either everywhere or scoped to instances whose labels include the scope's labels (env=staging). Deployments are covered when all their instances are, and roles when their targets are: listed instances must be covered, and a label selector must include the scope, so instances labelled later stay inside it.
| Level | Permissions it adds |
|---|---|
viewer |
read |
operator |
instance.check, instance.console, deployment.operate (apply, backup, logs, restart, rollback), role.edit, role.plan |
approver |
role.approve, deployment.restore, deployment.credentials |
admin |
deployment.manage; global only: instance.manage, sshkey.manage, secret.write, audit.read |
owner |
access.manage (users and grants). Owner grants have no scope |
Levels include the ones before them. The global-only permissions reach hosts, keys and secrets that no label can contain (a scoped admin could otherwise relabel a production host, or point a production key at a new address), so scoped grants never confer them.
- Enforcement. Every API route checks its permission. Lists show only what the caller can read, and anything else answers 404, so scoped users do not learn what exists elsewhere. Responses carry
can, the caller's permissions on each object, which the console uses to hide what would be refused. - Four-eyes approval. With
KORTA_FOUR_EYESon (the default), applying a role is refused to its last editor and to the author of the plan. The administrator token may override this; the audit entry says so. - Secret scopes. A vault secret can be limited to instance labels (
PUT /api/secrets/{name}/scope). Plans and applies refuse a role that uses it on any other instance, whoever runs them, so a staging role cannot copy a production password onto a staging host. - Links. Linking an application to a database needs
deployment.credentialson that database, because the application receives its password. - Attribution. Operations record their actor; roles record who last changed them and plans who made them. The audit log adds sign-ins, user and token changes, credential reads, secret changes and deletions.
- AI assistant. Claude sees only the instances and roles the user can read, and conversations belong to the user who started them.
Sign-in is a name and password, plus a code when the user has an authenticator app (TOTP, RFC 6238; each code works once). New users and resets get a one-time setup link valid for 72 hours, so owners never choose or see passwords. Sessions name the user and a credential epoch, which changes when the password, authenticator or account status changes, ending other sessions. Failed sign-ins are limited to 10 per client per 15 minutes.
Personal API tokens (korta_pat_…) act with their user's grants, expire after 90 days by default (at most 366), and stop working when revoked or when the user is disabled. The administrator token is the break-glass owner: it has every permission, has no account, and its console sign-ins are audited.
Not yet: single sign-on (OIDC) with group-to-grant mapping, and custom levels.
Security model
| Secret | Where it lives |
|---|---|
| Admin token | KORTA_ADMIN_TOKEN or <data>/admin-token |
| User passwords | Argon2id hashes (OWASP parameters), sealed in the state |
| Authenticator secrets | State, sealed |
| API tokens, setup links | Keyed hashes (Vault.MAC) in the state; the token itself is shown once |
| Vault key | KORTA_VAULT_KEY or <data>/vault-key. Back it up separately from the data directory |
| SSH keys | Vault, sealed per key ID |
| Role passwords, backup cipher, S3 secret, TLS keys | Deployment secrets, sealed with the deployment ID and name as additional data |
| CA private key | State, sealed |
API responses list secret names only. Connection URLs, which contain a password, come only from GET /api/deployments/{id}/connection.
Roadmap
- Reverse proxy with TLS (Caddy or nginx) for frontends and public backends, with certificates from ACME or Korta's CA.
- CLI and MCP server on top of
POST /api/provision, for one-command and one-prompt provisioning. - Python applications on the same build-and-ship engine.
- PostgreSQL state backend for the manager, then optional high availability.
- Later: streaming replicas, off-host MySQL backups, host metrics, firewall rule removal, per-instance operation concurrency.
Known limitations
- One operation runs at a time across the whole manager.
- Firewall rules are added for
accessentries but not removed when entries are removed. - On RHEL, enabling CodeReady Builder needs an active subscription.
- The PGDG apt signing key is downloaded over HTTPS and not pinned by fingerprint.
- The manager does not lock its data directory; run one manager per directory.
- Not yet verified on SELinux-enforcing hosts or with S3 repositories against real storage.
korta