<!-- Markdown rendering of deploy.html for AI agents.
     The HTML page is the same content with diagrams and styling;
     this view exists so an agent spends its context on the instructions. -->
# Deploy an app, _four ways_, one contract

Everything needed to go from a project directory to a live, verified address: set up once, shape the project to the contract, create the app, then ship releases from an AI skill, MCP, the Apps client or plain REST. Addresses, access, environment and rollback follow. For the same tasks step by step, with the web page, see the [user manual](manual.html).

## §01 Set up once

You need the `app.deploy` capability on your account (an administrator grants it) and an API token from [/tokens.html](/tokens.html) created with the **Apps** preset, which can read and deploy apps and read your account profile, and nothing else. The Apps client reads `PITCHSTATION_APPS_TOKEN` and the MCP server `PITCHSTATION_MCP_TOKEN`; each falls back to the shared `PITCHSTATION_TOKEN`. Keep the token in a file only you can read, not in your shell profile. This reads it without showing it:

**Save the token**

```
{ mkdir -p ~/.pitchstation; printf 'Paste your PitchStation Apps token (it stays hidden): '; read -rs T; echo
  [ -n "$T" ] && ( umask 077; printf 'PITCHSTATION_URL=https://pitchstation.ai\nPITCHSTATION_APPS_TOKEN=%s\n' "$T" > ~/.pitchstation/.env; chmod 600 ~/.pitchstation/.env ); unset T; }
```

Then install whichever channels you will use. They share one packager and one API, so mixing them is fine.

### The Apps client (terminals, CI, and the MCP deploy tool)

One dependency-free Node file (Node 20 or newer). It packages a directory, deploys, and drives domains, access, env, rollback and readiness.

**Install the client**

```
mkdir -p ~/.pitchstation
curl -fsSL https://pitchstation.ai/downloads/pitchstation-apps.cjs -o ~/.pitchstation/pitchstation-apps.cjs
printf '%s\n' '#!/bin/sh' 'set -a; [ -f "$HOME/.pitchstation/.env" ] && . "$HOME/.pitchstation/.env"; set +a' \
  'exec node "$HOME/.pitchstation/pitchstation-apps.cjs" "$@"' > ~/.pitchstation/ps-apps && chmod 755 ~/.pitchstation/ps-apps
~/.pitchstation/ps-apps help
```

`ps-apps` loads the token file for that one command, so the token never sits in every terminal's environment. Every client command in this guide runs through it: the client reads the token only from its environment. Run the box again to update the client, and do so if `help` shows a `clientVersion` older than `2026-10-02`: older clients have no `releases` command, no Next.js scaffold and no `env_file` in the Compose files they generate. The MCP deploy tool uses the copy beside the server, so download that one again too.

### The AI skill (Claude Code, Codex and other agents with a shell)

**Install the skill**

```
mkdir -p ~/.claude/skills/create-pitchstation-app
curl -fsSL https://pitchstation.ai/downloads/create-pitchstation-app-SKILL.md \
  -o ~/.claude/skills/create-pitchstation-app/SKILL.md
```

The skill teaches the agent the contract below, when a request is a deploy versus an address change, and to show you every dry run before applying. It drives the Apps client, so install that too.

### MCP (assistants without a shell)

Put the MCP server and the Apps client in the **same folder**: the deploy tool loads the client to package your directory. The MCP server reads `PITCHSTATION_MCP_TOKEN`, then `PITCHSTATION_TOKEN`, never `PITCHSTATION_APPS_TOKEN`. The quick form below puts the token on a command line and stores it in the assistant's settings. `--scope user` registers the server for every folder; without it, Claude Code offers the tools only in the folder where you ran the command.

**Install and register**

```
mkdir -p ~/.pitchstation
curl -fsSL https://pitchstation.ai/downloads/pitchstation-mcp-server.js -o ~/.pitchstation/pitchstation-mcp-server.js
curl -fsSL https://pitchstation.ai/downloads/pitchstation-apps.cjs -o ~/.pitchstation/pitchstation-apps.cjs
claude mcp add --scope user pitchstation \
  --env PITCHSTATION_URL=https://pitchstation.ai \
  --env PITCHSTATION_MCP_TOKEN=pst_your_token_here \
  -- node ~/.pitchstation/pitchstation-mcp-server.js
```

To keep the token in your own file instead, add it there once under the MCP server's name, then register a command that loads the file each time the assistant starts the server:

**Add the token for the MCP server**

```
{ printf 'Paste the same token again for the MCP server (it stays hidden): '; read -rs T; echo
  [ -n "$T" ] && ( umask 077; printf 'PITCHSTATION_MCP_TOKEN=%s\n' "$T" >> ~/.pitchstation/.env; chmod 600 ~/.pitchstation/.env ); unset T; }
```

**Install and register (token stays in your file)**

```
mkdir -p ~/.pitchstation
curl -fsSL https://pitchstation.ai/downloads/pitchstation-mcp-server.js -o ~/.pitchstation/pitchstation-mcp-server.js
curl -fsSL https://pitchstation.ai/downloads/pitchstation-apps.cjs -o ~/.pitchstation/pitchstation-apps.cjs
claude mcp add --scope user pitchstation -- sh -c 'set -a; . "$HOME/.pitchstation/.env"; set +a; exec node "$HOME/.pitchstation/pitchstation-mcp-server.js"'
```

Saving the token again with the first box of this section rewrites the file, so add this line again afterwards. Codex takes the same with `codex mcp add`. Other MCP hosts take the same command, arguments and env as JSON (use absolute paths). The six app tools are `pitchstation_app_list`, `pitchstation_app_status`, `pitchstation_app_logs`, `pitchstation_app_deploy`, `pitchstation_app_operation` and `pitchstation_app_readiness`.

## §02 The app contract

Design the app around these five facts. Every one is load-bearing; the troubleshooting table in §08 is mostly what happens when one is missed.

**Ports: bind 0.0.0.0 inside, publish on loopback outside**

The platform gives the app a permanent `HOST_PORT` and writes `PORT=<containerPort>` and `HOST_PORT` into the release's `.env`. Publish only on the host's loopback: `"127.0.0.1:${HOST_PORT:-8000}:8000"`. Inside the container the app must listen on `0.0.0.0`, never `127.0.0.1`.

**Durable data lives on a named volume**

The Compose project is per app (`ps-<slug>`) and stable across releases, so named volumes survive every deploy. Anything written into the image filesystem is gone on the next release.

**An unauthenticated health endpoint**

The pipeline polls `healthPath` for HTTP 200. A `/` that redirects to a login page fails. Add `GET /api/health` → `{"ok":true}` and point `healthPath` at it.

**The migrate step is your idempotent hook**

`migrateCmd` runs as `docker compose run --rm <migrateService> <cmd>` (the web service unless you set `migrateService`) after the images exist and before the web container is recreated; a non-zero exit aborts the deploy and the previous release keeps serving. It runs on every deploy, so it must be safe to repeat. The command is split into words the way sh does and runs **inside the container**: shell operators outside single quotes are refused, so a command that needs a shell says so — `sh -c 'npm run db:migrate && npm run seed'`. Deploy hooks (§04) run the same way, each in a new one-off container: `localhost` there is not your running app.

**Secrets are stored server-side, encrypted**

Register them once (`env` when creating the app, or the env operation). The platform writes the release's `.env`; your Compose file reads it with `env_file: .env`. Never ship a `.env` in the upload — the packager excludes it anyway. The platform writes that file into the release folder before the build, so keep it out of the image with a `.dockerignore` (scaffold below).

### Scaffold

```
# docker-compose.yml
services:
  web:                                    # ← webService
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:${HOST_PORT:-8000}:8000"  # ← right side = containerPort
    volumes:
      - data:/app/data                    # ← durable state
    restart: unless-stopped
volumes:
  data:
```

```
# Dockerfile — non-root, with the volume-ownership fix
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# The volume copies this directory's ownership on first mount: create it,
# owned by the app user, BEFORE switching user.
RUN useradd -m app && mkdir -p /app/data && chown -R app:app /app
USER app
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
```

```
# .dockerignore — COPY . . must not bake the platform's .env (every secret) into the image.
# Runtime values still reach the container through env_file: .env.
.env
```

A **Vite + React** project with `package.json`, `package-lock.json`, a `build` script and `index.html` needs no Compose file: the packager adds a two-stage build and a static nginx server on port 3000, and stamps an identity marker into `index.html` that readiness checks for.

### App checklist

- **Serve static files by allowlist.** A catch-all file server will hand `/.env` or your source to the internet.
- **Behind a proxy:** trust `X-Forwarded-For`/`-Proto` only behind a flag, and mark cookies `Secure` — TLS ends at the platform.
- **Seed the first admin from env** (`ADMIN_USERNAME`/`ADMIN_PASSWORD`) inside the idempotent migrate hook.
- **Add an identity marker** such as `<meta name="pitchstation-app" content="acme-demo-v1">` and list critical assets, so readiness can prove the address serves _this_ app.
- **Smoke-test locally** with a fresh project name and volume: build, run the migrate hook twice, start, then check health 200 and that `/.env` and source paths return 404.

## §03 Create the app (once)

An app is the permanent record: slug, host, web service, container port, health path, migrate command, budget, access and encrypted env. See the hosts you can use first — your own and the shared ones, with free slots and your quota.

**See your hosts, describe the app**

```
~/.pitchstation/ps-apps targets
cat > app.json <<'JSON'
{ "slug": "acme-demo", "name": "Acme demo",
  "placement": { "region": "ap-southeast" },
  "webService": "web", "containerPort": 8000, "healthPath": "/api/health",
  "migrateCmd": "python manage.py migrate" }
JSON
```

Then create it, private from the first request (recommended) or public. `create` has no dry run: the app and its address exist as soon as it succeeds. For a private app, make a password file first; it must be readable by you alone.

**Make a password file**

```
( umask 077; openssl rand -base64 12 > ~/.acme-preview-password )
```

**Private (recommended)**

```
~/.pitchstation/ps-apps create --file app.json --private --password-file ~/.acme-preview-password
```

**Or public, with no login of its own**

```
~/.pitchstation/ps-apps create --file app.json --public --application-policy none
```

**Where it runs.** Give `"targetId"` to choose a host, or `"placement"` (region and/or `arch`) to let the platform choose among your own live hosts and the shared ones. A host must match `arch`. The region you name is a preference: the platform uses another region when none there has room, and says so. Then it takes the host with the most free budget, tells you why, and lists every host it passed over and the reason. Name `arch` too when the region matters, and use a region as `ps-apps targets` shows it. Apps on shared hosts count against your quota (`QUOTA_EXCEEDED`), a full host answers `HOST_FULL`, and a budget that does not fit answers `RESOURCE_ADMISSION`. **Budget:** `"resources": {"memMb": 768, "cpus": 1, "pids": 256}` (default 512 MB, 1 CPU, 256 processes); on a shared host it is enforced — the app is killed for memory inside its own budget, never its neighbours'.

The response carries the app `id` and its permanent `hostPort`. The address is `https://acme-demo.apps.pitchstation.ai` on the Oregon host (`.apps-ap.` on Singapore). **Singapore is ARM64:** use multi-architecture base images (the official `node`, `python`, `nginx` images are) and no x86-only binaries, or the build fails with `exec format error`. Creating an app is a write, not a preview. Put secrets in `env` here or with the env operation in §06, never in the project.

## §04 Deploy a release

Every channel follows the same rhythm: **dry run → review → apply with a key → follow the operation**, and for a public app one more step after its first deploy: **declare that the app has no access restriction of its own** (§05), which readiness requires. The dry run deploys nothing and returns the plan and the exact file list; only an image archive it carries is uploaded and kept (see image archives below). The apply returns an operation ID straight away; the release is live only when that operation reports `succeeded`. If your connection drops, repeat the _identical_ apply with the _same_ key — it returns the existing operation instead of deploying twice.

### With the Apps client

**Dry run**

```
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/path/to/project
```

Review the plan, the file list and any Compose warnings, then apply:

**Apply**

```
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/path/to/project \
  --apply --key release-20260927-01 --watch
```

`--watch` polls for up to five minutes; resume any time with `ps-apps operation --app 7 --operation OPERATION_ID --watch`. Output is JSON, so CI can parse it. On failure the client prints one JSON line with the code, the message and the details the server returned: the fields or Compose settings to fix, the reason, the hosts placement refused, and a correlation id. For a Compose or Next.js project, pass `--marker` with text your home page's HTML always contains, such as its title; readiness looks for it in the HTML as served, so text that JavaScript draws does not count.

### With MCP

Ask in plain language; the assistant calls the tools. A typical exchange:

**Say to your assistant**

```
Deploy /absolute/path/to/project as a new release of my PitchStation app "acme-demo".
Do a dry run first and show me the plan and file list. After I confirm, apply it with
idempotency key release-20260927-01, follow the operation until it finishes, then run readiness.
```

Behind that: `pitchstation_app_list` (find the id) → `pitchstation_app_deploy` with `apply:false` (the plan, the file list, Compose findings, the budget and packaging warnings) → your confirmation → `pitchstation_app_deploy` with `apply:true` and the key → `pitchstation_app_operation` until `succeeded` → `pitchstation_app_readiness`. For a Compose or Next.js project, ask the assistant to pass `marker`, text the home page's HTML always contains.

### With the AI skill

**Say to your agent**

```
Use the create-pitchstation-app skill. Make /absolute/path/to/project deployable to
PitchStation Apps (docker-compose, loopback port, health endpoint, named volume,
idempotent migrate), run the local smoke test, then deploy it to app "acme-demo".
Show me the dry-run plan before applying, and report readiness for every address.
```

The skill will not create cloud resources, change addresses or deploy anything you did not ask for, and it reports checks it could not run separately from checks that passed.

### With REST

Build the archive with the client's packager (it applies the exclusions and limits the server enforces), then post it. First load the token into this one shell: it sets `PITCHSTATION_URL` and `PITCHSTATION_APPS_TOKEN`.

**Load the token for this shell**

```
set -a; . ~/.pitchstation/.env; set +a
```

**Package, then dry run**

```
~/.pitchstation/ps-apps package --source /absolute/path/to/project --output app.tgz
printf '{"dryRun":true,"tarballBase64":"%s"}' "$(base64 < app.tgz | tr -d '\n')" > plan.json
printf 'Authorization: Bearer %s\n' "$PITCHSTATION_APPS_TOKEN" | curl -sS -H @- -X POST "$PITCHSTATION_URL/api/apps/7/deploy" \
  -H 'Content-Type: application/json' --data-binary @plan.json
```

It answers `{ dryRun: true, plan: { liveUrl, steps } }`. The token reaches `curl` on its input (`-H @-`, curl 7.55 or newer), so it never appears in the list of running commands. Check the plan, then apply:

**Apply**

```
printf '{"dryRun":false,"tarballBase64":"%s"}' "$(base64 < app.tgz | tr -d '\n')" > apply.json
printf 'Authorization: Bearer %s\n' "$PITCHSTATION_APPS_TOKEN" | curl -sS -H @- -X POST "$PITCHSTATION_URL/api/apps/7/deploy" \
  -H 'Content-Type: application/json' -H 'Prefer: respond-async' -H 'Idempotency-Key: release-20260927-01' \
  --data-binary @apply.json
```

It answers 202 with `{ accepted: true, operation: { id, statusUrl } }`. Poll the operation until its status is `succeeded`:

**Poll the operation**

```
printf 'Authorization: Bearer %s\n' "$PITCHSTATION_APPS_TOKEN" | curl -sS -H @- "$PITCHSTATION_URL/api/apps/7/operations/OPERATION_ID"
```

**Private from the very first release?** Create the app with `--private --password-file` (§03), or deploy with `--password-file` (client), `password_file` (MCP: an absolute path to a file only you can read) or `"previewPassword"` in the REST apply body (12–72 bytes, one you keep). Never paste the password into a chat or a command line. Do not deploy publicly and plan to add the gate later. On later deploys leave `--password-file` off: any password a deploy sends, even the same one, sets the gate again and ends every visitor session.

**Compose rules.** Every deploy checks your Compose file — at the dry run, and again on the host with the fully resolved model before anything is built. On shared and strict hosts these settings are refused, each named in `fields` (for example `services.web.privileged`): `privileged`; host or other containers' namespaces; `cap_add`; `devices`; unconfined security options; other runtimes; bind mounts, env files and build contexts outside the release; the Docker socket; external or foreign volumes and networks; volume driver options; ports other than `"127.0.0.1:${HOST_PORT}:<containerPort>"` on the web service. Guarded values must be literal (only `${PORT}` and `${HOST_PORT}` are allowed). On your own standard host the same findings are warnings. Since milestone 2 these are checked on every host too, refused wherever the rules are enforced and warnings on your own standard host like the rest: `use_api_socket`, provider services, GPUs and device reservations, the host cgroup namespace, a negative `oom_score_adj`, BuildKit entitlements, and `extends`, `label_file` or `include` files outside the release. A **strict** host (§13) goes further: only reviewed Compose keys are accepted.

### What gets packaged

The client packs the source directory and leaves out:

- VCS and dependency folders: `.git`, `node_modules`, `venv`, `.venv`;
- build output: `.next`, `.turbo`;
- any folder or file named `backup` or `backups`, anywhere in the tree;
- credential folders: `.aws`, `.ssh`, `.codex`; and `.DS_Store` files;
- `.env` files (templates — `.env.example`, `.env.sample`, `.env.template`, `.env.dist` — are kept);
- private keys and key files: `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa`, `id_ed25519`;
- SQLite files (`*.db`, `*.sqlite`, `*.sqlite3` and their `-wal` and `-shm` files), with no warning;
- Postgres or PGlite data folders (`PG_VERSION`, `pg_wal/`) and a Redis `dump.rdb`, with a warning;
- anything listed in a `.pitchstationignore` at the source root (gitignore syntax: `*.log`, `/fixtures/`, `docs/**`, `!docs/keep.md`).

Check the dry run's file list. A raw REST upload that carries any of these, apart from the `.pitchstationignore` entries, is refused, and the error names the path.

Without a Compose file the client scaffolds two kinds of project: **Vite + React** (npm) and **Next.js with `output: 'standalone'`** (npm or pnpm; in a workspace, name the app with `--app-dir apps/web`). Next.js needs `--marker` for readiness. A Compose file that is not at the source root is found through the app's `composeFile` setting (below); relative paths in it resolve from its own directory.

### Build elsewhere: image archives

A deploy builds on the app's host, one build at a time, inside the host's **build ceiling** (40% of its memory by default; a build that runs out fails as `BUILD_LIMIT_EXCEEDED` with the limit). For a heavy framework build, build on your machine, in CI or on a Devbox, and deploy the images: the host loads them and builds nothing. Every service with `build:` needs an `image: name:tag` (use a tag that changes with each release, so a rollback can find older images), and the images must match the host's CPU: each shared host has a fixed architecture, and an isolated host has the one you chose; `ps-apps targets` shows it, and the dry run refuses a mismatch.

**Build and save the images**

```
~/.pitchstation/ps-apps archive --source /absolute/path/to/project --platform linux/arm64 --output images.tar.gz
```

**Upload once, dry run**

```
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/path/to/project --image-archive images.tar.gz
```

**Deploy**

```
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/path/to/project --image-archive images.tar.gz \
  --apply --key release-20260930-01 --watch
```

`archive` runs `docker compose build` with your local Docker and writes `docker save … | gzip`; it never overwrites `--output`, so use a new file name for each build, outside the project. `deploy --image-archive` uploads it once (the same file is not sent again; `images --app 7` lists what the platform keeps: the newest three, up to 1,024 MB each) and deploys with it. By REST: `PUT /api/apps/7/images` with the raw file, `X-Content-SHA256` and `Content-Length`, then deploy with `{"imageArchive": "ARCHIVE_ID"}`. With MCP: `pitchstation_app_deploy` takes `image_archive`.

**On a Devbox** (sized for builds), one command builds for the app host's architecture and deploys: `python3 ~/.claude/skills/pitchstation-devbox/devbox.py app-deploy box-name --app 7 [--apply]`, where the `pitchstation-devbox` skill installs it. It takes the Apps token from the environment or from `~/.pitchstation/.env`, where the setup above saved it, and the token reaches the box on the SSH session's input, never on a command line or its disk. With `--apply` it follows the operation for up to five minutes and prints where it ended.

### Migrations, deploy hooks and the Compose file's path

**settings.json**

```
{ "migrateCmd": "npx prisma migrate deploy",
  "migrateService": "api",
  "hooks": {
    "preDeploy":  [{ "service": "api", "cmd": "npm run check-schema", "timeoutSec": 120 }],
    "postDeploy": [{ "service": "web", "cmd": "node scripts/warm-cache.js", "timeoutSec": 60 }] },
  "composeFile": "deploy/pitchstation/compose.yaml" }
```

**Dry run**

```
~/.pitchstation/ps-apps settings --app 7 --file settings.json
```

**Store**

```
~/.pitchstation/ps-apps settings --app 7 --file settings.json --apply
```

Pre-deploy hooks (up to five) run after the images exist and before the migration; a failure stops the deploy like a failed migration. Post-deploy hooks run after the health check and only warn: the release stays live and the step shows the hook's output. Each hook runs in a new one-off container of its service (`docker compose run --rm`), where nothing else listens, so a hook calls a script rather than `localhost`. A rollback runs neither. Settings apply from the next deploy; an operation already accepted keeps the settings it was accepted with. The deploy's dry run (not the settings dry run) refuses a hook or migration in a service the Compose file does not define, unless the file uses `include`. Commands are never shown back.

## §05 Addresses and access

Address and access changes are **configuration operations**: they never rebuild or restart the app. Each is planned against the current configuration version (the ETag), reviewed, then applied with that version and a key. A plan expires after 15 minutes; a stale one is replaced, never forced.

### Custom domain

**Plan**

Describe the whole desired address set. Hosts you omit stay attached; detaching is explicit. Aliases either `serve` or `redirect` (308) to the primary; the platform address is `retain`, `redirect` or `disabled`.

**Prove ownership**

The plan lists a TXT record per hostname: `_pitchstation.<hostname>` with an app-scoped value. Add it at your DNS provider, and point the hostname at the app host (A record or CNAME) if it does not already. A working URL is not proof; the TXT record is. Keep the record: every operation that updates routing, deploys included, checks it again, and without it they wait in `waiting_external` for about 75–90 minutes, then fail.

**Verify, then apply**

Verification checks authoritative and independent resolvers without issuing certificates or touching the host. Apply issues a certificate per hostname and activates the route.

**Client**

```
cat > domains.json <<'JSON'
{ "primary": "demo.example.com",
  "aliases": [{ "hostname": "www.demo.example.com", "mode": "redirect" }],
  "platform": "retain" }
JSON
~/.pitchstation/ps-apps domains plan --app 7 --file domains.json
```

The plan prints its `id`, its `etag` and the TXT records. Add them, then verify and apply:

**Verify**

```
~/.pitchstation/ps-apps domains verify --app 7 --plan PLAN_ID
```

**Apply**

```
~/.pitchstation/ps-apps domains apply --app 7 --plan PLAN_ID --etag '"app-7-v2"' --key domains-20260927-01
```

### Access and expiry

Access is `public` or `private` (a password of 12–72 UTF-8 bytes that you choose and keep). A private app shows people a small **sign-in page**; the password sets a session cookie for 12 hours, and any password you send ends every session, even when it is the same one again. Scripts and API clients can still send HTTP Basic (user `preview`). Once a visitor is signed in, their own `Authorization` header reaches your app, so a bearer-token API works behind the gate. The paths under `/__ps/` belong to the gate on a private app. An expiry, with an explicit timezone, closes the app at that moment on every address; it is checked on each request on the host, so it holds even if PitchStation is unreachable. Omit `password` to keep the current one and its sessions; omit `expiresAt` to keep the current expiry; send `null` only to remove it.

**Client — policy.json kept owner-only**

```
cat > policy.json <<'JSON'
{ "access": "private", "password": "a-long-password-you-keep", "expiresAt": "2026-10-31T23:59:00+08:00" }
JSON
chmod 600 policy.json
~/.pitchstation/ps-apps policy plan --app 7 --file policy.json
```

**Apply the reviewed plan**

```
~/.pitchstation/ps-apps policy apply --app 7 --plan PLAN_ID --etag '"app-7-v3"' --key policy-20260927-01
```

The same flows are available by hand at [/apps.html](/apps.html): pick the app, edit domains or access, review the plan, apply, then check readiness and copy a redacted receipt.

### Access links for one person

An **access link** opens a private app for one named person without the password: `https://<primary>/__ps/enter?t=…`. Give it an optional expiry; revoke it on its own at any time, which also ends the sessions it opened. Creating or revoking a link re-applies the app's routing (a few seconds), so the link works once that operation succeeds.

**Create a link**

```
~/.pitchstation/ps-apps links --app 7 --create --label "Alice (investor)" --expires 2026-10-31T23:59:00+08:00
```

**List links (state and URL of each)**

```
~/.pitchstation/ps-apps links --app 7
```

**Revoke one**

```
~/.pitchstation/ps-apps links --app 7 --revoke LINK_ID
```

A link gives its person a session of up to 12 hours; opening it again starts a new one.

### Public apps: declare the application's own restrictions

The platform controls only its own gate. Whether your application adds a login, an invitation code or its own expiry is something only you know, so every app records an **application policy**:

| Value | Meaning | Readiness for a public app |
| --- | --- | --- |
| unknown | Not declared yet. **A new app starts here unless you declare it at creation**, so nothing is ever assumed open. | Fails with `ACCESS_POLICY_MISMATCH` and `fields: ["applicationPolicy"]` |
| none | The owner confirms the app itself restricts nobody: anyone with the address can use it (guest play, open demo). | Certified when TLS, identity and assets pass |
| restricted | The app has its own login or expiry. | Not certified by readiness by design; verify access in a browser |

The simplest way is at creation: `create --public --application-policy none` (or `"publication": {"access": "public", "applicationPolicy": "none"}` in `POST /api/apps`). For an app created without it, declare `none` after the first deploy, if it is true, before you run readiness. It is a policy operation — no rebuild, no restart:

**Plan**

```
printf '{ "access": "public", "applicationPolicy": "none" }\n' > policy.json
~/.pitchstation/ps-apps policy plan --app 8 --file policy.json
```

The plan prints its `id` and `etag`. Apply it, then run readiness:

**Apply, then prove**

```
~/.pitchstation/ps-apps policy apply --app 8 --plan PLAN_ID --etag '"app-8-v3"' --key policy-20260927-01
~/.pitchstation/ps-apps readiness --app 8
```

On the web page it is the **Separate application login or expiry** selector: choose _I confirm the application has no separate restrictions_, review the plan and apply. Declaring `none` for an app that does show a login page does not help: readiness also looks for a password field on the page and fails either way.

**Platform gate versus your app's own login.** `access: public` removes only the platform's gate. If your app has its own login or expiry, that still applies — declare it as `restricted`, and check access in a browser.

### Private apps: callbacks, caching, sign-in and link use

**policy.json (plan, then apply as above)**

```
{ "publicPaths": ["/api/payments/webhook/"],
  "cachePaths":  ["/_next/static/"],
  "accounts":    { "users": ["alice"], "teams": ["@you/design"] } }
```

- **Callback paths** — up to five prefixes served _without_ the gate, for signed machine callbacks (payment webhooks, OAuth redirects). Expiry still applies, and your app must verify each callback's signature: these paths are open to anyone who finds them.
- **Caching** — a private app is `no-store` by default. Responses your app marks `immutable`, or that lie under a `cachePaths` prefix, may stay in the signed-in viewer's own browser cache (`private, max-age=31536000, immutable`). Revoking someone's access does not remove files already cached on their device, so keep this to content-hashed assets.
- **Sign in with PitchStation** — listed people, and members of listed teams, open the app with their PitchStation account from its login page; membership is checked at every sign-in. Removing someone takes effect when their session ends (twelve hours at most); removing `accounts` ends every account session at once.
- **Link use** — each access link shows how often it was opened and when it was last used, reported by the host every five minutes (link ids and times only).

## §06 Environment variables

`PATCH env` previews by default: it lists which keys would change and which stay. Applying needs the configuration ETag and a key, then **restarts the current release** with the new values (no rebuild). Unrelated keys are preserved. The API never returns values.

**Client**

```
cat > env.json <<'JSON'
{ "set": { "API_BASE": "https://api.example.com" }, "unset": ["OLD_FLAG"] }
JSON
~/.pitchstation/ps-apps env --app 7 --file env.json
```

That previews the change. Applying restarts the app:

**Apply**

```
~/.pitchstation/ps-apps env --app 7 --file env.json --apply --key env-20260927-01
```

Reserved and refused: `PORT`, `HOST_PORT`, `COMPOSE_*`, `DOCKER_*`, key names that are not identifiers, and multi-line or NUL values. The older `PUT env` replaces the whole stored set and does not apply it until the next deploy.

### Build-time keys

Frameworks bake public variables into the build (`NEXT_PUBLIC_*`, `VITE_*`, `REACT_APP_*`, `NUXT_PUBLIC_*`, `EXPO_PUBLIC_*`, `GATSBY_*`, `PUBLIC_*`), so an env change — a restart — cannot apply them. Those keys count as **build-time**; declare others (or declare one of those _runtime_) with `scopes`. Changing a build-time key: the preview lists it under `buildScopedKeys` with `rebuildRequired: true`, the change is applied, and the app shows `buildStale` until the next successful deploy picks the value up. The platform writes the release's `.env`, with every key, into the release folder before it builds, so the file is part of the default build context (`build: .`) unless a `.dockerignore` leaves it out. A framework that reads `.env` while it builds, as Vite and Next.js do, picks the values up; the client's Vite scaffold, and its Next.js scaffold for an app at the source root, work this way. A Next.js app in a workspace (`--app-dir`) does not: Next.js reads `.env` only from the app's own folder. An image-archive deploy is built on your machine with your local env, so the platform's values never reach it, yet the deploy still clears `buildStale`. Whatever your build copies stays in the image: keep `.env` out with a `.dockerignore` unless the build needs build-time keys, and if it does, use a multi-stage build and copy only the build output into the final stage. Do not interpolate them into `build:`: on shared and strict hosts a `${…}` there is refused (`COMPOSE_POLICY_VIOLATION`, `must-be-literal`), since guarded settings allow only `${PORT}` and `${HOST_PORT}`.

**Declare scopes (no restart)**

```
{ "scopes": { "BASE_PATH": "build", "NEXT_PUBLIC_FEATURE_X": "runtime" } }
```

## §07 Operate

| Task | Client | MCP | REST |
| --- | --- | --- | --- |
| See every app, and the hosts you can use (own and shared, with your quota) | list · targets | pitchstation_app_list | GET /api/apps · /api/apps/targets |
| Configuration, hosts, DNS records, recent operations | show --app 7 | pitchstation_app_status | GET /api/apps/7/domain-config |
| Follow an operation | operation --app 7 --operation ID --watch | pitchstation_app_operation | GET /api/apps/7/operations/ID |
| Retry failed safe steps / cancel | retry \| cancel --app 7 --operation ID | …_operation action:retry\|cancel | POST …/operations/ID/retry \| /cancel |
| List releases (ids for rollback) | releases --app 7 | — | GET /api/apps/7 |
| Roll back | rollback --app 7 [--release 24] --apply --key K | — | POST /api/apps/7/rollback |
| Prove it works | readiness --app 7 | pitchstation_app_readiness | POST /api/apps/7/readiness |
| Redacted publication receipt | receipt --app 7 | — | GET /api/apps/7/publication-receipt |
| Stop (volumes and releases kept) | — | — | POST /api/apps/7/stop {"dryRun":false} |
| Containers, health, restarts, usage vs budget | status --app 7 | pitchstation_app_status | GET /api/apps/7/status |
| Logs (redacted, recorded) | logs --app 7 --service web --tail 200 --since 10m | pitchstation_app_logs | GET …/logs?service=web&tail=200 |
| Change the budget (live on shared hosts) | resources --app 7 --file r.json [--apply --key K] | — | PATCH /api/apps/7/resources |
| Resolve an uncertain operation yourself | reconcile --app 7 --operation ID [--inspection I --file attest.json --apply] | — | POST …/operations/ID/reconcile |
| Access links (private apps) | links --app 7 [--create --label L \| --revoke ID] | — | GET/POST /api/apps/7/access-links |
| Backups: configure, take, restore | backup-config · backups · restore | — | PATCH …/backup-config · POST …/backups · POST …/restore |
| Move to another host | move --app 7 --target 5 [--apply] · moves --app 7 | — | POST /api/apps/7/move · GET …/moves |
| Your own isolated hosts and spend | host-catalog · hosts · spend | — | GET/POST /api/apps/hosts · GET /api/apps/spend |
| Read-only services | hardening --app 7 --file h.json [--apply] | — | PATCH /api/apps/7/hardening |
| Deploy settings: migration service, hooks, Compose path | settings --app 7 --file settings.json [--apply] | — | PATCH /api/apps/7/settings |
| Image archives: build elsewhere, upload once | archive --source DIR · images --app 7 [--file F \| --remove ID] | …_deploy image_archive | GET/PUT /api/apps/7/images |
| Build on a Devbox and deploy (token from `~/.pitchstation/.env`) | devbox.py app-deploy BOX --app 7 [--apply] | — | — |
| Publisher terms (accept them on the Apps page, signed in; scoped tokens are refused) | terms | — | GET /api/apps/terms · POST …/terms/accept |

The same status, logs, budget and reconcile controls are on [/apps.html](/apps.html). When a step fails, its operation shows the **redacted tail of that step's own output** (build, migration, Compose, health) — values from your environment are masked; TLS and routing steps show only their classification.

**Operation states:** `queued` → `running` → `succeeded`, or `failed` / `cancelled`. `waiting_external` means a prerequisite outside the platform (usually DNS) is missing. `cancel_requested` stops at the next safe checkpoint. A cleanup state of `reconciliation_required` means the remote outcome is uncertain and a blind retry is refused. The operation says who resolves it: `reconcile.by: "owner"` when only your containers were involved (a migration, `up`, health) — inspect (this pauses the app's queue of operations while the app keeps serving; the host is read, not changed), check your database, then confirm with a short note; `"operator"` for routing, certificates, a restore or a quarantined host (a step that timed out or lost its connection) — an admin resolves it from the admin page. Either way the failed operation stays failed; deploy or roll back again to converge.

**Rollback** recreates a retained release from its Compose file and keeps the current domains, access, budget and last applied env. Every release runs as the same Compose project and the host builds images under the same names, so services the host builds come back at their old code only if each release tags its images differently (for example `image: acme-web:2026-10-02-1`); images named by tag come back. Without `--release` it goes to the newest healthy release other than the current one. Normally that is the previous release; after a rollback it is the newer release you rolled back from. Check the dry run's `to` (a release's `releaseTs` in `releases --app 7`), or name the release: `releases --app 7` lists the ids. It does **not** undo database migrations: keep migrations forward-compatible. **Deleting** an app on your own host removes its registry record only, and keeps its addresses reserved for good (even the same slug cannot be created again); stop it and add `?removeFromHost=1` to clear it from the host and release its addresses. On a **shared** host the app must be stopped first (`APP_RUNNING`); deletion then removes its containers, routing and budget from the host and releases its addresses, and keeps its data volumes. A stopped copy left by a recent move must be removed first (§11).

## §08 Troubleshooting

| Symptom | Cause → fix |
| --- | --- |
| 403 `CAPABILITY_REQUIRED` | The token's user lacks `app.deploy`. Ask an administrator. |
| `BUILD_LIMIT_EXCEEDED` | The build ran out of the host's build ceiling (`detail.limitMb`). Make the build lighter, or build elsewhere and deploy an image archive (§04). |
| `IMAGE_ARCH_MISMATCH` · `IMAGE_ARCHIVE_INCOMPLETE` | The archive's images are for the other CPU (rebuild with `--platform`), or a service with `build:` has no `image:` tag the archive holds. |
| `DEPLOY_SETTINGS_INVALID` | A migrate or hook command uses a shell operator (wrap it in `sh -c '…'`), names a service the Compose file does not define, or a setting is out of range; `fields` and `detail.reason` say which. |
| `HOST_NOT_APPROVED` or `LIVE_DISABLED` on apply | The host is not approved for live publishing (an admin runs its host check and approves it), or live publishing is off on this server. Dry runs still work. |
| 400 `COMPOSE_POLICY_VIOLATION` | A Compose setting the host does not allow; `fields` names each one. See the Compose rules in §04. |
| `QUOTA_EXCEEDED` · `HOST_FULL` · `RESOURCE_ADMISSION` · `PLACEMENT_UNAVAILABLE` | Your shared-host quota is used, the host has no free slot, the budget does not fit, or no host matched (`detail.rejected` gives each host's reason). Delete an app, lower the budget, pick another region, or ask an admin. |
| An app keeps restarting, `status` shows "out of memory" | It exceeded its memory budget and was killed inside it. Raise the budget (`resources`) or reduce its memory use. |
| `reconciliation_required` after a failed migration | Read the failed step's output in the operation, fix or check the database, then `reconcile` (inspect, confirm) and deploy again. |
| First deploy fails at `migrate`: "unable to open database file" | Volume mountpoint is root-owned. Create and `chown` the data directory in the Dockerfile before `USER`. |
| Second deploy fails at `migrate` | Seeding is not idempotent. Check before you create. |
| `health` step times out, app runs locally | The app listens on 127.0.0.1 (use 0.0.0.0), or `healthPath` redirects or needs a login. `up` has already replaced the containers, so the address serves the failing release until you act: retry if it was only slow to start, or deploy a fix with a new key. To roll back, name the last good release (`releases --app 7` shows it as live, then `rollback --app 7 --release ID`); a plain rollback skips it, and code built on the host comes back only with per-release image tags. |
| 400 `SOURCE_INVALID` | The error's `reason` says why (`too-large`, `symlink`, `private-key`, `excluded-path`, `unsafe-path`, `duplicate`, …) and `path` names the entry. Check the dry-run manifest. |
| 500 `INTAKE_FAILED` | An unexpected server error while reading the upload; nothing was accepted. Retry once, then report the `correlationId`. |
| `OPERATION_BUSY` | Another operation owns the app or its host. Wait for it to settle, then retry with the same key. |
| `TARGET_UNREACHABLE` / `TARGET_FENCED` | The host could not be reached, or a newer publisher took the host lease. Nothing was changed; retry. |
| `PRECONDITION_REQUIRED`, `CONFIG_VERSION_CONFLICT`, `PLAN_EXPIRED` | Send the current ETag in `If-Match`; if the configuration moved or 15 minutes passed, make a new plan. |
| `OWNERSHIP_UNVERIFIED`, `DNS_TARGET_MISMATCH`, `DNS_PROPAGATING` | Add the exact TXT record shown for each hostname and point the host at the app host; wait for propagation, then verify again. Keep the TXT record afterwards: every routed operation, deploys included, checks it again, and a deleted one stalls deploys in `waiting_external` (and other changes with `OPERATION_BUSY`) for about 75–90 minutes. |
| `TLS_VALIDATION_FAILED` / `TLS_RATE_LIMITED` | At domain verification, `TLS_VALIDATION_FAILED` means the hostname's CAA records do not allow Let's Encrypt: add `0 issue "letsencrypt.org"` (or remove the restriction) and verify again. Otherwise certificate issuance failed or is rate-limited: hand the code to the operator, and do not retry in a loop. |
| Readiness: `ACCESS_POLICY_MISMATCH` with `fields: ["applicationPolicy"]` | A public app whose owner has not declared that the app restricts nobody. Declare `applicationPolicy: "none"` with a policy plan/apply (§05) or the web selector, then run readiness again. Nothing is rebuilt. |
| Readiness: `ACCESS_POLICY_MISMATCH` without `fields` | The page shows a password field (the app's own login), or the platform gate answered differently from the requested access. Check the app and the policy separately. |
| Every path returns nginx 500 after a managed apply | The host's policy check could not read the app's policy. A platform bug with this symptom was fixed on 27 September 2026; if you still see it, report the app id — do not redeploy repeatedly. |
| `exec format error` or a failed build on Singapore | An image or binary is x86-only. Singapore is ARM64: switch to multi-architecture images, or, if `ps-apps targets` lists an x86 host you may use, create the app there. |
| Data gone after a release | State was written outside the named volume. |
| Secrets visible at some URL | Catch-all static serving. Switch to an allowlist and rotate the exposed secrets. |
| `BACKUP_FAILED` during a deploy | The backup a deploy takes before its hooks, migration and start failed, so none of them ran and the running containers were not changed; the new build is already on the host, though, so deploy the fix before any env change, which would start it. Read the step output (a wrong service, command or volume name, or a stopped database), fix the backup settings (§10) and deploy again. |
| `MOVE_UNSUPPORTED` · `MOVE_IN_PROGRESS` | The move cannot happen as asked (`detail.reason`), or one is already running: changes wait until it finishes (§11). |
| `COST_CAP` | The host would take your monthly spend over your cap (dev instances included). Choose a smaller size or ask an admin to raise the cap (§12). |
| `APP_TAKEN_DOWN` · `TERMS_REQUIRED` | An administrator took the app down after a report (it serves 404 until lifted), or the current publisher terms must be accepted first (§13). |

Every error carries a stable `code`, whether it is retryable, who must act (publisher or operator) and the next action. The full list is in the [API reference](api.html#errors).

## §09 Before you announce it

- The deploy operation reports `succeeded` — not just accepted.
- For a public app, the application policy is declared `none` (§05) — otherwise readiness cannot turn green.
- Readiness is green for **every** hostname: TLS, access policy (anonymous visitors are refused if private), identity marker and critical assets.
- Anything interactive — video, 3D, language switching, payments — was tried in a browser. HTTP readiness cannot certify those.
- If private: the password went to the audience separately, and the expiry is what you meant, with its timezone.

## §10 Backups and restore

Tell the platform how your app's data is backed up, once. Once it is set up, a deploy of an app that already has a release backs up first: right before its first pre-deploy hook, else its migration, else the start of the new release (unless `beforeDeploy` is false). Backups also run on demand, and daily if you ask. The host keeps the newest few; the platform keeps the newest 10 away from the host, and they leave the server with the platform's own backups.

| Kind | You give | Good for |
| --- | --- | --- |
| command | a service, a command whose output is the dump (`pg_dump -U app -Fc app`, `sqlite3 /app/data/app.db .dump`), and the command that reads it back from stdin | databases: a consistent dump while the app runs |
| volumes | the names of your Compose volumes | files and uploads; restored with the app stopped |

**Write the settings, preview them**

```
printf '{ "kind": "command", "service": "db", "cmd": "pg_dump -U app -Fc app", "restoreCmd": "pg_restore -U app -d app --clean", "schedule": "daily" }\n' > backup.json
~/.pitchstation/ps-apps backup-config --app 7 --file backup.json
```

The preview shows the exact command the host will run. Save the settings, then back up whenever you like:

**Save**

```
~/.pitchstation/ps-apps backup-config --app 7 --file backup.json --apply
```

**Back up now**

```
~/.pitchstation/ps-apps backups --app 7 --apply --key backup-20260928-01
```

To restore, preview first: the dry run says what is replaced.

**Preview a restore**

```
~/.pitchstation/ps-apps restore --app 7 --backup BACKUP_ID
```

**Restore**

```
~/.pitchstation/ps-apps restore --app 7 --backup BACKUP_ID --apply --review PLAN_REVIEW --confirm my-app --key restore-01
```

A failed backup stops the deploy **before** its hooks, migration and start, and shows the command's output. The running containers are unchanged, but the new build is already on the host: deploy a fix before any env change, which would start it. A restore uses the recipe recorded with the backup, replaces the app's data and needs the dry run's `plan.review` fingerprint (`--review`) plus the app's slug as confirmation; if it fails part-way the state is uncertain, and only an operator can reconcile it (§07). You can download the platform's copy of any backup from the Apps page.

### Repeat an app for another customer

Create with `{"slug":"customer-acme","fromApp":41,"targetId":5,"tags":{"customer":"acme"},"env":{"CUSTOMER_ID":"acme"},"publication":{"access":"private","password":"NEW_PRIVATE_PASSWORD"}}`. The template must belong to your account. Only its structural settings are copied; provide fresh environments and private credentials. No data, domains, access links, grants, hooks or releases travel to the new app. Configuration command strings are copied; keep credential values in environment variables.

Upload the image archive once, then deploy the new app with `imageArchive:{appId:41,id:"ARCHIVE_SHA256"}` and its source/Compose bundle. The server retains a destination archive independently of the source. The source bundle still needs a Compose file whose built services name images contained in the archive.

**Reuse an uploaded archive**

```
~/.pitchstation/ps-apps deploy --app NEW_APP_ID --source . --image-archive-id ARCHIVE_SHA256 --image-archive-app 41 --apply --key customer-acme-v1 --watch
```

Check `receipt --app NEW_APP_ID` for the observed `imageDigest` and separate `archiveDigest`. Image evidence is frozen per release; `imageEvidenceStatus: "unavailable"` and null IDs mean inspection evidence was unavailable. Actual ready time must be measured on your deployment hosts.

Use `list --customer acme`, or set labels with `tags --app NEW_APP_ID --file tags.json` containing `{"customer":"acme"}`. Tags also appear beside each app's spend. An administrator can explicitly assign an operator policy (for example 25 pool apps with 512 MB defaults); the usual quota, host slots and memory limits remain in force until that assignment. No plan pricing is implied.

### Private webhook endpoints

Set `publicPaths:[{"path":"/api/integrations/pitchstation/webhook","methods":["POST"]}]` in a policy plan, then apply it. The Private extras form accepts `POST /api/integrations/pitchstation/webhook`. Only POST to that exact path skips the gate; expiry still blocks it, and other methods answer 405. The application must validate the signed payload and reject replays. Legacy `/prefix/` entries still admit all methods beneath that prefix.

## §11 Moving to another host

A move copies the release, stops all app services on the current host, takes a final volume backup, builds the release on the destination (a release deployed from an image archive is loaded instead) and restores the data, then routes it there (certificates, access, the DNS record) and proves readiness. Once the move succeeds, the stopped copy stays on the old host for 7 days. The app stays stopped from before the final snapshot until the destination serves, including backup transfer, build and restore.

**Dry run**

```
~/.pitchstation/ps-apps move --app 7 --target 5
```

The dry run shows the destination, the address change, how the data travels and the steps. Then start the move and follow it:

**Start**

```
~/.pitchstation/ps-apps move --app 7 --target 5 --apply
```

**Follow**

```
~/.pitchstation/ps-apps moves --app 7
```

- **Data travels through a volume backup** (§10), covering all app data. Command backups need a running service and have no supported write-quiescence procedure, so stateful moves using them answer `MOVE_UNSUPPORTED`; ordinary command backups and restores remain available. An app that keeps nothing moves with `--stateless`.
- **The platform address** stays the same between hosts that share a base domain with per-app DNS, and changes when the destination has a different base domain: across regions (for example `shop.apps-ap…` to `shop.apps…`), and to an isolated host without per-app DNS, even in the same region (`shop.h7.apps-ap…`). Two hosts under one base domain answer `MOVE_UNSUPPORTED` unless per-app DNS is set up. Once the move succeeds, the old address redirects to the new one until the old copy is removed.
- **Custom domains** stay attached. Lower their DNS TTL beforehand, and point each one at the destination's address (the dry run shows it) as soon as the move has stopped the source: the destination routes the app and issues its certificates only once every name resolves to it, and until then no address serves the app. After 45 minutes the move is recorded as failed after the switch; its routing operation keeps waiting and finishes once DNS resolves. If that operation fails too, roll back to the current release.
- **After a platform restart,** a move already on the destination is finalized when its routing operation succeeded, or marked failed for recovery there. The app stays on the destination; inspect its routing operation before retrying routing. Failed moves retain their source without automatic removal, and cannot be aborted back to it.
- **Moving back** within the 7 days replaces the copy the first move left on that host.
- **Anything that fails before the switch** removes what the destination got and starts the app again where it was.

## §12 Your own host and spend

Besides the shared hosts, you can reserve an **isolated host**: a machine nobody else's apps share, in Oregon or Singapore, arm64 or x86, from 2 to 16 GB. The price is all-in (compute, disk and the public IPv4 address) and counts against your monthly cap together with any dev instances; the reservation is refused if it would exceed it. An administrator launches it within a day; once approved it appears in your host list and `create` can place apps on it. It runs the strict Compose rules and restricted egress (§13) from its first boot. Per-app budgets there are stored and only warn, unless the platform sets `APP_RESOURCE_LIMITS=enforce`.

**Sizes and a quote against your cap**

```
~/.pitchstation/ps-apps host-catalog
printf '{ "name": "lab", "sizeClass": "S", "arch": "arm64", "region": "us-west-2" }\n' > host.json
~/.pitchstation/ps-apps hosts --file host.json
```

**Reserve**

```
~/.pitchstation/ps-apps hosts --file host.json --apply
```

**Spend: cap, committed, each app's share**

```
~/.pitchstation/ps-apps spend
```

Each app is shown its share of its host's monthly price, split by memory budget, and who pays: you on your own host, the platform on a shared one. Release a host you no longer need with `hosts --release ID`, using the id that `hosts` lists (not the host id from `targets`); move or delete its apps first. It acts at once, with no dry run.

**Your host reports on itself** every 5 minutes (memory, disk, load, containers). Your host list shows the latest numbers, says when a host has stopped reporting, and proposes the next size up when memory or disk has stayed above 85% for half an hour.

## §13 Strict hosts, egress and acceptable use

- **Strict hosts** (your own isolated hosts, and shared hosts an admin switches) accept only reviewed Compose keys (on an isolated host the per-app budgets still only warn unless `APP_RESOURCE_LIMITS=enforce`), keep container names inside your app's namespace, allow no security option but no-new-privileges, send logs only to local drivers, and run every service with no-new-privileges. The dry run names anything refused.
- **Exemptions:** on an owner's own host, an administrator can grandfather one reviewed finding for one app (for example a port binding that predates the rules), so the host can enforce the rules without refusing that app. Each exemption names the rule and the field, is recorded with its reason, and can expire; a new finding is not covered. Shared hosts never exempt.
- **Read-only services:** list services that should run with a read-only root file system and a writable `/tmp` (`hardening`, or the Apps page). Keep data in named volumes.
- **Restricted egress** (isolated hosts): containers cannot send mail directly (ports 25, 465, 587), cannot reach the cloud metadata service, and open new outbound connections at a bounded rate. Send mail through an email API over HTTPS.
- **Terms:** where the platform requires it, accept the publisher terms once, on the Apps page while signed in, before creating apps, deploying live, or quoting or reserving an isolated host. `terms` shows them; an Apps token cannot accept them (`SESSION_REQUIRED`).
- **Abuse:** anyone can report an app at [pitchstation.ai/abuse.html](/abuse.html). An administrator can take an app down: it stops, every address answers 404, and its owner cannot change it until the takedown is lifted. Who published what, and when, is recorded.
