PitchStation Apps Apps home Markdown for agents
PitchStation Apps · Deploy guide

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.

1 Set up once2 The app contract3 Create the app4 Deploy a release5 Addresses & access6 Environment7 Operate8 Troubleshooting9 Before you announce it10 Backups11 Moves12 Your own host13 Rules

§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:

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 clientmkdir -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 skillmkdir -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 registermkdir -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.

§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.

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

§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.

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.

§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

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 assistantDeploy /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 agentUse 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 shellset -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:

Applyprintf '{"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 operationprintf '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:

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.

§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

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.

Clientcat > 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-onlycat > 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: pick the app, edit domains or access, review the plan, apply, then check readiness and copy a redacted receipt.

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:

ValueMeaningReadiness for a public app
unknownNot 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"]
noneThe 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
restrictedThe 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:

Planprintf '{ "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"] } }

§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.

Clientcat > 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" } }

§07Operate

TaskClientMCPREST
See every app, and the hosts you can use (own and shared, with your quota)list · targetspitchstation_app_listGET /api/apps · /api/apps/targets
Configuration, hosts, DNS records, recent operationsshow --app 7pitchstation_app_statusGET /api/apps/7/domain-config
Follow an operationoperation --app 7
--operation ID --watch
pitchstation_app_operationGET /api/apps/7/operations/ID
Retry failed safe steps / cancelretry | cancel --app 7
--operation ID
…_operation action:retry|cancelPOST …/operations/ID/retry
| /cancel
List releases (ids for rollback)releases --app 7—GET /api/apps/7
Roll backrollback --app 7 [--release 24]
--apply --key K
—POST /api/apps/7/rollback
Prove it worksreadiness --app 7pitchstation_app_readinessPOST /api/apps/7/readiness
Redacted publication receiptreceipt --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 budgetstatus --app 7pitchstation_app_statusGET /api/apps/7/status
Logs (redacted, recorded)logs --app 7 --service web
--tail 200 --since 10m
pitchstation_app_logsGET …/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 yourselfreconcile --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, restorebackup-config · backups · restore—PATCH …/backup-config ·
POST …/backups ·
POST …/restore
Move to another hostmove --app 7 --target 5 [--apply] ·
moves --app 7
—POST /api/apps/7/move ·
GET …/moves
Your own isolated hosts and spendhost-catalog · hosts · spend—GET/POST /api/apps/hosts ·
GET /api/apps/spend
Read-only serviceshardening --app 7 --file h.json
[--apply]
—PATCH /api/apps/7/hardening
Deploy settings: migration service, hooks, Compose pathsettings --app 7
--file settings.json [--apply]
—PATCH /api/apps/7/settings
Image archives: build elsewhere, upload oncearchive --source DIR ·
images --app 7
[--file F | --remove ID]
…_deploy image_archiveGET/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

SymptomCause → fix
403 CAPABILITY_REQUIREDThe token's user lacks app.deploy. Ask an administrator.
BUILD_LIMIT_EXCEEDEDThe 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_INCOMPLETEThe 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_INVALIDA 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 applyThe 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_VIOLATIONA Compose setting the host does not allow; fields names each one. See the Compose rules in §04.
QUOTA_EXCEEDED · HOST_FULL · RESOURCE_ADMISSION · PLACEMENT_UNAVAILABLEYour 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 migrationRead 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 migrateSeeding is not idempotent. Check before you create.
health step times out, app runs locallyThe 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_INVALIDThe 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_FAILEDAn unexpected server error while reading the upload; nothing was accepted. Retry once, then report the correlationId.
OPERATION_BUSYAnother operation owns the app or its host. Wait for it to settle, then retry with the same key.
TARGET_UNREACHABLE / TARGET_FENCEDThe host could not be reached, or a newer publisher took the host lease. Nothing was changed; retry.
PRECONDITION_REQUIRED, CONFIG_VERSION_CONFLICT, PLAN_EXPIREDSend the current ETag in If-Match; if the configuration moved or 15 minutes passed, make a new plan.
OWNERSHIP_UNVERIFIED, DNS_TARGET_MISMATCH, DNS_PROPAGATINGAdd 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_LIMITEDAt 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 fieldsThe 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 applyThe 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 SingaporeAn 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 releaseState was written outside the named volume.
Secrets visible at some URLCatch-all static serving. Switch to an allowlist and rotate the exposed secrets.
BACKUP_FAILED during a deployThe 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_PROGRESSThe move cannot happen as asked (detail.reason), or one is already running: changes wait until it finishes (§11).
COST_CAPThe 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_REQUIREDAn 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

§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.

KindYou giveGood for
commanda 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 stdindatabases: a consistent dump while the app runs
volumesthe names of your Compose volumesfiles and uploads; restored with the app stopped
Write the settings, preview themprintf '{ "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.

§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.

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

§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.

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.

§13Strict hosts, egress and acceptable use

PitchStation Apps documentation · updated 2 October 2026 · Markdown version · Overview · User manual · API reference · Architecture · Apps home: pitchstation.ai/apps