# App manifests

Published 2026-10-21 · Canonical: https://setucp.com/docs/manifest

---

A TOML file per app version, published at a URL or pasted into the catalogue (**Apps → Add**, or `POST /api/v1/apps` with the TOML or `{url}`). It is the only app-specific knowledge Setu has (spec §2). Every table in the examples except `[env]` is required; unknown keys are a validation error naming the key and line number. The validator is a pure function, with its corpus at `crates/manifest/tests/corpus/*.toml` (each with an `.expect` file holding `ok` or the one expected error line). `POST /api/v1/apps/validate` runs it on raw TOML without storing anything.

## Fields

Targets: declare at least one of `x86_64-linux` and `aarch64-linux`; a site is only placed on a server whose architecture the manifest covers. (Setu ships no aarch64 build until phase 2). Archives may not contain symlinks, hard links, devices, absolute names, `..` or setuid/setgid/sticky bits: the helper refuses the whole install.

### `format`

Many projects (most Go ones) publish a bare executable per platform, e.g. `miniflux-linux-amd64`, rather than an archive. `format = "binary"` says the download *is* the executable: the root helper verifies its sha256, refuses anything that is not a regular file (a symlink, fifo or directory in the downloads dir), and copies it to `bin/<version>/<exec>`, mode `0755`, root-owned, with no special bits whatever the download had. `exec` then names that file (`exec = "miniflux"`) and must be one path component. The format comes from the manifest, never from sniffing the bytes. With the default `"tar.gz"` the archive is extracted under the rules below.

```toml
[run.binary."x86_64-linux"]
url = "https://github.com/miniflux/v2/releases/download/2.3.3/miniflux-linux-amd64"
sha256 = "237bf0aed05e86c235b6bcfbad843bfc7bcdd6a628ece672eae3d1e013ddd244"
format = "binary"
exec = "miniflux"
```

### `workdir`

The site's binary lives at `/srv/setu/sites/<short>/bin/current/<exec>` (`current` is a symlink to `bin/<version>/`). `WorkingDirectory=` is:

Only `data/` and `run/` are writable by the app; `bin/` is root-owned.

## Env rules

Each `[env]` entry is exactly one of four sources:

Modifiers: `secret = true` masks the value in the UI and API (reveal needs the operator's password, audited) and seals it `v1:` at rest; `once = true` (requires `secret`) is described below. Values never contain control characters, so env files never need more than systemd quoting.

When values are made: every rule is resolved **once**, at `POST /sites`, and stored sealed; a provisioning retry reuses them (same generated keys, same database password). On **update** to a new version, `generate` and `prompt` values are kept by name, `value` and `from` are re-resolved (the database URL keeps its password), new rules are resolved fresh (answers for new prompts are asked by the update dialog), removed rules are dropped. **Env edits** (`PUT /sites/{id}/env`) change `prompt` and `value` variables and restart the site.

### The `once` contract

A `once` value (typically the first admin password) is written to the env file for the first boot only. After the first successful health check the env file is rewritten without it, and no update, rollback or env edit writes it again. The control plane keeps it sealed so an operator can reveal it (with password re-entry, audited) at any time. So the app must consume it before it reports healthy: create the admin account on boot, then let `health` pass.

### Health

`health` is polled on `127.0.0.1:<port>` every 500 ms until it answers 2xx or `startup_grace_s` runs out; a failure fails the provision step, or triggers the rollback of an update. The app should return 2xx only when it can serve (database migrated, once-values consumed).

## Example: Vyasa 0.2.0

The reference app, exactly as in the corpus (`crates/manifest/tests/corpus/vyasa_0_2_0.toml`) and provisioned by the end-to-end test:

```toml
schema = 1

[app]
id = "vyasa"
name = "Vyasa"
version = "0.2.0"
homepage = "https://vyasa.site"
health = "/readyz"
admin_path = "/admin"
startup_grace_s = 90

[run]
kind = "binary"
workdir = "exec_dir"
args = ["serve"]
[run.binary."x86_64-linux"]
url = "https://github.com/vyasa-cms/vyasa/releases/download/v0.2.0/vyasa-0.2.0-x86_64-unknown-linux-gnu.tar.gz"
sha256 = "84b5a99beccace445037191ff2bb168e8b0287a3f52d460191a9ce745220debc"
exec = "vyasa-0.2.0-x86_64-unknown-linux-gnu/vyasa"

[needs]
postgres = ">=16"
dirs = ["media", "index", "registry"]

[env]
VYASA_BIND_ADDR      = { from = "bind_addr" }
VYASA_DATABASE_URL   = { from = "postgres_url" }
VYASA_SECRET_KEY     = { generate = "hex32", secret = true }
VYASA_MEDIA_DIR      = { from = "dir", dir = "media" }
VYASA_INDEX_DIR      = { from = "dir", dir = "index" }
VYASA_REGISTRY_DIR   = { from = "dir", dir = "registry" }
VYASA_RUN_DIR        = { from = "run_dir" }
VYASA_TRUSTED_PROXIES = { value = "127.0.0.1" }
VYASA_ADMIN_EMAIL    = { from = "owner_email" }
VYASA_ADMIN_PASSWORD = { generate = "password20", secret = true, once = true }

[limits.default]
memory_mb = 512
cpu_percent = 100
disk_mb = 5120

[backup]
postgres = true
dirs = ["media", "registry"]

[update]
strategy = "replace"
```

Vyasa binds `127.0.0.1:<port>` (so only Caddy reaches it), runs from the archive's top-level folder (where its admin assets are), backs up `media` and `registry` but not the rebuildable `index`, trusts Caddy on 127.0.0.1 for client IPs (see the loopback caveat in [SECURITY.md](/docs/security#what-hosted-sites-share)), and creates its admin from the one-time password, which the operator reveals in the site's Env tab.

## Example: `hello` (the e2e app)

`apps/hello/manifest.tmpl.toml`, rendered by `scripts/e2e.sh` once per version (`__VERSION__`, `__URL__`, `__SHA256__`; `__EXTRA_ENV__` is `HELLO_FAIL = { value = "1" }` for 0.1.2, whose health then fails):

```toml
schema = 1

[app]
id = "hello"
name = "Hello"
version = "__VERSION__"
homepage = "https://example.com/hello"
health = "/healthz"
admin_path = "/"
startup_grace_s = 20

[run]
kind = "binary"
workdir = "exec_dir"
args = []

[run.binary."x86_64-linux"]
url = "__URL__"
sha256 = "__SHA256__"
exec = "hello"

[needs]
postgres = ">=16"
dirs = ["uploads", "state"]

[env]
HELLO_BIND = { from = "bind_addr" }
DATABASE_URL = { from = "postgres_url" }
HELLO_UPLOADS_DIR = { from = "dir", dir = "uploads" }
HELLO_STATE_DIR = { from = "dir", dir = "state" }
HELLO_NAME = { value = "e2e" }
HELLO_VERSION = { value = "__VERSION__" }
__EXTRA_ENV__

[limits.default]
memory_mb = 512
cpu_percent = 100
disk_mb = 5120

[backup]
postgres = true
dirs = ["uploads"]

[update]
strategy = "replace"
```

The corpus also holds a smaller `hello.toml` (with a `prompt` and a `generate` rule) used by the unit tests.

## Corpus

## Examples

Complete, provisioned-and-checked manifests live in [github.com/setucp/manifests](https://github.com/setucp/manifests):

- [`miniflux/manifest.toml`](https://github.com/setucp/manifests/blob/main/miniflux/manifest.toml): Miniflux, a Go feed reader shipped as a bare executable (`format = "binary"`), configured entirely by environment (`LISTEN_ADDR`, `DATABASE_URL`, `BASE_URL`), migrating and creating its admin on first boot from a `once` password, everything kept in PostgreSQL (no `dirs`).
- [`hello-axum/manifest.toml`](https://github.com/setucp/manifests/blob/main/hello-axum/manifest.toml): the Rust sample above (`apps/hello`) as a `tar.gz` release with a top-level folder, published in that repository's [`hello-axum-0.1.0`](https://github.com/setucp/manifests/releases/tag/hello-axum-0.1.0) release.

The repository's README explains how to add one.
