Run anything that is not a static site: web services, workers, cron jobs, Telegram bots, databases and
third-party software, on nodes shared between teams with quotas. Host: apps.<d>. Binary: appsd.
Workloads run through the node agent xvii-node (node.md). Stage 5.
Candidates: nodes the team may use, online, matching labels, with free quota for the team and free
allocatable capacity. Pick the one with the most free memory, prefer the node of the previous release.
No preemption, no autoscaling. A node going offline marks its workloads lost after a grace period and,
if the app allows, reschedules them (stateless apps only; volumes pin an app to its node).
Per team a Docker network on each node; containers never publish host ports (traffic comes through the
node's Caddy or the TCP proxy); no-new-privileges, dropped capabilities, read-only root where the
template allows, user namespaces (userns-remap), memory and CPU limits, pids limit, disk quota on
volumes. Nodes in gvisor mode run containers with runsc (for teams that do not trust each other).
Logs: kept on the node (rotated, 7 days by default), streamed live and searched through the agent.
Metrics: CPU, memory, network, restarts per container every 10 s on the node, aggregated per minute into
PostgreSQL for 30 days. Health checks drive restarts and the app state. Exec (a shell into a container)
through the agent, audited.
Nodes (capacity, usage per team, access, quotas, join command), apps by project and environment, app
page (overview, deployments, logs, metrics, environment, domains, settings, shell), databases,
templates catalog, previews, builds.
-
Nodes: curl -fsSL https://apps.<d>/join.sh | sh -s -- TOKEN [edge|relay] [NAME] downloads xvii-node
from the release store (checked against SHA256SUMS), trades the one-time token for a node id and secret
(POST /api/v1/agent/join) and runs the agent as a service; it dials wss://apps.<d>/agent
(node/proto) and reports capacity, usage and workloads every 15 s. Instance nodes: access owner, all
teams or listed teams, quotas per team (memory never promised twice); a team's own nodes run its apps
only, until a superuser makes one the instance's (PATCH /api/v1/nodes/{id} {"instance": true}). node.online / node.offline go to notify.
-
Apps from images: an app belongs to a project environment; kind web (port, optional health path)
or worker; CPU and memory limits. Deploying makes a release (image and settings; variables resolved
from env by name at that moment) and places the app: its current node when it still fits, else the
node with the most free memory among those the team may use (access, quota, allocatable memory). The
node runs the new container beside the live one; the release goes live once running (and healthy), the
old one goes; a release that fails leaves the app on its last live release (app.deploy_failed).
Rollback is a new release of an old one. Stop, start, delete.
-
Docker on the node (ADR 0015): containers xvii-<release> labelled xvii.* in per-team networks
xvii-<team>, no published ports, no-new-privileges, a pids limit, a few capabilities dropped, JSON
logs rotated (10 MB × 5); variables through a root-only env file, files from env mounted read-only at
/run/xvii/files.
-
Traffic: <app>.apps.<d> (production) or <app>-<env>.apps.<d>, the project's slug added when
taken. Caddy on the control plane takes *.apps.<d> (one wildcard certificate) to appsd, which relays
to the app's node: HTTPS to the node's proxy (port 7443), its self-signed certificate pinned by
SHA-256, with the node's relay token; the proxy passes it to the container in the team network.
-
Logs through the agent (docker logs, last lines), metrics per minute (CPU, memory, restarts)
for 30 days, app.crashed when a live container restarts.
-
Agent updates: the desired state names the newest xvii-node in the release store; an agent on an
older version downloads it, checks SHA256SUMS and restarts into it.
-
Databases (kind database): PostgreSQL 18/17/16, MariaDB 11.8/11.4/10.11, Valkey 8/7.2, Redis 7.4/7.2 (RDB
snapshots every minute with changes), MongoDB 8.0/7.0; engine and major version fixed at creation, a
deploy pulls the version's newest patch. The data is on a labelled volume xvii-<app>-data; the old
container stops (60 s) before the new one starts, and the health check is the engine's own
(pg_isready, …). User and database are app, the password is generated and sealed with
DATA_DIR/seal.key. apps writes the connection string into the environment's env set as
<NAME>_URL, and as DATABASE_URL / REDIS_URL / MONGODB_URL when the set has none (the variables
carry a note naming the database, and go when it is deleted). The host is the database's app id, an
alias in the team network.
-
One node per environment: an app goes where the other apps of its project environment already run
(they reach each other, and their databases, over that node's team network); a database stays on the
node with its volume. When that node is full or offline, the deploy says so.
-
Dumps and restores: team admins download a dump (GET /api/v1/apps/{id}/dump: pg_dump -Fc,
mariadb-dump, an RDB, a mongodump archive) and restore one in place (POST …/restore); appsd runs
the command in the container through the node's proxy (/_xvii/exec, stdout streamed, failures in a
trailer). backups takes every running database in plans with all sources (app:<id>): the node
streams the dump from apps into restic, or backupsd into a Telegram channel; restores go back the same
way. Deleting a database removes its volume on the node (remove_volumes in the desired state, until
the node no longer reports it).
-
Builds (step 4): xvii apps deploy APP --dir . packs the folder (in a git repository, the files
git doesn't ignore; no links), uploads it (POST /api/v1/apps/{id}/builds, up to 512 MB) and follows
the output. The build runs on the node the app goes to, so there is no registry: with the source's
Dockerfile (--dockerfile for another path), else with Railpack (its CLI, pinned and checked by
SHA-256, writes a plan for BuildKit's frontend). The image is xvii.local/<app>:<build>, never pulled
(--pull never); an app with such an image stays on its node, which keeps its last five builds for
rollbacks. One build at a time per node, 30 minutes at most; the last 512 KB of output is kept.
-
Health: an HTTP health path is asked by the agent over the team network (images need no curl); a
release goes live once it answers, fails after five minutes. Web apps get PORT unless env sets it.
-
Previews: --preview NAME deploys to a preview of the app (POST …/previews): its own app with
the parent's settings, in the project's preview environment when there is one, at
<app>-<name>.apps.<d>, gone after its TTL (7 days by default, up to 30; every CI run renews it) or
with xvii apps previews APP --rm NAME. Deleting an app takes its previews.
-
CI: appsd serves /ci/gitlab.yml (include it: the default branch deploys, a merge request deploys
mr-<iid> shown as the MR's environment, closing it stops the preview) and /ci/github.yml (the same
for pull requests, with a comment). Jobs sign in with their own OIDC token through an id trust rule and
need no Docker.
-
Bots (kind bot): Telegram's webhook goes to https://bots.<d>/<app id> (appsd's manifest
alias bots). When a release goes live (or the bot starts) appsd calls setWebhook with the token
from the environment's env set (bot_var, BOT_TOKEN by default) and a secret of its own; it checks
X-Telegram-Bot-Api-Secret-Token on every update and relays it to the bot's port on its node (path
bot_path, /). Stopping or deleting the bot deletes the webhook. The bot gets PORT,
WEBHOOK_SECRET and WEBHOOK_URL, which is what yaebal's serve(bot, { port, secretToken }) takes;
any framework that answers a webhook POST works, and a long-polling bot is a worker. A bot has no
previews (they would share its token). bot.webhook_failed when Telegram refuses.
-
Cron (kind cron): a schedule (five fields in UTC, or @hourly, @daily, …) and a run timeout
(an hour by default). Each minute appsd starts the due runs as one-off containers on the app's node
(/_xvii/run: the release's image, env, files and limits, removed after), one run of an app at a
time; output (last 256 KB), exit code and error are kept per run for 30 days, cron.failed on a
failure. "Run now" in the panel or POST /api/v1/apps/{id}/runs.
-
Own variables and volumes: an app has variables of its own on top of its environment's env set
(vars, plain; secret_vars, sealed, names only in the panel and API) and volumes
(xvii-<app>-<name>), which keep it on its node and make its deploys stop the old container first.
-
Templates: a catalog of 55 manifests (internal/templates/catalog: content, docs and notes, data,
files and media, monitoring, communication, AI, automation, security, reading, development,
productivity, analytics, a yaebal bot), each image pinned to a tag its registry has and installed once
on the instance before shipping, plus an instance's own (superusers, POST /api/v1/templates; the same
name replaces a curated one). Installing one in a project environment
(POST /api/v1/templates/{name}/install, xvii apps install n8n --project ops) makes its databases
(their connection strings stay with the template's apps, not in env) and apps named after the
install, fills placeholders ({{host}}, {{url}}, {{db.host}}, {{db.password}}, {{db.url}},
{{<app>.host}}, {{random 32}}; secrets only in secrets), seals the generated secrets and deploys.
Its after lines (a first sign-in, which may show a generated password) are shown once when it's
done; a starter template (yaebal) lists the steps to give its app code.
-
Compose import: xvii apps import --compose FILE --project P (or POST /api/v1/import) reads a
compose file with its .env (interpolation) and env files: services become apps (web when they
publish or expose a port, health from a curl/wget health check, memory from limits), PostgreSQL,
MariaDB/MySQL, Redis (it stays Redis: Valkey 8 doesn't read Redis 7.4's files), Valkey and MongoDB
services managed databases that keep their user, database and
password, variables that look secret are sealed with their app, named volumes stay volumes, and other
services' hostnames in variables point at the new apps' aliases; a hostname baked into an image
(reverse_proxy server:3000 in a Caddyfile) still resolves, as an app also answers on its bare name
(the team's oldest running app of that name on the node). Folder mounts are reported, not kept;
services built from code wait for xvii apps deploy --dir.
-
Adopting a server: the agent lists the compose projects a node ran before it joined (by compose's
container labels; never the agent's own). Adopting one (POST /api/v1/nodes/{id}/projects/{p}/adopt,
the node's Projects tab) reads its files on the node and makes its apps there, over the project's own
volumes (<project>_<volume>, mounted as they are and never removed) and with its images, even
private ones the node already has. A cut-over (…/cutover) switches (the old containers stop and
lose their restart policy, the apps start), switches back (the apps stop, the node confirms, the old
containers start) or finishes (the old containers go). Domains move with stage 5b.
-
Limits apps.apps, apps.memory, apps.cpu, apps.nodes; MCP tools apps_list_nodes,
apps_list_apps, apps_get_app, apps_create_app, apps_create_database, apps_deploy,
apps_rollback, apps_logs, apps_list_builds, apps_get_build, apps_list_templates,
apps_install_template; events app.deployed,
app.deploy_failed, app.crashed, build.failed, bot.webhook_failed, cron.failed,
database.restored.
-
Moving between nodes: a project environment moves whole (xvii apps move NODE --project P, the
app page's Move, POST /api/v1/moves answering its steps as lines of text), since its apps reach each
other and its databases over one node's team network: its images are copied while it runs (what the old
node built or adopted can't be pulled), then, stopped, its volumes; it starts on the new node, databases
first, and is checked, and if anything fails it starts again on the old node, whose data a move never
touches. Once it runs, the old node removes its copies of the agent's volumes (an adopted project's
volumes stay, as adopted volumes do). Nodes pass the data through their relay proxies
(/_xvii/volume-export|import, /_xvii/image-export|import), appsd streaming one into the other. Admins
of the team; the new node must fit the environment within the team's quota there.
-
Edge nodes (ADR 0015): a node's manager switches its traffic to edge (the node's settings,
PATCH /api/v1/nodes/{id} {mode}); appsd says so in the desired state and the agent serves its apps'
hosts itself on 80 and 443, with certificates from Let's Encrypt for exactly the hosts its workloads
serve (HTTP-01 on 80, which otherwise redirects to https; kept under the agent's state). It reports
edge (serving, or what keeps it from it, a port taken say) with its status; names attached to its
apps in domains point at its address while it serves (GET /api/v1/apps/{id}/address, domains checking
every two minutes), else at the control plane, which relays as before. The relay keeps running on
every node: <app>.apps.<d> stays relayed. An edge node counts its apps' analytics itself and sends the
hits with hits messages. A node whose 80 and 443 belong to something else stays a relay.
-
Cache and protection on edge nodes: domains passes a name's guard on (PUT /api/v1/apps/{id}/domains/{host} {cache, rate, conns, attack, bans, purge}, domains' token only; kept
on the endpoint) and an edge node's desired state carries the guards of its hosts, which the agent
applies with the same kit/httpcache and kit/shield as domains' front; a purge moves purged_at on so the
node drops its copies.
-
Registries (the Registries page, xvii apps registries; GET|POST /api/v1/registries
{team_id, host, username, password}, DELETE /api/v1/registries/{id}, admins of the team;
registry.save, registry.delete): a team's sign-in to a private image registry, one a host (Docker
Hub's names are docker.io). appsd signs in to the registry the way docker login does before keeping
it (a refusal is an error; a registry that can't be reached is taken at its word) and seals the
password. A workload whose image's host (proto.ImageHost: the first part when it has a dot, a port or
is localhost, else docker.io) has a sign-in of its team carries it (auth), and the agent pulls with it
through a docker config of its own for that pull, removed after. Builds' base images are pulled
without it.
Next: TCP services (a port of an app published on its node).