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.
§01Set up once
You need the app.deploy capability on your account (an administrator grants it) and an API token from /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:
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.
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)
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.
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:
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.
§02The 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.
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.
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.
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.
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.
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
/.envor your source to the internet. - Behind a proxy: trust
X-Forwarded-For/-Protoonly behind a flag, and mark cookiesSecure— 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
/.envand source paths return 404.
§03Create 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.
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.
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.
§04Deploy 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
Review the plan, the file list and any Compose warnings, then apply:
--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:
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
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.
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:
It answers 202 with { accepted: true, operation: { id, statusUrl } }. Poll the operation until its status is succeeded:
--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.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
backuporbackups, anywhere in the tree; - credential folders:
.aws,.ssh,.codex; and.DS_Storefiles; .envfiles (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,*.sqlite3and their-waland-shmfiles), with no warning; - Postgres or PGlite data folders (
PG_VERSION,pg_wal/) and a Redisdump.rdb, with a warning; - anything listed in a
.pitchstationignoreat 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.
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
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.
§05Addresses 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
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.
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.
Verification checks authoritative and independent resolvers without issuing certificates or touching the host. Apply issues a certificate per hostname and activates the route.
The plan prints its id, its etag and the TXT records. Add them, then verify and apply:
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.
The same flows are available by hand at /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.
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:
The plan prints its id and etag. Apply it, then run readiness:
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.
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
- 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-storeby default. Responses your app marksimmutable, or that lie under acachePathsprefix, 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
accountsends 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).
§06Environment 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.
That previews the change. Applying restarts the app:
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}.
§07Operate
| 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. 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).
§08Troubleshooting
| 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.
§09Before 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.
§10Backups 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 |
The preview shows the exact command the host will run. Save the settings, then back up whenever you like:
To restore, preview first: the dry run says what is replaced.
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.
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.
§11Moving 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.
The dry run shows the destination, the address change, how the data travels and the steps. Then start the move and follow it:
- 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…toshop.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 answerMOVE_UNSUPPORTEDunless 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.
§12Your 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.
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.
§13Strict 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.
termsshows them; an Apps token cannot accept them (SESSION_REQUIRED). - Abuse: anyone can report an app at pitchstation.ai/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.