---
name: create-pitchstation-app
description: Deploy, publish or host a project, repository or folder on PitchStation as a running app (web app, API, prototype, anything that runs in Docker), then run it — redeploy, private or public access, domains, expiry, env, status and logs — through the Apps client, MCP tools or REST API. Use for any wording like "deploy to PitchStation as an app", "deploy this to pitchstation", "publish this app on PitchStation", "host this on PitchStation", "put it online on PitchStation", "redeploy", "make this deployable to PitchStation", "install the PitchStation Apps skill" or "connect a domain to my app". Not for sharing a document, report or deck as a link (share-to-pitchstation, publish-to-pitchstation), static Website Creator publishing, or Devbox provisioning. Address/policy-only work must not trigger a content build or app restart.
---

# create-pitchstation-app

Make an application **deployable to PitchStation app hosting** from day one — or
retrofit one that wasn't. The platform runs any project with a
`docker-compose.yml` on an app host it manages (a shared host for teammates, an
isolated host of the user's own, or a host they registered): one POST uploads a
source tarball, the platform builds, migrates, health-checks, routes traffic
behind nginx+TLS, and retains release history for rollback. Managed publishing
separates content releases from domain/policy revisions. **Guide (read it once):
`/apps-docs/deploy.md`** · user manual (every task, every channel, and what to do
when something fails): `/apps-docs/manual.md` · API reference: `/apps-docs/api.md`
· OpenAPI: `/api/openapi-apps.json` · agent summary: `/llms.txt` ("APP HOSTING") ·
human pages: `/apps-docs/` (manual: `/apps-docs/manual.html`) and the product page `/apps`.

**Auth**: the user's `PITCHSTATION_APPS_TOKEN` (a `pst_…` token created with the
**Apps** preset at /tokens.html, whose user holds `app.deploy` — admins do; others need
an admin grant) lives in `~/.pitchstation/.env`; an older setup's shared
`PITCHSTATION_TOKEN` there works too. Run the client through the `~/.pitchstation/ps-apps`
wrapper from Setup: it loads that file inside the command only, so the token reaches the
client without being printed or exported to every shell, even where the agent's shell
hides it (Codex removes variables named `*TOKEN*`/`*KEY*`/`*SECRET*`). Never ask for the token in chat, and never
put it or any password on a command line. Server: `PITCHSTATION_URL` (default
`https://pitchstation.ai`).

## Setup (once per machine)

Needs Node 20+. Install the client and the wrapper, then check access:

```bash
mkdir -p ~/.pitchstation
curl -fsSL https://pitchstation.ai/downloads/pitchstation-apps.cjs -o ~/.pitchstation/pitchstation-apps.cjs
printf '%s\n' '#!/bin/sh' '# PitchStation Apps client; loads ~/.pitchstation/.env for this command only' \
  '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 targets
```

`targets` lists the hosts this user can deploy to: their own, plus shared hosts with
free slots and their shared-host quota. Summarize them in a line each. If it fails:

- `Set PITCHSTATION_APPS_TOKEN` (or a 401): no valid token. Do not ask for it in chat.
  Tell the user to create one with the **Apps** preset at
  https://pitchstation.ai/tokens.html and paste this into their own terminal. It reads
  the token without showing it and saves it owner-only in `~/.pitchstation/.env`, where
  only the `ps-apps` wrapper reads it:

  ```bash
  { 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 && echo 'Saved in ~/.pitchstation/.env'; unset T; }
  ```

  An older setup also added a line to `~/.zshrc` that exported this file into every
  terminal; the wrapper does not need it, and the user can delete it.
- `CAPABILITY_REQUIRED`: the user lacks `app.deploy`; an admin grants it (Admin → Users).
- `Transport failed`: the command had no network. In Codex, ask the user to approve
  network access for it.

## Quick path: deploy a project as an app

Any wording that asks for a project to run on PitchStation takes this path: "deploy
to PitchStation as an app", "deploy/publish/host this on PitchStation", "put it
online", "redeploy". The project is the one the user names, else the current
directory. A request to deploy a project authorizes creating its app and applying the
reviewed dry run. Take anything the dry run shows that the user did not ask for back
to them first. `ps-apps` below means `~/.pitchstation/ps-apps`.

1. **Setup.** Run Setup if `~/.pitchstation/ps-apps` is missing, or if `ps-apps help` shows a
   `clientVersion` older than `2026-10-02` (Setup downloads the current client; older ones lack
   `releases` and the Next.js scaffold); run `ps-apps targets` either way.
2. **One project, one app.** `ps-apps list`: reuse an app whose slug or name matches the
   project (go to 6). Never create a second app for the same project. A new slug is
   1–40 lowercase letters, digits and hyphens, starting and ending with a letter or
   digit, unique on PitchStation (409 "already taken" → choose another).
3. **Access.** Use what the user said; otherwise ask once, recommending private.
   - **Private** (a platform password in front of every request): create a password file
     without printing it — `( umask 077; openssl rand -base64 12 > ~/.pitchstation/SLUG.password )`.
     Browser visitors type only that password on the app's sign-in page; scripts and API
     clients use HTTP Basic with user name `preview`.
   - **Public**: add `--application-policy none` only when the app restricts nobody (an
     open demo). An app with its own login gets `--application-policy restricted`.
4. **Make it deployable** (The contract, Scaffold files below). A Vite + React project
   with an npm lockfile, or a Next.js project with `output: 'standalone'` (npm or pnpm),
   needs nothing: the client generates its runtime files. Otherwise
   add `Dockerfile`, `docker-compose.yml`, `.dockerignore` and an unauthenticated health
   endpoint. The shared host is ARM64: base images must have ARM64 variants, and nothing
   may download x86-only binaries. When Docker runs locally, smoke-test (below). Do not
   commit; list what you added.
5. **Create** (new app only). Write `~/.pitchstation/SLUG.app.json`. For a Vite + React
   or Next.js project the client scaffolds (no Compose file), use 3000 (the generated Vite
   image listens there; Next.js follows `containerPort`):
   `{"slug":"SLUG","name":"Name","webService":"web","containerPort":3000,"healthPath":"/"}`
   (if the home page redirects, point `healthPath` at a page that answers 200). With the
   project's own Compose file, `containerPort` is the port the web service listens on
   (the right side of its `ports` mapping; 8000 with the scaffold files below) and
   `healthPath` its unauthenticated health endpoint (`/api/health` there). Add
   `"migrateCmd"` if the app has one. Leave out `targetId`: the platform picks a host
   and says why (`"placement": {"region": "ap-southeast"}` or `{"arch": "amd64"}` steers it).
   Secrets go under `"env"` in a file written with `umask 077`: generate random values
   inside the command that writes the file, never print them, and ask the user to add
   third-party keys to that file themselves. Then
   `ps-apps create --file ~/.pitchstation/SLUG.app.json --private --password-file ~/.pitchstation/SLUG.password`
   (or `--public --application-policy none`). Keep `app.id`.
6. **Dry run.** `ps-apps deploy --app ID --source /absolute/project --marker "MARKER"`.
   MARKER is 12+ characters that the home page's HTML source always contains, usually
   its `<title>`; if nothing fits, add `<meta name="pitchstation-app" content="SLUG on
   PitchStation">` to the page. Vite projects get one automatically. Pass the same
   marker on every deploy. Check `source.files` for secrets the exclusions missed,
   `composePolicy.warnings` (empty), `configuration.domains.primary` (the address) and
   `configuration.policy.access`.
7. **Apply.** The same command plus `--apply --key SLUG-YYYYMMDD-NN --watch | tail -n 1`
   (the watch prints the operation every 2 seconds; the last line is where it ended). It
   gives up after 5 minutes while a long build continues: `ps-apps operation --app ID
   --operation OPERATION_ID --watch | tail -n 1`. After a transport error, repeat with the
   SAME key. A failed step: read its `output`, fix, deploy again with a new key. A
   failure at `health` leaves the new release's containers serving (they replaced the
   old ones): deploy the fix, or `ps-apps releases --app ID` and roll back to a named
   release (Operations below). A failure before `up` leaves the previous release serving.
8. **Verify and report.** `ps-apps readiness --app ID` (an app whose home page is its own
   login is checked in a browser instead). Report the address; the access (private: the
   password is in `~/.pitchstation/SLUG.password`, which the user reads in their own
   terminal; browsers ask only for it, HTTP Basic clients add user name `preview`); the
   app ID; and the files added to the project.

To redeploy: steps 1, 2, 6, 7 and 8 with a new key.

## Choose the operation before touching the project

- **Build/retrofit:** inspect the contract below. A request to prepare a project
  is not permission to deploy it, create cloud resources, or publish a document share.
- **Deploy content:** reuse the existing app ID when present. Package, inspect
  the dry-run plan, then apply only within the user's requested target/scope.
- **Change domains/access/expiry:** use `/apps.html`, domain/policy plans or
  the CLI below. Do not upload source, rebuild or restart an unchanged app.
- **Change env:** managed `PATCH env` preserves unrelated keys and restarts the
  current release; compatibility `PUT env` replaces all stored keys but does
  not apply them. Neither is a domain-only operation.

The MCP server has six Apps tools — `pitchstation_app_list` (own + shared hosts,
quota), `_status` (containers, usage vs budget, configuration), `_logs` (read-only,
redacted), `_deploy` (dry run by default; `marker` for readiness; optional
`password_file`), `_operation` (get/retry/cancel) and `_readiness`. It reads
`PITCHSTATION_MCP_TOKEN`, then `PITCHSTATION_TOKEN` — not `PITCHSTATION_APPS_TOKEN`.
Domains, access, env, budgets and rollback stay in the client or REST: they are
reviewed plans guarded by an ETag. Discover
`GET /api/apps/publishing-capabilities`, `/targets` (your hosts plus live SHARED
hosts, with free slots, arch, region and your shared-host quota) and the app's
`GET /api/apps/:id/domain-config` instead of assuming availability from this file.
Status in this release: target 5 (Singapore, `*.apps-ap.`, ARM64 — images and binaries
must support ARM64, else `exec format error`) is the shared host for teammates, with
Compose rules and budgets enforced; target 4 (Oregon, `*.apps.pitchstation.ai`, x86)
is the platform's own host (rules and budgets warn). New apps use the managed route
from their first deploy; six apps from before 2026-09-18 keep legacy routes until
their next domains/policy apply; custom-domain ownership proofs are implemented,
first production use pending. `HOST_NOT_APPROVED`, `LIVE_DISABLED` or missing
inventory is an admin handoff, not permission to switch flags, bypass proof or use
SSH. Shared hosts are for trusted teammates: rules and budgets reduce accidents,
they are not isolation. Since 2026-09-28 a user can reserve an isolated host of
their own (`host-catalog`, `hosts --file host.json`, dry run first, inside the
monthly cap shown by `spend`); an administrator launches it, and it runs strict,
user-namespace remapped and egress-restricted. A move to a host with another base
domain (another region, or an isolated host's `h<n>.` name) changes the platform
hostname (`shop.apps-ap…` → `shop.apps…`; the plan says so, custom domains stay).
Between two hosts under the same wildcard it needs per-app DNS records, which
are implemented and switch on once the app zones are delegated (`MOVE_UNSUPPORTED`
until then).

## The contract (what the platform expects)

Design the app around these five facts — every one is load-bearing:

1. **Ports.** The platform allocates a permanent `hostPort` and writes
   `PORT=<containerPort>` and `HOST_PORT=<hostPort>` into the release's
   `.env`. Publish only on host loopback:
   `"127.0.0.1:${HOST_PORT:-<containerPort>}:<containerPort>"`.
   The app binds `0.0.0.0` inside the container — **never 127.0.0.1** (the
   health poll and nginx proxy arrive over the container network).
2. **Data lives on a named volume.** The compose *project* is per-app
   (`ps-<slug>`) and stable across releases; release *directories* change.
   Named volumes therefore survive every deploy — put the database there
   (e.g. `DATABASE_URL=sqlite:////app/data/app.db`, volume mounted at
   `/app/data`). Anything written to the image filesystem is lost on the next
   release.
3. **Health endpoint.** The pipeline polls `healthPath` for **HTTP 200**
   (60s) before routing traffic. A `/` that 302-redirects FAILS the check —
   give the app an unauthenticated `GET /api/health` → `{"ok":true}` and set
   `healthPath` to it.
4. **Migrate step = your idempotent hook.** `migrateCmd` runs via
   `docker compose run --rm <webService> <cmd>` BEFORE the web container
   is recreated; non-zero exit aborts the deploy. Shared data may already
   have changed, so failure does not imply rollback or uninterrupted service.
   Use it for schema migrations AND first-boot seeding — but it runs on
   *every* release, so it must be **idempotent** (an "ensure-admin" that
   no-ops when an admin exists, never a "create-admin" that errors on rerun).
5. **Env is stored server-side, encrypted.** Register secrets once
   (`env` on app creation, or `PUT /api/apps/:id/env`); the API returns key
   *names* only. The app reads plain environment variables (compose
   `env_file: .env` — the platform writes that file). Never ship a `.env` in
   the tarball or bake one into the image.

## Scaffold files

`docker-compose.yml` (the platform contract; also works locally):

```yaml
services:
  web:                                   # ← name this in webService
    build: .
    env_file: .env
    ports:
      - "127.0.0.1:${HOST_PORT:-8000}:8000" # ← right side = containerPort
    volumes:
      - data:/app/data                   # ← durable state lives here
    restart: unless-stopped
volumes:
  data:
```

`Dockerfile` — non-root, with the **volume-ownership fix**:

```dockerfile
FROM python:3.12-slim                    # or node:22-slim, etc.
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# /app/data must EXIST in the image, owned by the app user: a named volume
# copies the image directory's ownership on first mount. Without this the
# mountpoint is created root-owned and a non-root app cannot write its DB
# ("unable to open database file" on the very first deploy).
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` (defense in depth — these also stay out of the tarball):

```
.env
*.db
*.db-shm
*.db-wal
.venv
node_modules
__pycache__
*.pyc
.git
```

## App-code checklist

- **Static files by allowlist only.** If the app serves its own directory,
  whitelist the exact files/dirs; never serve dotfiles, `*.py`/source, config,
  or data files. (`SimpleHTTPRequestHandler`-style catch-alls will serve
  `/.env` to the internet.)
- **Behind the proxy**: honor `X-Forwarded-For` / `X-Forwarded-Proto` only
  behind a trusted proxy flag (e.g. `TRUST_PROXY=1`); set cookies
  `Secure` in production (`COOKIE_SECURE=1`) — the platform terminates TLS
  and redirects HTTP→HTTPS.
- **Seeding creds via env**: first admin from `ADMIN_USERNAME` /
  `ADMIN_PASSWORD` env in the idempotent migrate hook — never hardcoded.
- WebSockets work through the vhost (Upgrade headers are proxied). Long
  requests: proxy read timeout is 300s.

## Local smoke test

Use a new, uniquely named fixture project/volume, synthetic credentials and
loopback-only published ports. Never delete a pre-existing volume to make
the test pass. Build; run the migrate/seed hook twice before starting the web
service; then verify health 200, secret/source paths 404, and the app's auth
and one real feature. Clean up only fixture resources created in this run.
If Docker is unavailable, report that the runtime smoke test was not run.

## Official client or REST

The client is one standalone file (Node 20+, no dependencies), installed by Setup
beside `pitchstation-mcp-server.js`, so `pitchstation_app_deploy` can package
directories. `ps-apps` runs it with the token loaded; without the wrapper it is
`node ~/.pitchstation/pitchstation-apps.cjs` with `PITCHSTATION_APPS_TOKEN` (or the shared
`PITCHSTATION_TOKEN`) in the environment, never a command-line token or password flag. Output is JSON; a failure is one JSON line on stderr that names the fields, detail, reason and path to fix. Treat the
following IDs and filenames as examples.

```bash
~/.pitchstation/ps-apps help
~/.pitchstation/ps-apps list
~/.pitchstation/ps-apps show --app 7
~/.pitchstation/ps-apps package --source /absolute/project
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/project
# Only when the user authorized this deployment; retain the key for retries:
~/.pitchstation/ps-apps deploy --app 7 --source /absolute/project --apply --key release-20260918-01 --watch
~/.pitchstation/ps-apps operation --app 7 --operation OPERATION_UUID --watch
~/.pitchstation/ps-apps releases --app 7
```

For a new app, list hosts with `GET /api/apps/targets` (client `targets`), then
`POST /api/apps` with slug, name, webService, containerPort, healthPath, optional
migrateCmd/env, and either `targetId` or `placement: {region?, arch?}` (the platform
chooses and explains; apps on shared hosts count against a quota of 3 by default).
Always decide access at creation — the client refuses to guess: `create --file
app.json --private --password-file FILE` (owner-only file, 12–72 bytes) or `create
--file app.json --public --application-policy none` (only if the app has no login of
its own). REST: `"publication": {"access": "private", "password": "…"}` or
`{"access": "public", "applicationPolicy": "none"}`. Optional
`"resources": {"memMb", "cpus", "pids"}` (default 512 MB, 1 CPU, 256 processes; a
shared host kills an app for memory inside its own budget). Typed refusals:
`QUOTA_EXCEEDED`, `HOST_FULL`, `RESOURCE_ADMISSION`, `PLACEMENT_UNAVAILABLE`
(`detail.rejected` says why for each host). Creating the app is a write, not a
preview; promote/provision hosts only if authorized.

**Compose rules** (refused on shared hosts, warnings on owner hosts; checked at the
dry run and again on the host before build): no `privileged`, host or other
containers' namespaces, `cap_add`, `devices`, unconfined `security_opt`, `runtime`,
bind mounts / env files / build contexts outside the project, the Docker socket,
external or foreign volumes and networks, volume driver options; publish only
`"127.0.0.1:${HOST_PORT}:<containerPort>"` on the web service. Guarded values must
be literal — only `${PORT}`/`${HOST_PORT}` may be interpolated there.
`COMPOSE_POLICY_VIOLATION` names each field (`services.web.privileged`).
On an owner's own host an admin can grandfather one reviewed finding for one app
(audited; it shows under `composePolicy.exempted`); never ask for one to get past a
finding you can fix, and shared hosts never exempt.
**Strict hosts** (every per-developer host; an admin may switch any host) accept
only an allowlist of Compose keys, and the platform adds no-new-privileges to every
service; the dry-run plan's `composeReport.hardening` says which mode applies.
`hardening --app 7 --file h.json` with `{"readOnlyRootfs": ["web"]}` runs named
services with a read-only root filesystem (dry run first; the app must write only to
volumes or tmpfs).
For Vite + React + npm lockfile projects, the packager generates runtime files
in the archive without rewriting the project; existing Compose is preserved.
Inspect the manifest: secret exclusions are not a complete secret scanner.
Server intake limits are **80 MiB compressed, 256 MiB expanded, 20,000 entries**;
links, traversal, devices and duplicate paths are rejected. Normal REST uploads
use `tarballBase64`; server-local `sourceDir` requires an approved `APP_SOURCE_ROOTS`.

### Domains and publication policy — no content deploy

1. Read the current configuration and ETag; preserve every existing host,
   alias mode, password and expiry unless the requested change includes them.
2. Plan with `PUT /api/apps/:id/domain-config` and
   `{ "domains": { "primary":"example.com", "aliases":[{"hostname":"www.example.com","mode":"serve"}], "platform":"retain" } }`,
   or `PATCH /api/apps/:id/publication-policy` with `{ "policy": { … } }`.
   Both require `If-Match`; dry-run defaults true. Planning can save a plan
   and reserve claims, but does not write target routes or provider DNS.
3. Copy the **per-host** `_pitchstation.<hostname>` TXT proof from the returned
   plan or configuration. Correct existing A/CNAME routing need not be replaced.
   A working URL/certificate is not ownership proof. Leave provider DNS to the
   user unless separately authorized. Verify with
   `POST /api/apps/:id/domain-config/verify {"planId":"PLAN_UUID"}`.
4. Apply the reviewed plan with `dryRun:false`, `planId`, its exact `If-Match`
   and a stable `Idempotency-Key`. Plans expire after 15 minutes. A stale plan
   needs a new review, not a forced overwrite. Poll the returned operation.

CLI equivalents:

```bash
~/.pitchstation/ps-apps domains plan --app 7 --file domains.json
~/.pitchstation/ps-apps domains verify --app 7 --plan PLAN_UUID
~/.pitchstation/ps-apps domains apply --app 7 --plan PLAN_UUID --etag '"app-7-v0"' --key domains-20260918-01
~/.pitchstation/ps-apps policy plan --app 7 --file policy.json
~/.pitchstation/ps-apps policy apply --app 7 --plan PLAN_UUID --etag '"app-7-v1"' --key policy-20260918-01
~/.pitchstation/ps-apps readiness --app 7
~/.pitchstation/ps-apps receipt --app 7
```

**Public apps need one declaration** — best at creation (`--application-policy none`),
otherwise after the first deploy. An app created without it records
`applicationPolicy: "unknown"` (the platform never assumes an app is open), and
readiness certifies a PUBLIC app only when the owner has declared `"none"` — the app
itself restricts nobody. Without it readiness fails with `ACCESS_POLICY_MISMATCH` and
`fields: ["applicationPolicy"]`. Declare it only when true (open demo, guest play),
with a policy plan/apply — no rebuild, no restart:

```bash
printf '{ "access": "public", "applicationPolicy": "none" }\n' > policy.json
~/.pitchstation/ps-apps policy plan  --app 8 --file policy.json
~/.pitchstation/ps-apps policy apply --app 8 --plan PLAN_UUID --etag '"app-8-v3"' --key policy-20260927-01
```

An app with its own login is `"restricted"`; readiness does not certify it — verify in a
browser. Private policy uses an owner-supplied, retained password of 12–72 UTF-8 bytes;
put it in an owner-only input file, not argv/logs/receipts. Omit `password` to
retain an existing gate; do not send an empty password. Explicit `access:public`
removes only the **platform** gate. Omit `expiresAt` to preserve it, use an
explicit timezone-qualified value to change it, or `null` only to remove it.
Document-share audience/password/30-day defaults do not automatically apply
to a hosted app. Its own login/expiry is a separate restriction.
`POST /preview` only returns a one-time credential; it does not activate a gate.
For a new app that must be private from its first deployment, create it with
`--private --password-file`, or deploy with `--password-file` (client),
`password_file` (MCP: absolute path to an owner-only file) or `previewPassword` in
the REST deploy body. Never put the password in argv, chat or logs; do not deploy
publicly first and promise to add the gate afterward.

### Operations, env and verification

- Managed deploy/rollback: `Prefer: respond-async` plus `Idempotency-Key`
  opts into HTTP 202. Acceptance is not success; poll the operation ID. A
  disconnected client does not cancel it. Retry identical intent with the same
  key, not a new release. Without `Prefer`, compatibility returns final 200/502.
- Env: `PATCH /api/apps/:id/env {set:{…},unset:[…]}` previews by default;
  apply needs `dryRun:false`, `If-Match` and `Idempotency-Key`, and restarts
  the current release. `PUT env` remains replace-all/stored-only. Reserved
  PORT/HOST_PORT/COMPOSE_*/DOCKER_* keys and multiline/NUL values are rejected.
- Rollback: `releases --app 7` lists release IDs (the live one marked), then
  `rollback --app 7 --release ID` previews and `--apply --key K` applies. Always pass
  `--release`: without it the platform picks the newest healthy release other than the
  current one, which after a failed deploy skips the last good release (roll back to
  the one `releases` marks live) and after a rollback returns to the release you left.
  It keeps managed hosts/policy, the last-applied env, the current budget, read-only
  services and deploy settings (no migration or hook runs), and brings back that
  release's Compose file and the images it names — but services built on the host keep their
  newest build, so code rolls back only when each release tags its built images
  differently (for example `image: shop-web:2026-10-02`). It does **not** undo
  database migrations. Container updates may affect traffic before route
  activation. `reconciliation_required` is never retried blindly: the operation's
  `reconcile.by` says who resolves it. `owner` (the uncertain step touched only the
  app's containers — migrate, up, health, activate): read the failed step's
  redacted `output`, check the database with the user, then `reconcile --app 7
  --operation ID` (inspect: pauses the app's queue of operations while the app keeps
  serving, and reads the host) and, after the user
  confirms, `reconcile … --inspection INSPECTION_ID --file attest.json --apply` with
  `{"migrationsReviewed": true, "note": "…"}`; then deploy or roll back again.
  `operator` (routing, TLS, quarantined host): an admin resolves it.
- Status and logs: `status --app 7` (containers, health, restarts, exit codes, OOM
  kills, usage vs budget) and `logs --app 7 [--service web] [--tail 200] [--since
  10m]` (redacted, recorded, 10 reads a minute). Budget: `resources --app 7 --file
  r.json` previews, `--apply --key K` applies (live on shared hosts).
- Access (private managed apps): browsers get a sign-in page and a 12-hour
  session; API clients keep HTTP Basic; the visitor's own `Authorization` header
  reaches the app. `links --app 7 --create --label "Dana" [--expires ISO]` makes a
  per-recipient link (it works once its operation succeeds; up to 50 active);
  `links --app 7 --revoke LINK_ID` ends it and every session it opened. A new
  password signs everyone out.
- Backups: `backup-config --app 7 --file backup.json` with `{"kind": "command",
  "service": "db", "cmd": "pg_dump -U app app", "restoreCmd": "psql -U app app"}`
  (stdout is the backup, stdin the restore) or `{"kind": "volumes", "volumes":
  ["data"]}`, plus `keep` (1–10), `schedule` (`none`/`daily`), `beforeDeploy`
  (default true); dry run first, `--apply` saves. Then 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, and a failed backup stops that deploy. `backups --app 7` lists,
  `--apply --key K` backs up now. `restore --app 7 --backup ID` previews; applying
  needs `--confirm <slug> --key K` and replaces the current data — back up first.
- Move: `move --app 7 --target 5` (or `--region ap-southeast`; `--stateless` for
  an app with no data) shows the plan — downtime, hostname change, custom-domain
  records to update — and `--apply` runs it: final backup, stop, rebuild (or load the
  release's image archive) and restore on the destination, route, keep the source 7
  days (when the address changed, the old one redirects to the new one meanwhile).
  Tell the user to point each custom domain at the destination as soon as the source
  stops: the destination routes the app only once every name resolves to it, and no
  address serves it until then. `moves --app 7` follows it
  (`--abort ID` before the switch, `--remove-source ID` frees the old copy sooner).
  Owner writes wait while it runs (`MOVE_IN_PROGRESS`).
- Build elsewhere (heavy framework builds): on a shared host a build runs inside
  the host's build ceiling and fails as `BUILD_LIMIT_EXCEEDED` when it runs out.
  Instead run `archive --source DIR --platform linux/arm64 --output
  ~/.pitchstation/SLUG-images-YYYYMMDD-NN.tar.gz` (Singapore; `linux/amd64` for Oregon)
  with the user's local Docker — every service with `build:` needs an `image: name:tag`.
  Keep the archive outside DIR (one inside is packed into the upload) and give each build
  a new name (the client never overwrites a file). Then `deploy --app 7 --source DIR
  --image-archive ~/.pitchstation/SLUG-images-YYYYMMDD-NN.tar.gz --apply --key K`: the
  archive is uploaded once and the host loads it instead of building. On a Devbox:
  `devbox.py app-deploy BOX --app 7 [--apply]`. MCP: `pitchstation_app_deploy` takes `image_archive` (absolute path).
- Deploy settings: `settings --app 7 --file settings.json` previews, `--apply`
  stores (used by the next deploy): `migrateCmd`, `migrateService`, `hooks`
  (`preDeploy` / `postDeploy`, up to five `{service, cmd, timeoutSec}` each;
  pre-deploy failures stop the deploy, post-deploy ones only warn) and
  `composeFile` (a Compose file in a subdirectory, e.g.
  `deploy/pitchstation/compose.yaml`). Commands run inside the container as words:
  `;`, `&&`, `|`, `>` and `$VAR` outside single quotes are refused — write
  `sh -c '…'` when a shell is needed.
- Build-time env: keys such as `NEXT_PUBLIC_*` and `VITE_*` are baked into the
  build, so changing one needs a redeploy; the env preview says `rebuildRequired`
  and the app shows `buildStale` until then. Declare other keys with
  `{"scopes": {"KEY": "build"}}` in the env file.
- Packaging: `.env.example`-style templates are kept, real `.env` files are not;
  `.pitchstationignore` (gitignore syntax) excludes more; local database folders
  are left out with a warning. Without a Compose file, Next.js with
  `output: 'standalone'` is scaffolded (npm or pnpm; `--app-dir apps/web` in a
  workspace; pass `--marker`).
- Private apps (publication policy): `publicPaths` lets signed callbacks (payment
  webhooks, OAuth redirects) skip the gate — expiry still applies and the app must
  verify signatures; `cachePaths` (or `immutable` responses) may be cached in the
  viewer's browser; `accounts: {users: [...], teams: [...]}` adds "Sign in with
  PitchStation" to the login page. `links --app 7` also shows each link's opens.
- Verify every primary/alias/platform hostname: TLS, anonymous denial or
  explicit public access, expiry, identity and required assets. HTTP readiness
  is not evidence of working video, language switching or 3D; run matching
  browser checks before claiming those. Report pending checks separately.
- Certificate renewal is automated on an onboarded target, but alert delivery
  is separate. `APP_ACME_EMAIL` does not enable expiry notices; Let’s Encrypt
  ended those emails in 2025. Do not enable monitoring/email flags implicitly.
- Stop is `POST /api/apps/:id/stop` (dry-run default; volumes kept). Deleting an
  app on the user's own host removes registry data without stopping the host or
  freeing live host claims, unless `?removeFromHost=1`; an app on a SHARED host
  (or with that flag) must be stopped first (`APP_RUNNING`), then its containers,
  routing and budget are removed from the host and its addresses released (data
  volumes kept). A move's retained copy must be removed first (`HOST_STATE_CONFLICT`).

### MCP-only hosts (no shell)

`pitchstation_app_list` → `pitchstation_app_deploy {app_id, source_dir, marker}` (dry
run: show the plan, the file list, `composePolicy` warnings, the budget and packaging
warnings) → after the user confirms,
`pitchstation_app_deploy {…, apply:true, idempotency_key}` → poll
`pitchstation_app_operation` until `succeeded` → `pitchstation_app_readiness`.
Retry an interrupted apply with the SAME idempotency_key. The deploy tool needs
`pitchstation-apps.cjs` in the MCP server's folder; its error says how to fetch it. After
a PitchStation update the user downloads it again: one older than 2026-10-02 packages
without the Next.js scaffold and the generated `env_file`.

## Failure modes seen in the field

| Symptom | Cause → fix |
|---|---|
| First deploy fails at `migrate`, "unable to open database file" | Volume mountpoint root-owned; `mkdir` + `chown` the volume dir in the Dockerfile **before** `USER` |
| Second deploy fails at `migrate` | Seeding command not idempotent — make it check-then-create |
| `health` step times out but app runs | App bound to 127.0.0.1 (bind 0.0.0.0), or `healthPath` returns 302/401 (use an unauthenticated 200 endpoint) |
| Deploy 413 / `SOURCE_INVALID` | Tarball over budget or an unsafe entry: the error's `reason` and `path` say which. Check for bundled originals, node_modules, `.git`; list what stays home in `.pitchstationignore` |
| `OWNERSHIP_UNVERIFIED` | Add the exact app-scoped TXT proof for each custom hostname, then verify; do not replace correct A/CNAME records |
| `TLS_VALIDATION_FAILED` / `TLS_RATE_LIMITED` | Hand the typed failure to the operator; check HTTP-01/CAA and issuer retry timing, without bypassing TLS or repeatedly issuing certificates |
| `CONFIG_REVIEW_REQUIRED` / `HOST_NOT_APPROVED` / `LIVE_DISABLED` | Reviewed inventory, an admin's host approval or the platform switch is missing; do not self-enable anything |
| `COMPOSE_POLICY_VIOLATION` | Remove the named Compose settings (see Compose rules); do not work around them with env interpolation |
| `COMPOSE_POLICY_VIOLATION` "Publish HOST_PORT → the container port (…:8000), not …:3000" for a Vite project the client scaffolds | The app was created with `containerPort` 8000 (an older template) and the generated Vite image listens on 3000. On a shared host, stop and delete the app, then create it again with 3000 (a Next.js scaffold follows `containerPort`) |
| `QUOTA_EXCEEDED` / `RESOURCE_ADMISSION` / `HOST_FULL` | Shared-host quota used, budget too big for the host, or no slot — delete an app, lower `resources`, or ask an admin |
| App restarts, `status` shows OOM | It hit its memory budget; raise `resources` or reduce memory use |
| `reconciliation_required` after a failed migration | Read the step `output`, check the database, then `reconcile` (inspect → confirm) and redeploy |
| Secrets visible at `/<something>` | Catch-all static serving — switch to an allowlist |
| Data gone after a release | State was written outside the named volume |
| 403 CAPABILITY_REQUIRED | Token's user lacks `app.deploy` — ask the server admin |
| Readiness `ACCESS_POLICY_MISMATCH` with `fields: ["applicationPolicy"]` | Public app not yet declared open: policy plan/apply `{ "access": "public", "applicationPolicy": "none" }` (only if true), then readiness again |
| nginx 500 on every path right after a managed apply | Host policy check could not read the app's policy (fixed 2026-09-27, `529a2c2`). If it recurs, report the app id to the operator; do not redeploy in a loop |
| `APP_TAKEN_DOWN` | An administrator took the app down after an abuse report; writes are refused until they lift it — the user contacts them, never redeploys under another name |
| `TERMS_REQUIRED` | Show the user `terms`; only they accept, on the Apps page (`/apps.html`) while signed in — an Apps token cannot (`SESSION_REQUIRED`); never accept on their behalf |
| `COST_CAP` (402) | A host would exceed the user's monthly cap; `spend` shows cap, committed and remaining — release a host or ask an admin to raise the cap |
| `BACKUP_NOT_CONFIGURED` / `BACKUP_FAILED` | Configure `backup-config` (or pass `--stateless` to a move of an app with no data); a failed backup's step output says why, and the running containers were not changed; deploy a fix before any env change, which would start the new build |
| `MOVE_UNSUPPORTED` | Two hosts under one wildcard need per-app DNS, or the data exceeds the transfer limit — hand to the operator |
| `pitchstation_app_deploy` says the Apps client is needed | Download `pitchstation-apps.cjs` into the MCP server's folder (or set `PITCHSTATION_APPS_CLIENT`) |
