# Percher > AI-native hosting for personal and small-scale apps: things people build for themselves, their family, a club or association, or a smaller business. Deploy in seconds via CLI or AI assistant. Live at name.percher.run with SSL, logs, rollback, and an opt-in managed PocketBase sidecar. Not positioned as production-grade hosting today: no uptime SLA, no formal compliance, single region, in Finland. > > This file is the full documentation corpus for agent consumption (llmstxt.org). For human-friendly browsing see https://percher.app/docs; for an orientation summary see /llms.txt. ## Pricing - Free (free) — 512MB RAM pool, 0.5 vCPU pool, 1GB disk pool, up to 2 apps, 50 live + 25 preview deploys/day, 5 rollbacks, sleep after idle - Starter (€5/mo or €50/yr incl. 25% Swedish VAT; €4 or €40 before VAT) — 1.5GB RAM pool, 1 vCPU pool, 5GB disk pool, up to 10 apps, 100 live + 50 preview deploys/day, 15 rollbacks, always-on - Maker (€15/mo or €150/yr incl. 25% Swedish VAT; €12 or €120 before VAT) — 4GB RAM pool, 2 vCPU pool, 25GB disk pool, up to 20 apps, 200 live + 100 preview deploys/day, 30 rollbacks, always-on - Max (€36.25/mo or €362.50/yr incl. 25% Swedish VAT; €29 or €290 before VAT) — 10GB RAM pool, 4 vCPU pool, 75GB disk pool, up to 40 apps, 1000 live + 500 preview deploys/day, 60 rollbacks, always-on ## Connecting an AI assistant (MCP) Run `bunx percher mcp` to print the MCP server config for Claude Code, Cursor, Windsurf, and VS Code. The local server exposes 54 tools; the hosted connector exposes 23. Both include `percher_publish` and `percher_wait_for_deploy`. The local server also includes `percher_doctor` and `percher_logs`; the hosted connector has neither, so use `percher_deploys_inspect` there. The local count applies to the source tree; npm release 1.0.1 has 53 tools and lacks the unreleased local `percher_sleep`. The recommended flow is async: `percher_publish` returns immediately with a queued deploy ID (MCP defaults waitForLive=false), then `percher_wait_for_deploy` short-polls until live or routes to `percher_doctor` (local) for recovery. ## Get started Run Percher by talking to your assistant. Percher puts your app on the internet and keeps it there: a real web address with HTTPS, and no server to set up or look after. An idle app sleeps and wakes on the next visit. Publish again and the live version updates in place; a recent version can be brought back. You do the rest by asking, in ordinary words. Most people never touch a terminal. Percher is built for the assistant you already use, as long as it can see your project or reach Percher: Claude, ChatGPT, Codex, Cursor, Claude Code, Gemini CLI or another MCP client. You do not need to understand the commands it runs. ## Say this to your assistant > Publish this project to Percher. Read https://percher.app/docs/quick-start first. If you cannot access the project files or run Percher tools, tell me how to connect you instead of guessing. Before the first deploy, confirm the public app name with me. Keep secrets out of source code and return the live URL when the deploy is healthy. Then just ask, in plain words: - **"Publish my latest changes"** — Percher updates the live version in place; the address stays the same. - **"Why did it stop working?"** — If a publish failed, Percher tells your assistant what went wrong and how to fix it. For an app that crashed after going live, the local MCP or the dashboard has the logs. - **"Go back to the version that worked"** — Percher puts a recent version's code back online; your data stays as it is. Anything else you can say in plain words, your assistant can usually do. ## Connect your assistant Three ways in, depending on where you use your assistant. ### Connector A connector lets an AI service reach Percher over the internet. Percher's side is hosted, so there is no Percher server to install: add one address in the assistant's settings and sign in once to authorize it. The connector publishes what the assistant writes or a repository you point it at; it cannot read files on your machine. The address is `https://mcp.percher.app/mcp`. - **Claude (web or desktop):** Customize → Connectors → Add custom connector → paste the address → a Percher sign-in opens; check that the browser's address bar shows percher.app before you approve. On a Team or Enterprise plan, an owner adds the connector under Organization settings first. - **ChatGPT:** turn on Developer mode under Settings, then add a connector (ChatGPT calls them plugins) with the address and approve the Percher sign-in. Whether custom connectors are allowed is up to your account or workspace. - **Codex:** `codex mcp add percher --url https://mcp.percher.app/mcp`, then `codex mcp login percher`. - **Gemini (web app):** Settings → Connected apps → Custom apps → Add a custom app → paste the address → approve the Percher sign-in. Personal Google accounts, 18 or over, in the US for now; the app then works in the Gemini mobile app too. Full detail, including the hosted tool list: https://percher.app/docs/hosted-mcp ### MCP server The MCP server runs on your own machine next to your editor, so the assistant can see your project files and publish them directly. One small config entry; you sign in the first time it publishes (the assistant hands you a browser link). ```json { "mcpServers": { "percher": { "command": "npx", "args": ["-y", "@percher/mcp"] } } } ``` - **Claude Code:** `claude mcp add percher -- npx -y @percher/mcp`, or add the config to `~/.claude.json` or to `.mcp.json` in your project. - **Claude Desktop:** Settings → Developer → MCP Servers → Edit Config, paste the config, restart Claude Desktop. - **Cursor:** Settings → MCP Servers → Add Server, paste the config. - **VS Code:** add the server to `.vscode/mcp.json` (VS Code lists servers under `servers`; same command and args), or use the MCP: Add Server command. - **Gemini CLI:** add the config to `~/.gemini/settings.json`, or run `gemini mcp add percher npx -y @percher/mcp`. - **Windsurf:** Settings → MCP, add the percher server config. Tool reference: https://percher.app/docs/mcp-tools ### Command line (CLI) The same tool without an assistant: run it yourself in a terminal, from the folder that holds your app. Logging in opens a browser. For CI, create a token on your account page and set it as `PERCHER_TOKEN`. ```sh cd path/to/your-app npx percher login npx percher publish ``` ## Nothing to publish yet? Percher hosts your app; an AI assistant can build it. Copy a starter, swap in your own details, and paste it into whichever AI you use. Each is written so the result publishes on Percher without extra setup. Ask for a static site (plain HTML or a framework that builds to files) or a simple Node or Bun app, and skip platform-specific features like edge functions; these starters already do. **Personal site / portfolio** — A single page about you and your work. The simplest thing to build and host. ``` Build a one-page personal portfolio as plain HTML, CSS, and JavaScript with no framework. Include a hero with my name and what I do, a projects grid, and a contact section. Keep it a static site I can deploy as-is, no build step. ``` **Link in bio** — A tidy page of links for your social profiles. ``` Build a single-page 'link in bio' site as static HTML and CSS. A round avatar, my name, a one-line bio, and a vertical list of tappable link buttons. Mobile-first, no build step. ``` **Blog** — Posts you write in Markdown, rendered as a clean readable site. ``` Create a blog as an Astro site with three example posts in Markdown, a home page that lists them, and a clean readable layout. Produce static output that's ready to deploy. ``` **Landing page for an idea** — One page to explain a product and collect interest. ``` Build a landing page for [describe your idea] as a static Vite + React site: a headline, three benefit cards, and an email signup form wired to a hosted form service so it works without a backend. Use either a Formspree endpoint (a plain POST form) or an embedded Tally form; I'll paste mine. Keep it a static build with no server. ``` **A small tool or web app** — Something interactive that remembers data between visits. ``` Build a small [todo / expense tracker / habit tracker] web app as a Node app using Express. Keep the data in Percher's managed PocketBase rather than in a file: put a [data] section with mode = "pocketbase" in percher.toml, and use the official pocketbase npm package. Percher injects POCKETBASE_URL, POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD, so on startup sign in as the superuser, create the collection you need if it does not exist yet, and read and write through the SDK after that. Include a package.json with a start script, and read the port from the PORT environment variable. ``` **A simple API** — A few JSON endpoints for another app to call. ``` Build a small JSON API in Node with Express: two or three endpoints plus a /health route. Read the port from the PORT environment variable and include a package.json with a start script. ``` ## What Percher does with what the AI built You don't need to know which runtime your app is; Percher detects it from the files. These cover roughly what AI assistants generate: - **A React + Vite single-page app** — the most common Claude artifact shape. Served as a static build: a Bun stage compiles the bundle, a slim Caddy image serves it, with SPA routing and SSL baked in. - **A Next.js app** — detected from next.config.js and run on the Node runtime. Server Components, Server Actions, and ISR work unchanged. Edge Functions become regular API routes. - **An Express, Hono, or Fastify API** — detected from package.json and run as a long-running Node server. Add a /health route that returns 200 and the deploy health check passes. - **A Bun server** — a Bun.serve handler ships on the Bun runtime: fast cold builds, native HTTPS proxy, no Node compatibility shims. - **A Python FastAPI, Flask, or Django app** — built with Percher's own multi-stage Dockerfile, no Dockerfile of yours needed. Set [web].command to start the server, and ship requirements.txt or a supported lockfile with its project file. - **Static HTML or a landing page** — no build step, just files served by Caddy in a few-MB container. Instant cold start, automatic SSL. If the guess is wrong, the `runtime` field in `percher.toml` is a one-line override. ## Is Percher right for this? Percher is for personal and small-scale apps: hobby sites, side projects, internal tools, an app for your family, a club or association, or a smaller business, the small thing you want online today. It runs in one region with no uptime SLA and no compliance certifications, so it isn't the right platform for a paid SaaS or anything a customer depends on. ## Common questions **Do I need to know how to code?** No. You describe what you want in plain language and the AI writes the code. If your assistant is connected to Percher, it publishes for you too; otherwise you copy the files into a folder and run one publish command. If a step is unclear, paste the error back to your assistant and ask it to fix it. **Which AI should I use?** The one you already have. If you use Claude, ChatGPT, Codex, Cursor, Claude Code or Gemini CLI, ask it to build the app and publish it on Percher; once it is connected (see above), it does both. You only need a different tool if yours cannot reach Percher at all. **Can I move an app I built in Lovable, v0 or Bolt?** Yes. Download the project from the builder, put it in a folder, and ask your assistant to publish it, or run the CLI from that folder. A Lovable app that uses Supabase keeps working with its Supabase backend. **Can I publish straight from a claude.ai artifact?** Yes, once Percher is connected to Claude.ai as a connector: say "publish this" and Claude hands the files it wrote to Percher directly — no download, no terminal (inline source is capped at about 25 MB). Without the connector, copy the code into a local folder (or download the zip if Claude offers one) and run `bunx percher publish` from there. **What about Claude Code, Cursor, or another coding assistant?** Those edit files on your machine, so they use the local route: `bunx percher mcp` prints the config to paste into the assistant's settings, and it gets `percher_publish`, `percher_logs`, `percher_doctor`, and the rest as tools. You ask it to ship the project, the assistant follows the build, and the live URL comes back as a message. **Why do the prompts say 'static site' or 'read PORT'?** Those constraints make the result publish cleanly on Percher. A static site (plain HTML or a framework that builds to files) needs no server. A Node or Bun app needs to listen on the port Percher gives it via the PORT environment variable and have a start script. Ask for those and the first publish usually just works. **What if the AI used an Edge Function or a host-specific API?** Ask it to convert that to the standard-Node equivalent. Edge functions (`runtime: 'edge'` in Next.js) become regular API routes. Vercel KV, Postgres, and Blob storage have no direct equivalent here: opt into the managed PocketBase sidecar with `[data] mode = "pocketbase"` for SQLite + sign-in + file storage, or point `DATABASE_URL` at any external Postgres. **What's the CLAUDE.md file, and do I need it?** Optional, but it helps. `bunx percher ai-files install` writes `CLAUDE.md` and `.cursor/rules/percher.mdc` into your project. They teach the assistant exactly how to write a working `percher.toml`, add a health endpoint, set env vars, and recover if a deploy fails — so the deploy instructions are baked into every prompt. Run `bunx percher ai-files update` later to refresh them. **What can't I host here?** Percher is for small, self-contained sites and apps. It runs in one region, has no uptime SLA, and no compliance certifications. It isn't the right platform for a paid SaaS or anything a customer depends on. For a hobby site, a side project, an app for your family or a club, or a tool for a smaller business, it's a good fit. **What licence applies to the tools and the files they generate?** Percher's CLI, local MCP server and JavaScript libraries use FSL-1.1-MIT from version 1.0.0. Older versions keep MIT. Percher-authored app starters and AI instruction files use the permissive 0BSD licence, and your existing files keep their existing licences. ## Recommended configurations by app type Starting points for `[resources]` and `[data]` in percher.toml, by app type. These are recommended sizing (your app's per-container cap) — your plan pool is charged on measured usage, not the cap, so pick what fits the app and adjust after deploying based on real usage. 256mb is the platform minimum. - **Static site / landing page**: memory 256mb, cpu 0.25. runtime "node" or "static", no `[data]`. (Astro static, Vite, 11ty, Hugo.) - **API service / webhook handler**: memory 256mb, cpu 0.5. Go/Rust fit 256mb; Python 256–512mb. Add `[data] mode = "pocketbase"` for storage. (Express, Fastify, Hono, Flask, FastAPI.) - **Fullstack SSR web app**: memory 512mb, cpu 0.5 (1gb for heavy image/SSR work). Next.js, Remix, SvelteKit, Nuxt, Astro SSR. - **Fullstack app with database**: memory 512mb, cpu 0.5, `[data] mode = "pocketbase"`. PocketBase gives SQLite + REST + auth + file storage + realtime; the app receives POCKETBASE_URL / POCKETBASE_PUBLIC_URL and an auto-created superuser; admin UI at pb-.percher.run/_/; the sidecar runs in its own 128mb container. - **Real-time app (WebSocket/SSE)**: memory 512mb (≈1mb per active connection; 1gb for 500+), cpu 0.5. Works through the Caddy proxy. - **Background worker / queue processor**: memory 256mb, cpu 0.5. Must still expose an HTTP `/health` endpoint on port 3000 so Percher can verify the container. - **Python data app / ML inference**: memory 1gb, cpu 1.0. numpy/pandas need ≥512mb. Large LLM/image models exceed Percher's limits (use a dedicated GPU service). Health-check timeout defaults to 30 s; raise it with `[checks.health] timeout` (max 300) for model loads. Set `[web].command`: Python builds refuse an app without it. - **Go or Rust microservice**: memory 256mb, cpu 0.25. runtime "docker" via a multi-stage Dockerfile. - **Monorepo / multi-package app**: memory 512mb, cpu 0.5. Built from the project root; the start script must build and start the right package. - **PHP application (Laravel/Symfony)**: memory 256mb (512mb with queues), cpu 0.5. runtime "docker"; set APP_KEY via env. Full reference: https://percher.app/docs/recommended-setup ## CLI reference The CLI and local MCP packages use FSL-1.1-MIT from 1.0.0; older versions retain MIT, and generated app/AI files use 0BSD. See [Package versions and licences](/docs/licensing) for upgrades and npm deprecation notices. Run the CLI with `bunx percher ` (Bun) or `npx percher ` (Node). If you are new to Percher, start with `publish`; it performs pre-flight checks, waits for the result, and explains common failures. Add `--help` to a command for its exact current options. Arguments in `` are required. Arguments in `[square brackets]` are optional. Most commands infer the app from `percher.toml`; use `--app ` where offered to override it. ## Start here These commands cover the normal path from a local project to a tested live app. - `percher login`: Sign in through the browser. Use --force to switch accounts. - `percher whoami`: Show the signed-in account and token status. - `percher init`: Detect the project and create percher.toml. --force replaces an existing file. - `percher publish`: Recommended deploy command: inspect, package, upload, wait, and diagnose failures. - `percher publish --dry-run`: Validate and package without uploading anything. - `percher publish --preview`: Create a temporary preview without replacing the live version. - `percher publish -m "note"`: Attach a note to deploy history. --no-cache forces a fresh build. - `percher wait-for-deploy `: Wait for a queued deploy; --timeout sets the maximum seconds. - `percher doctor [app]`: Diagnose configuration, build, health, and runtime failures; --json is agent-friendly. - `percher open [app]`: Open the live app URL. ## Apps and deploys Commands that change or inspect an app. Delete and rollback require deliberate confirmation or an explicit target. - `percher create [name] --template `: Create a starter app from one of 30 templates; --list-templates prints them. - `percher push`: Low-level package and deploy. Prefer publish unless you need the lower-level flow. - `percher redeploy [deployId]`: Deploy existing uploaded source again; --no-cache rebuilds it from scratch. - `percher deploys [app]`: List recent deploy IDs, states, timestamps, and errors. - `percher deploys inspect [id]`: Show status and build log. --latest-failed resolves the latest failed deploy. - `percher logs [app]`: Stream runtime logs; use --tail N, --no-follow, or --build [deployId]. - `percher versions [app]`: List deploy history and source commit SHAs. - `percher diff [base] [head]`: Compare two deploys. --patch includes unified file diffs for the owner. - `percher rollback [sha] [app]`: Restore a version from its kept image. Without a SHA, choose among five recent versions; --last-good selects the latest healthy one; --rebuild builds a version whose image is no longer kept, with today's dependencies. - `percher reproduce `: Download a deploy source bundle and reproduce its build locally. - `percher preview `: list, promote , discard , branch set , branch unset, or branch show. - `percher rename [app]`: Rename an app and change its percher.run subdomain. - `percher resume [app]`: Resume an app suspended for inactivity. - `percher sleep [app]`: Put an app to sleep; it wakes on the next visit and frees its RAM back into your plan's allowance. Requires CLI 1.0.2 or later; with 1.0.1, use the Put to sleep card on the app's Settings tab. - `percher claim`: Attach an anonymously published app to the signed-in account. - `percher inspect-link [app]`: Create a public read-only diagnostic link; --revoke disables it. - `percher cache reset [app]`: Clear the build cache so the next deploy runs cold. - `percher dashboard [tab]`: Open the app dashboard, optionally at a named tab. - `percher dev [app]`: Watch local files and deploy changes; --confirm asks before every push. - `percher appstore-kit`: Generate PWA, TWA/Capacitor, and asset-link starter files. - `percher delete [app]`: Permanently delete an app. --yes skips the interactive confirmation. ## Configuration, data, and domains Secret values belong in Percher environment variables, never in percher.toml or committed files. - `percher env set KEY=VALUE`: Set a secret or setting. Also supports --from-stdin, --from-file, --from-env-file, and --scope live|preview|all. - `percher env list`: List configured keys with values masked. - `percher env unset KEY`: Remove a key, optionally from one scope. - `percher generate`: Generate .env.example and TypeScript env types from percher.toml. - `percher data [app]`: Show PocketBase/capsule data status and collections; --json returns structured output. - `percher data oauth `: Configure capsule OAuth, including provider client ID and secret. - `percher data reset-superuser [app]`: Rotate PocketBase admin credentials; --reveal prints the new password once. - `percher data export [app]`: Download PocketBase data as .tar.gz; --out chooses the destination. - `percher backups [app]`: List restorable backup IDs and dates. - `percher restore [app]`: Restore app data; use --dry-run first and --components to limit scope. - `percher export [app]`: Download a portable app bundle without secrets; --git adds repository metadata. - `percher domains [domain]`: add, verify, list, or remove a custom domain. - `percher analytics [app]`: Show cookie-free visitor analytics when the app owner has Starter, Maker or Max, including for collaborators; --days accepts 1–90. - `percher import`: Convert supported Vercel or Netlify configuration to percher.toml. - `percher migrate-from-supabase`: Create a PocketBase schema/data migration and optionally rewrite SDK calls. ## Automation and access URLs, tokens, and signing secrets shown once should be stored in a secret manager. - `percher mcp`: Print paste-ready MCP configuration for supported AI clients; --json returns only JSON. - `percher ai-files `: install, update, status, or remove managed CLAUDE.md and Cursor instructions. - `percher github connect `: Connect, deploy, and enable auto-deploy for a full HTTPS GitHub repository URL. - `percher github setup-webhook`: Regenerate webhook setup after an incomplete GitHub connection. - `percher hook `: create , list, or revoke deploy hooks. A new hook URL is shown once. - `percher token `: create , list, or revoke ; create supports --scope, --app, and --ttl-days. - `percher status`: Show public API and worker health; --json returns structured output. ## Collaboration, ownership, and account Agents should inventory impact and ask for confirmation before revoking access, transferring ownership, or deleting data. - `percher share `: list, invite , role , or revoke . - `percher invites `: list, accept , or decline incoming app invitations. - `percher transfer `: Start an ownership transfer; --list shows history and --cancel cancels pending work. - `percher transfers `: list, accept , or decline incoming ownership transfers. - `percher account export`: Download GDPR account data; --include-app-data can include app snapshots. - `percher account preview-delete`: Read-only list of every blocker and cleanup action before account deletion. - `percher account delete`: Request permanent account erasure after all blockers are cleared. The operator-only `admin` command is intentionally excluded from this public user reference. Full reference: https://percher.app/docs/cli-reference ## Package versions and licences The CLI and local MCP server are distributed through npm alongside four JavaScript libraries. All six established a shared 1.0.0 baseline on 13 September 2026. The September 13 npm release contains CLI, local MCP, core and client **1.0.1**, with shared and toml **1.0.0**. Version 1.0.2 adds the CLI/local MCP sleep command and updated runtime templates. The 1.0.1 patch clarifies shared live data during preview/check execution. Discarding a preview does not undo writes to PocketBase or persistent files. See the [release announcement](/changelog/2026-09-13-npm-1-release) for the update. ## Which licence applies? | Package | Role | From version 1.0.0 | | --- | --- | --- | | `percher` | CLI | FSL-1.1-MIT | | `@percher/mcp` | Local MCP server | FSL-1.1-MIT | | `@percher/core` | Command and scaffold APIs | FSL-1.1-MIT | | `@percher/client` | Platform API client | FSL-1.1-MIT | | `@percher/shared` | Shared constants and utilities | FSL-1.1-MIT | | `@percher/toml` | Configuration parser and validation | FSL-1.1-MIT | **Versions below 1.0.0 retain their original MIT licence. Versions from 1.0.0 use FSL-1.1-MIT.** The version number identifies the boundary across all six packages. FSL is a source-available licence with use restrictions. Each version also receives an MIT grant two years after it is first made available under FSL. Read the `LICENSE` included with your exact package version for the full permitted-purpose terms and future grant; the npm licence label alone does not establish when that future grant takes effect. ## Your project and generated files Percher-authored files produced by `percher create --template` and `percher ai-files install` use the permissive **BSD Zero Clause licence (0BSD)**. Starter projects carry `PERCHER-TEMPLATE-LICENSE.txt`; generated AI instructions include their notice. These are separate grants from the package's FSL licence. Your existing project files keep their existing licences. Third-party dependencies and bundled components retain their own licences; CLI and MCP archives include their complete `THIRD-PARTY-NOTICES.txt`. The public package grants do not cover Percher's proprietary hosted platform or private logo artwork. ## Upgrade a CLI or local MCP installation Run the current 1.x CLI with `npx --yes percher@1 --version` or `bunx percher@1 --version`. For a global npm installation, run `npm install -g percher@1`, then `percher --version`. For the local MCP server, change the package argument in your existing client configuration to `@percher/mcp@1` and restart that server. For example: ```json { "mcpServers": { "percher": { "command": "npx", "args": ["-y", "@percher/mcp@1"] } } } ``` A hosted connector uses `https://mcp.percher.app/mcp` and requires no local npm installation. Keep an existing working connection; the npm release alone is not a reason to disconnect it. See [Hosted MCP](/docs/hosted-mcp) for that separate setup. If you import the libraries directly, select the 1.x packages you use and update your lockfile. For example, `npm install @percher/core@1` resolves its declared compatible client, shared and toml dependencies. Packages can receive independent patch versions after the common 1.0.0 baseline; they do not have to share every later patch number. Read the [1.0 release announcement](/changelog/2026-09-13-npm-1-release) or visit the [CLI package on npm](https://www.npmjs.com/package/percher). ## GitHub & CI/CD Three ways to deploy from GitHub. **Option A, Dashboard + GitHub App (public + private repos, recommended).** Install the Percher GitHub App from the GitHub section at percher.app/account#github and pick which repos to grant. Then click Deploy on a repo; auto-deploy on push is active immediately, no webhook config. Every open PR also gets its own preview at `{app}-pr-{n}.percher.run` — Percher posts a sticky PR comment with the preview URL (updated on every push, edited to "torn down" when the PR closes) plus a check-run. AI agents can connect after the one-time install with `percher github connect https://github.com/owner/repo --app myapp` (a full HTTPS URL — the `owner/repo` shorthand is rejected). (The GitHub section only appears once the App is configured on the instance — see the operator runbook if it's missing.) **Option B, Server-side webhook (public repos only).** Percher clones the repo and deploys on every push to the tracked branch, with no Actions runners or billing minutes. If the app doesn't exist yet, `github connect` creates it from the repo. Then: ```sh bunx percher github connect https://github.com/owner/repo --branch main ``` It clones, queues a deploy, and prints a webhook secret. Add a GitHub webhook: Payload URL `https://api.percher.run/webhooks/github`, content type `application/json`, the printed secret (shown once), just the push event. Percher verifies the HMAC-SHA256 signature and ignores other branches. Recover a failed setup with `bunx percher github setup-webhook --app `. **Option C, GitHub Actions with PERCHER_TOKEN (public + private).** Create a token at percher.app/account → API token, add it as the Actions secret `PERCHER_TOKEN`, then run `bunx percher publish` in the workflow (use `--preview` on pull_request for preview deploys). The CLI auto-detects the branch from GITHUB_HEAD_REF / GITHUB_REF_NAME (also GitLab, Vercel, Netlify env vars) for the deploy note. Token auth works in any shell: `PERCHER_TOKEN= bunx percher publish` or save it with `bunx percher login --token `. **Scoped tokens (recommended for CI).** A default token has full account access — more than a pipeline needs. Mint a least-privilege one instead: ```sh bunx percher token create ci-deploy --scope deploy --app my-app --ttl-days 90 ``` Scopes: `deploy` (the publish loop: publish, redeploy, rollback, logs, diagnostics — no env, domains, data, or account routes), `env` (env vars only), `read` (safe GETs only), `full`. `--app` binds the token to one app; `--ttl-days` gives it an expiry. Rotate by creating the replacement first, swapping the CI secret, then `percher token revoke ` (list ids with `percher token list`). The secret is shown once at create time. Full guide: https://percher.app/docs/github-cicd ## Environment variables Env vars are encrypted at rest (AES-256-GCM) and injected at container startup. Changes take effect on the next deploy. ```sh bunx percher env set STRIPE_KEY=sk_live_... bunx percher env set --from-env-file .env # every KEY=VALUE line at once bunx percher env list # values are masked bunx percher env unset STRIPE_KEY ``` **Bulk import.** `env set --from-env-file .env` sets every KEY=VALUE line from a .env-format file in one call (comments, quotes, and `export` prefixes handled; the file is only read when you pass the flag — never automatically). The dashboard env editor has the same thing: "Paste a .env file" shows a preview with conflict badges before anything is saved. **Deploy scopes.** Every var has a scope: unscoped (default) applies everywhere; `--scope preview` applies only to preview deploys (all four preview kinds) and overrides the unscoped value there; `--scope live` does the reverse. So `percher env set DATABASE_URL=postgres://staging... --scope preview` keeps every preview away from the production database while live keeps the unscoped value. `env list` annotates scoped rows; unset is scope-specific (`env unset KEY --scope preview` leaves the live value alone). The env-requirement gate is scope-aware too — a preview-only key never satisfies a live deploy's requirements. Three keys can't be scoped: `SITE_PASSWORD` (the login gate reads one password per app, live and previews alike) and the PocketBase admin credentials (platform-managed). **Build-time exposure.** Keys matching a public prefix (`NEXT_PUBLIC_*`, `VITE_*`, `NUXT_PUBLIC_*`, `PUBLIC_*`, `REACT_APP_*`, `VUE_APP_*`, `EXPO_PUBLIC_*`) are forwarded to the build automatically and baked into the client bundle — the dashboard shows a "build + runtime" chip on those keys. Everything else is runtime-only (correct for secrets), which breaks frameworks that expect a non-prefix value at build time; opt those in per key via `[build] pass_env` in percher.toml. Values always come from the env store, not the TOML. For `runtime = "docker"` the forwarded keys arrive as `--build-arg` (declare a matching `ARG` in your Dockerfile). **Outbound HTTPS.** Apps run on an internal Docker network and reach the public internet through a forward proxy Percher injects automatically: `HTTP_PROXY`/`HTTPS_PROXY=http://egress-proxy:8888`, `NO_PROXY=localhost,127.0.0.1,.local`, `NODE_USE_ENV_PROXY=1`. Bun and Node 22.21+/24+ use it automatically; Python (requests/httpx/urllib) and Go honor `HTTPS_PROXY` natively; Node 20 and the 21/23 lines need `setGlobalDispatcher(new ProxyAgent(process.env.HTTPS_PROXY))` from undici at startup. Direct outbound is blocked by design, so `ENETUNREACH` on `1.1.1.1:443` means the runtime isn't using the proxy. There is no host allowlist, but proxied HTTPS is port 443 only — an `https://host:8443` URL gets a 403 from the proxy. Full guide: https://percher.app/docs/env-vars ## Password protection Password-protect your site so only people with the password can open it. Visitors see a branded login page (not a browser popup) before they reach your app. **Every app is public by default** — its URL is openly reachable. Protection is opt-in and takes two steps that work together, then applies on your next deploy: ```sh # 1. Turn on the gate — add this to your [web] block in percher.toml [web] password = true # 2. Set the actual password (stored encrypted; never put it in percher.toml) bunx percher env set SITE_PASSWORD=your-secret-here # 3. Deploy — protection is applied during the deploy bunx percher publish ``` Both pieces are required: `password = true` is the switch, `SITE_PASSWORD` is the secret. Setting one without the other does not protect the site. - Verify: open your app's URL — you should see the login page before the app. - Turn it off: set `password = false` (or remove the line) and deploy again. - Rotate: change `SITE_PASSWORD` (`bunx percher env set SITE_PASSWORD=...`) — it takes effect without a redeploy, and existing login cookies stop working within about 30 seconds. Available on all plans at no extra cost. A correct password sets a 7-day cookie, so visitors aren't asked again until it expires. **Explicit form — `[web] visibility`.** `password = true` is the legacy alias for `visibility = "password"`; you can write either. The full field is: ```toml [web] visibility = "public" | "password" | "private" ``` `"public"` is the default (open to anyone). `"password"` is the SITE_PASSWORD gate above. `"private"` — a Percher-login gate where only the owner and the people you invite can open the app, each signed in with their own Percher account (no shared secret). Don't combine `visibility = "public"` with `password = true` — that's a contradiction and is rejected. With `visibility = "password"`, set `SITE_PASSWORD` before you deploy. Only `password = true` blocks the deploy when the secret is missing; the explicit form does not, and the app then shows a login page nobody can pass. Full guide: https://percher.app/docs/password-protection ## percher.toml — full reference Validation is strict: unknown keys in any section fail the parse instead of being silently dropped. Run `percher doctor` to surface validation issues before you publish. ```toml [app] name = "my-app" # 3-40 chars, lowercase a-z 0-9 and hyphens runtime = "node" # node | bun | python | static | docker framework = "nextjs" # optional: nextjs, sveltekit, astro, remix, nuxt, # vite, preact, solid, express, fastify, hono, # elysia, fastapi, flask, django, eleventy, hugo, # laravel, rails, phoenix, go, rust, dotnet, java, # docker [build] command = "bun run build" # optional output = ".next" # optional pass_env = ["NEXT_PUBLIC_API_URL"] # opt-in build-time env; values come # from "percher env set", not the TOML [web] port = 3000 # 1024-65535 health = "/health" # default "/" command = "npm start" # optional container start command visibility = "password" # "public" (default) | "password" | "private" # "private" = Percher-login gate (owner + invitees) password = true # legacy alias for visibility = "password" spa = false # static apps: deep links fall back to index.html rate_limit = { rps = 10, burst = 50 } # optional per-app rate limit, keyed by client IP # (default: unlimited). rps 1-100, burst 1-1000, # rps <= burst <= rps*10. Over the ceiling -> 429. # Needs the platform's rate-limit module (operator). [resources] # recommended sizing, not a pool charge — see note below memory = "512mb" # 256mb | 512mb | 1gb | 2gb cpu = 0.5 # 0.25 - 2.0 instances = 2 # static N containers; plan-gated # (free=1, starter=1, maker=2, max=4) [resources.autoscale] # alternative to instances; CPU-based min = 1 max = 4 [data] mode = "pocketbase" # pocketbase | postgres | sqlite | convex | # supabase | external | none [domain] # parsed but not applied today: subdomain = "my-app" # the app name sets name.percher.run custom = "myapp.com" # use "percher domains add" for a custom domain [env] STRIPE_KEY = "sk_live_..." # legacy KEY=VALUE shape (still supported); or use # required/optional/ignore arrays (the env contract) [crons] cleanup = { schedule = "0 3 * * *", command = "node cleanup.js" } [[redirects]] # up to 32, served at the edge before your app from = "/old-path" # exact path, or a prefix ending in "/*" to = "/new-path" # relative path or absolute https:// URL status = 308 # 301 | 302 | 307 | 308 (default 308) [headers."/*"] # response headers per path (16 paths x 16 headers) X-Custom-Header = "value" # platform security headers can't be overridden [dev] ignore = ["*.log", "tmp/"] debounce = 300 # ms before rebuild (100-10000) auto_open = true # [addons.*] is reserved and has no effect today: use [data] mode and [crons] [checks.health] # the only checks name Percher reads path = "/health" # required by the parser, not used (see [web] health) timeout = 90 # seconds to wait for healthy (default 30, max 300) initial_delay = 20 # seconds before failed probes count (default 5, max 120) ``` ## Complete field index - `app.name` — required; 3–40 chars: Lowercase public app name; starts with a letter and uses a–z, 0–9, or hyphens. - `app.runtime` — node | bun | python | static | docker: Required runtime/build path. - `app.framework` — nextjs | sveltekit | astro | remix | nuxt | vite | preact | solid | express | fastify | hono | elysia | fastapi | flask | django | eleventy | hugo | laravel | rails | phoenix | go | rust | dotnet | java | docker: Optional detection override; normally leave it out. - `build.command` — string: Optional custom build command; not allowed with runtime = static. - `build.output` — relative path: Optional directory containing build output. - `build.pass_env` — up to 50 key names: Encrypted runtime env keys also exposed during build; values never belong in TOML. - `web.port` — 1024–65535: Container port. Required when a [web] table is present; otherwise the platform resolves 3000. - `web.health` — path string: Health-check path; defaults to /. - `web.command` — 1–1000 chars: Container start command, especially for Python; bind to 0.0.0.0. - `web.visibility` — public | password | private: URL access policy. Public is the default. - `web.password` — boolean: Legacy alias: true means visibility = password. - `web.spa` — boolean: Static-only fallback from deep links to index.html. - `web.rate_limit.rps` — integer 1–100: Sustained requests-per-second target. - `web.rate_limit.burst` — integer 1–1000: Ceiling; must be between rps and 10 × rps. - `data.mode` — pocketbase | postgres | sqlite | convex | supabase | external | none: App data integration. - `data.file` — non-empty path: Required when data.mode = sqlite. - `data.convex.deployment_url` — URL: Required when data.mode = convex. - `data.supabase.url` — URL: Required when data.mode = supabase. - `data.supabase.anon_key` — non-empty string: Optional public Supabase anon key; keep privileged keys in env. - `domain.subdomain` — valid app name: Parsed but not applied; the app name sets the percher.run subdomain (change it with percher rename ). - `domain.custom` — hostname without https://: Parsed but not applied; add a custom domain with percher domains add . - `resources.memory` — 256mb | 512mb | 1gb | 2gb: Per-container memory cap used for admission. - `resources.cpu` — 0.25–2.0: Per-container CPU cap used for admission. - `resources.instances` — integer 1–4: Static instance count, plan-capped and incompatible with autoscale. - `resources.autoscale.min` — integer 1–4: Autoscaling floor; must not exceed max. - `resources.autoscale.max` — integer 1–4: Autoscaling ceiling, plan-capped. - `resources.autoscale.scale_up_cpu_percent` — integer 1–100: Optional scale-up CPU threshold. - `resources.autoscale.scale_up_sustain_seconds` — 30–3600: Seconds above the scale-up threshold. - `resources.autoscale.scale_down_cpu_percent` — integer 1–100: Optional scale-down threshold; lower than scale-up. - `resources.autoscale.scale_down_sustain_seconds` — 60–86400: Seconds below the scale-down threshold. - `resources.autoscale.cooldown_seconds` — 30–3600: Minimum delay between scaling actions. - `env.` — legacy string value: Backward-compatible seed value. Prefer the contract below and percher env set for real secrets. - `env.required` — UPPER_SNAKE_CASE[]: Keys that must exist before deploy. - `env.optional` — UPPER_SNAKE_CASE[]: Referenced keys that may be absent. - `env.ignore` — UPPER_SNAKE_CASE[]: References intentionally ignored by the scanner; a key may appear in only one env list. - `required_env.` — secret | url | string: Older typed requirement map; validated before deploy. - `dev.ignore` — string[]: File patterns ignored by percher dev. - `dev.debounce` — 100–10000 ms: Delay before percher dev rebuilds. - `dev.auto_open` — boolean: Open the URL automatically in dev mode. - `crons..schedule` — cron expression (UTC): Required schedule for a named task, evaluated in UTC. - `crons..command` — non-empty string: Required command for a named task. - `crons..description` — up to 200 chars: Optional plain-language dashboard description. - `addons.pocketbase.enabled` — boolean: Reserved; no effect today. Use data.mode = pocketbase. - `addons.cron.enabled` — boolean: Reserved; no effect today. Declare jobs under [crons]. - `addons.storage.mode` — pocketbase | volume | s3: Reserved; no effect today. - `addons.storage.max_size` — string: Reserved; no effect today. - `checks..path` — non-empty path: Required by the parser but not used; the probe uses web.health. - `checks..interval` — integer ≥ 1: Accepted but not used. - `checks..timeout` — integer ≥ 1: Read only under checks.health: seconds the deploy waits for the container to become healthy (default 30, max 300). Other names have no effect. - `checks..initial_delay` — integer ≥ 0: Read only under checks.health: seconds before failed probes count (default 5, max 120). Other names have no effect. - `redirects[].from` — exact /path or /prefix/*: Source path; unique, no whitespace or placeholders. - `redirects[].to` — /path or https:// URL: Destination; cannot equal from. - `redirects[].status` — 301 | 302 | 307 | 308: HTTP status; defaults to 308. At most 32 rules. - `headers..` — string: Custom response header: up to 16 paths and 16 headers per path; platform security/transport headers are reserved. **Redirects and headers are edge-served.** Caddy answers a matching redirect before the request reaches your container (on password-protected apps, before the login gate too), and response headers are applied when the response is written — a header set here wins over the same header set by your app. Platform-managed headers (Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and transport headers) are rejected at parse time. Prefix rules ("/docs/*") send every matched path to the same `to` — no capture groups in v1. `percher import` maps `vercel.json` redirects/headers and Netlify `_redirects`/`_headers` into these sections. **What the CLI uploads.** `percher publish` and `percher push` leave out `node_modules`, `.git`, `dist`, `.next`, `.svelte-kit`, `build`, `coverage`, `.env` and `.env.*`, `.bun`, `*.log` and `.DS_Store`, plus whatever `.gitignore` excludes. A `.percherignore` in the project root (same syntax as `.gitignore`) drops more files from the upload, and a `!` line re-includes one — for example `!/dist` for a static site whose `[build] output` is `dist`. `.env` files can never be re-included; set secrets with `percher env set`. **Pools are measured, not reserved (with caveats).** `[resources]` `memory` and `cpu` are *recommended sizing* — the per-container cap your app runs inside — not a standing reservation. In steady state your account pool (memory / CPU / disk) is charged on *measured* usage, so running apps occupy only what they actually use and a light idle app barely touches the pool; that's what lets you run many small apps or one heavy one. The cap isn't entirely free, though: when an app is *deployed, woken, or resized*, its requested cap is counted against the pool for that admission check, an app with no fresh sample (new/stale/mid-restart) falls back to its cap until measured again, and RAM caps are bounded by a hard 2× pool committed guard. Pick a cap that fits the app's real footprint (too low risks OOM-kill; much higher than needed only tightens deploy/wake/resize admission + the 2× RAM guard), then watch measured usage per app on its Resources tab and account-wide on the Account page. Full reference: https://percher.app/docs/percher-toml ## Deploy history and retention Every deploy keeps its metadata (status, timestamp, and error message). The plan's "rollback deploys" number is how many recent deploys keep their image and source tarball for fast rollback; it is not the number of history rows you can see. Build-log text and monitoring history have time-based retention. | Plan | Deploy artifacts kept | Build logs | Monitoring history | |------|-----------------------|------------|--------------------| | Free | 5 most recent | 3 days | 1 day | | Starter | 15 most recent | 14 days | 7 days | | Maker | 30 most recent | 30 days | 30 days | | Max | 60 most recent | 90 days | 90 days | Runtime logs are different: `percher logs` reads the current container's rotating Docker log (up to about 30 MB per container). It is operational tail output, not a day-based archive, and it disappears when that container is replaced or deleted. Use build logs, crash reports, or your own log service when you need durable history. Full guide: https://percher.app/docs/retention ## Telemetry The Percher CLI sends anonymous usage statistics to help improve the platform: CLI version, command name, success/error with an error type, OS, and Bun version. The local MCP server (`@percher/mcp`) sends one event per tool call: package version, tool name, success/error with an error type, OS, Bun version, a random session id generated when the server starts, a short summary of known flags (such as `dryRun` or `preview`, or the number of env keys set, never their names or values), the call's duration and a tool-specific outcome such as `live` or `failed`. No file names, source code, or env var names or values are collected. The random MCP session ID correlates tool-call events within that server session. The same opt-out variables apply to both. Opt out with an environment variable: ```sh PERCHER_NO_TELEMETRY=1 # or DO_NOT_TRACK=1 ``` See the Privacy Policy at https://percher.app/privacy for full details. Full guide: https://percher.app/docs/telemetry ## Versions and rollback Every deploy is stored as a git commit in Percher's internal Forgejo instance. List versions and roll back to any previous deploy: ```sh # List all versions bunx percher versions # e950cff deploy: dep_850j... 2026-04-16 # 867d57f Initial commit 2026-04-16 # Roll back to a specific version bunx percher rollback e950cff # No sha handy? Bare rollback lists the 5 most recent versions and lets you pick bunx percher rollback # Or let the server pick the most recent version that went live without crash-looping bunx percher rollback --last-good ``` Rollback reuses the kept image for versions inside your plan's rollback retention (see [History and retention](/docs/retention)). An older version is refused with `ROLLBACK_TARGET_UNSUPPORTED` unless you add `--rebuild`, which builds it again from its source with today's dependencies, so the result can differ from what originally shipped. You can also roll back from the Versions tab in the dashboard. Full guide: https://percher.app/docs/versions ## Zero-downtime deploys Every live deploy goes through a drain-and-swap sequence: the new container starts, passes its health check, takes over traffic via an atomic Caddy route swap, and the old container is stopped only once the post-deploy canary settles — so if the canary fails, the previous version still exists and Percher rolls the route straight back to it. A 60-second canary monitor probes the new version after the swap. If two probes return 5xx in a row, Percher rolls back: it restores the previous version's upstream and the access settings that version was serving under — password protection, visibility, redirects, response headers and rate limit all go back with it — stops the failed container, and marks the deploy failed. A rollback never leaves a protected app public because the deploy that failed asked for public. When the previous containers are already gone — they crashed on their own, or an operator removed them — there is nothing to restore, so Percher leaves the new container running instead of taking the app down. It does the same on the rarer occasions when it can restore the route but cannot safely finish the bookkeeping: nothing that might still be serving is destroyed on the strength of a rollback Percher could not complete. The deploy is marked failed either way, and the row on the deploy timeline says which of them happened — read the message on it rather than the heading, which only has two words to work with. The dashboard shows this as a stepper (Build, Start, Health check, Swap, Canary, Live) with a rollback row if the canary fired. The canary window and automatic rollback are settings of the Percher instance, not of your app: on percher.app the window is 60 seconds and auto-rollback is on for every app. Setting `PERCHER_CANARY_AUTO_ROLLBACK`, `PERCHER_CANARY_WINDOW_MS` or `PERCHER_DRAIN_GRACE_MS` as an app environment variable has no effect; they apply only to a self-hosted Percher API. Post-deploy screenshots (preview): once a deploy is live, Percher can capture a screenshot of your app and attach it to that deploy — surfaced as a `screenshotUrl` on the deploy result. It's a preview feature, off by default, and strictly best-effort: a screenshot never delays or fails a deploy. Only public apps are captured; a password-protected or private app is skipped (Percher never logs in to take the shot). The capture hits your app's public URL through a locked-down headless browser that can only reach your own app's hosts — it can't be pointed at anything internal. Full guide: https://percher.app/docs/graceful-deploys ## Preview deploys A preview runs a version of your app at its own URL while the live URL continues to serve the current version. Share it, then promote it to live or let it expire. There are four kinds — they differ in how they're triggered, what URL they get, and how long they live. **Check the data target before testing.** All preview kinds share the app's managed PocketBase database and persistent files in `/app/data` with live. Writes from a preview can change live data, and discarding a preview does not undo those writes. For an external database, set a separate test connection with `percher env set DATABASE_URL=... --scope preview` if your app uses that variable. To test with a separate managed PocketBase database or separate persistent files, publish a separate app with synthetic data. **Ephemeral** — `percher publish --preview`. Deploys to `{app}--p-{slug}.percher.run` with a random slug, so every publish gets a fresh URL. Expires automatically after 24 hours on Free, 72 hours on paid plans. Free has one preview slot per app, and a new preview replaces the previous one; paid plans allow up to 3 active previews per app, and another is refused with `PREVIEW_LIMIT_REACHED` until you promote or discard one. This is the quick "look at this" preview. **Preview branch (stable URL)** — `percher preview branch set `. Pins a branch to the fixed address `{app}-preview.percher.run`. The URL never changes; every push to the pinned branch updates what it serves. Use this when you want one preview address to bookmark or share standing links to. **Auto branch** — enable autoPreviews on the connected GitHub repo, then every push to a non-live branch deploys to `{app}--p-{branch}.percher.run`. **Pull request** — open a PR on the connected repo and it deploys to `{app}-pr-{n}.percher.run`, where n is the PR number. Finding them: the app's Overview shows the four surfaces in the Previews panel, with every currently reachable preview linked under "Active now" together with its time left. The full list — including expired and failed ones — lives under Publishes → Deploys & previews, where you can open, promote (make it the live version), or discard each preview. Previews of a private app are gated the same way as the app itself — a preview URL never exposes a private app publicly. Full guide: https://percher.app/docs/previews ## Troubleshooting common errors Fixes for the errors you're most likely to hit running `bunx percher publish`. Error names match what the CLI and dashboard show, so you can search them verbatim. `percher doctor` diagnoses most of these and prints the next action. Configuration: - **name already taken**: app names are global (they become `name.percher.run`); pick a unique `app.name`. - **unknown runtime**: `runtime` must be node | bun | python | static | docker. - **port out of range**: `web.port` must be 1024–65535 (containers don't run as root). - **repo size exceeded**: tarball over 500 MB, usually node_modules/.next/dist/.git. Check with `bunx percher publish --dry-run`; the CLI honors .gitignore and .percherignore plus a default exclude list (node_modules, .git, dist, .next, .svelte-kit, build, coverage, .env*, .bun, *.log, .DS_Store). Add a path to .percherignore to drop it from the upload only; re-include one with `!` (for example `!/dist`). .env files can never be re-included. - **Git encoding conversion failed**: Percher does not support Git `working-tree-encoding` values `SHIFT_JISX0213` (including `ShiftJISX0213`) or `EUC-JISX0213` during source import or deploy snapshots. If Git reports an unsupported encoding, review the affected files and `.gitattributes` in your own repository. Convert the files to UTF-8 locally, update the matching attribute, verify your app, then commit and retry. Do not merely relabel unchanged bytes. Percher does not rewrite these files for you. Ordinary `SHIFT_JIS` remains supported. Build and startup: - **build failed**: build command exited non-zero. See the log with `bunx percher logs --build`. Common causes: missing dev dependency, prod-only TS error, undeclared build env. - **health check failed**: Percher gives the container 30 seconds to report healthy (after a 5-second grace period), probing whatever you set as `[web].health` — or `/` if you never set it. If your app needs longer to boot (migrations, a model load, a cold cache), raise the window with a `[checks.health]` block — `path` is required, then `initial_delay = 20` (seconds before failures count, max 120) and `timeout = 90` (seconds to become healthy, max 300). Otherwise open that health path in a browser: if it returns 200, the probe hit a transient error and the old version is still live — re-deploy on a current CLI. If it's also down, verify the app listens on `[web].port` and the endpoint returns 200. - **missing API key**: set the runtime env var (use `--from-file`/`--from-stdin`/interactive for secrets), then re-publish. Rate limits and quotas: - **DAILY_QUOTA_EXCEEDED (429)**: per-day cap (Free 50 live + 25 preview, Starter 100/50, Maker 200/100, Max 1000/500), resets 00:00 UTC (`resetAt` in the response). Live and preview counters are independent, so `--preview` still works. - **DEPLOY_RATE_LIMITED (429)**: per-app 60s burst limit; retryable after `retryAfterSec`. - **RETRY_LIMIT_REACHED**: transient infra retries exhausted; try again in a few minutes or email support@percher.app. - **already_in_progress**: not an error — a deploy for this app is already running, so Percher returns HTTP 200 with that deploy's `deployId` instead of queueing a duplicate. Wait for it. Deploys can't be cancelled once queued. - **DEPLOY_RATE_LIMIT (429)**: note the missing "ED" — this is the per-hour cap, not the per-minute burst above. Free 12/hour, Starter 30, Maker 60, Max 120. Env contract (catches missing env at upload time): declare `[env].required`/`optional`/`ignore`. **REQUIRED_ENV_MISSING** = a required key isn't set (set it, re-publish). **ENV_KEY_UNDECLARED** = source references a key not in any list (add it to one). Performance and freshness: - **Slow first paint (7+ s)**: almost always third-party fonts (fonts.googleapis.com) hanging the first paint; the request goes browser to Google, not through Percher, so self-host fonts (next/font, @fontsource, or @font-face). A stale service worker can cache the slow state; hard-reload twice. - **Changes aren't showing**: the build cache hit (keyed on a content hash of toml + package.json + lockfile + Dockerfile + build env + every source file). Force a fresh build with `bunx percher publish --no-cache`. If still stale, run publish from the project root and check .gitignore isn't excluding the changed files. Still stuck? Email support@percher.app with the deploy id and build log. Full guide: https://percher.app/docs/troubleshooting ## Custom domains Every app gets a `name.percher.run` subdomain. You can also add your own domain. ```sh bunx percher domains add app.myapp.com # Percher returns DNS instructions for that exact name: # 1. TXT _percher-challenge.app.myapp.com -> percher-domain-verification= # 2. CNAME app.myapp.com -> your-app.percher.run bunx percher domains verify app.myapp.com # after DNS propagation ``` **Root domain?** For an apex like `myapp.com` Percher returns **A records** pointing at the platform IPs instead of a CNAME — most DNS providers reject a CNAME on the root. (If yours supports CNAME flattening or ALIAS, that's offered as an alternative.) Follow whichever records the command prints; don't copy the subdomain example above. Also: `bunx percher domains list` and `bunx percher domains remove `. Custom domains are a paid feature — per app: Starter 2, Maker 5, Max 10. Free apps stay on `name.percher.run`. SSL is provisioned automatically via Let's Encrypt once verification passes. Full guide: https://percher.app/docs/custom-domains ## Publishing to the App Store & Google Play **Can I publish my Percher app to the Apple App Store or Google Play?** Not directly — and that's by design. Percher doesn't submit apps to the stores for you. What it does is host your app on the web — and, once you add a web manifest and a service worker, as an installable PWA (Path 0 below), so the simplest option needs no store at all: users add it to their home screen, offline-ready. But you *can* take a Percher-hosted app into the stores — keep Percher as the web + backend layer and put a thin store app on top. There are three common paths, all built on top of the PWA step. External facts below (fees, review rules) are moving targets — each is sourced and datestamped; re-check the official link before you rely on it. ## Path 0 — Just make it a great PWA (start here, no store needed) Add a web manifest and a service worker so your Percher-hosted app is installable to the phone home screen and works offline. For many apps this is the whole answer — no developer account, no store review, no fee. Everything below is optional and builds on this. Link a manifest from your HTML and serve it from your app's origin: ```html ``` `manifest.json` (starter): ```json { "name": "My App", "short_name": "MyApp", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#2e8055", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] } ``` Register a service worker so the app is installable and works offline: ```js if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); } ``` This works on the shared `name.percher.run` subdomain or a custom domain. Every app is served over HTTPS (required for service workers), and device APIs a PWA commonly needs — camera, microphone, geolocation, and the Payment Request API — are allowed for your app's own origin; the browser still prompts the user before granting each one. Web push notifications work too (see the deploy guide's outbound-HTTPS section). The one rule for static single-page apps: ship `sw.js` (and `manifest.json`) as real files in your build output so they're served as files, not rewritten to `index.html`. ## Path A — PWA to Google Play (TWA) Wrap an installable PWA in a Trusted Web Activity (TWA) to get a real Android app (`.aab`) you can list on Google Play. It's the cheapest store path — you reuse the PWA from Path 0. Android only: Apple doesn't support TWA. Generate the Android project with Bubblewrap, pointed at your manifest: ```sh npx @bubblewrap/cli init --manifest https://your-app.percher.run/manifest.json npx @bubblewrap/cli build ``` Or use PWABuilder (https://www.pwabuilder.com/) — paste your app URL and it produces the Android package. A TWA needs Digital Asset Links to verify you own the origin (and to hide the browser URL bar). Serve `/.well-known/assetlinks.json` on your app's origin: ```json [{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "app.percher.yourapp", "sha256_cert_fingerprints": [""] } }] ``` Fill in `package_name` and the SHA-256 fingerprint after you create and sign the Android app — they don't exist until then. Then add the file to your app's own static output — or let Percher serve it for you: open the **App store kit** panel on your app's page in the dashboard, paste the package name + fingerprint, and click **Serve on Percher**. Percher then serves it at the edge on every origin the app answers on (including custom domains), even while the app is asleep, and it stays public on password-protected apps. ## Path B — Webview wrapper (Capacitor) to both stores Wrap your web frontend in a native shell that loads your Percher app (or bundles the frontend and calls Percher as an API) and adds native plugins. One codebase, both stores. ⚠️ Apple App Review Guideline 4.2 (minimum functionality): a bare webview wrapper is frequently rejected. Add genuine native value — push notifications, camera, offline support, native navigation — not just a website in an app shell. Set up Capacitor: ```sh npm install @capacitor/core @capacitor/cli npx cap init ``` `capacitor.config.ts` (point at your Percher app): ```ts import type { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { appId: "app.percher.yourapp", appName: "My App", webDir: "dist", server: { url: "https://your-app.percher.run", cleartext: false }, }; export default config; ``` Then add platforms: `npx cap add ios` and `npx cap add android`. ## Path C — Native frontend, Percher backend Build a real native app (React Native, Flutter, Swift, or Kotlin) that only talks HTTPS to your Percher app as its API/PocketBase backend. It's the most work but the cleanest path through Apple review — and it's Percher's sweet spot: backend-as-a-service for your store app. ```ts const API_BASE = "https://your-app.percher.run"; // PocketBase apps: use POCKETBASE_PUBLIC_URL from the app's env ``` Your data model, auth, and file storage stay in PocketBase exactly as on the web. For iOS universal links, serve `/.well-known/apple-app-site-association` (a JSON file, no extension, served as `application/json`) on your origin: ```json { "applinks": { "apps": [], "details": [{ "appID": "TEAMID.app.percher.yourapp", "paths": ["*"] }] } } ``` As with assetlinks, you can either add this file to your app's own output or let Percher serve it — the **App store kit** panel on your app's page has a form (Team ID + bundle ID) and a **Serve on Percher** button. It's served with no extension as `application/json`, as Apple requires. ## Which path? | What you have / want | Start here | Why | |---|---|---| | A web app you just want on phones | Path 0 (PWA) | installable + offline, no store, no fees | | It on Google Play, as cheaply as possible | Path A (TWA) | reuse the PWA, one `.aab` | | Both stores with some native features | Path B (Capacitor) | one codebase — mind Guideline 4.2 | | The cleanest App Store experience | Path C (native) | best review path; Percher stays the backend | Whichever path you pick, your frontend, API, PocketBase, env vars, custom domain and SSL stay on Percher unchanged — the store app is a thin layer on top. ## Before you start (sourced, checked 2026-06-16) - Apple Developer Program: $99/year. https://developer.apple.com/programs/ - Google Play Console: one-time $25 registration fee. https://support.google.com/googleplay/android-developer/answer/6112435 - Apple App Review Guideline 4.2 (minimum functionality) — bare webview wrappers get rejected; add native value. https://developer.apple.com/app-store/review/guidelines/ - A custom domain is strongly recommended: you own and control the origin for Digital Asset Links and universal links. Run `bunx percher domains add` (auto-SSL included). The shared `name.percher.run` subdomain still works. - Signing, store listings, and review are your responsibility. Percher hosts the web + backend layer; it does not submit apps to the stores on your behalf. Full guide: https://percher.app/docs/app-stores ## Persistent app data Each app has a persistent `/app/data` directory backed by a Docker volume. Data there survives container restarts and redeploys. Use it for SQLite databases, uploaded files, caches, or any state your app needs to keep. The volume is deleted when the app is deleted. See Backup and restore (https://percher.app/docs/backup-and-restore) for how the platform protects it. For an app's own records — the rows it reads and writes — reach for the managed PocketBase sidecar first (https://percher.app/docs/pocketbase): `[data] mode = "pocketbase"` gives you a database with a REST API, sign-in and file storage, and it is backed up and restored as its own thing. This directory is the right home for what does not belong in a database: uploads you serve, generated files, caches. **`/app/data` is reserved in every image.** The volume is mounted there for every app, whatever `[data] mode` says, including `runtime = "docker"` apps with their own Dockerfile. Leave `/app` and `/app/data` as directories or absent: an image that puts a file there (for example a compiled binary copied to the path `/app`, or a project file named `data` copied into `/app`) cannot start, and the deploy fails with `APP_DATA_MOUNT_CONFLICT`. Copying a directory to `/app` is fine. Install binaries somewhere else, such as `/usr/local/bin/app`, as the go and rust templates do. Keep a shell and `wget` or `curl` in the final image as well: the container health check runs through `/bin/sh`, so a distroless or `scratch` image of your own never becomes healthy. If the image runs as a non-root `USER`, create the directory owned by that user (`RUN mkdir -p /app/data && chown app:app /app/data`): a new data volume takes the image directory's ownership, and is root-owned without it. Full guide: https://percher.app/docs/persistent-data ## Deploy hooks A deploy hook is a secret URL that triggers a rebuild of your app when POSTed — no auth header, the URL itself is the credential. Wire it into a headless CMS publish button (Sanity, Contentful, Strapi), a cron job, or a CI step so content changes redeploy the app without touching git. **What a trigger does.** `POST https://api.percher.run/hooks/deploy/` → `202 { deployId, app }`. An app with a connected GitHub/Forgejo repo rebuilds the latest commit of its tracked branch; an app without one re-runs the latest live deploy's build. If the app has never had a live deploy, the trigger returns `409 NO_DEPLOY_YET`. If its stored source has been cleaned up, it returns `410 TARBALL_GONE`. In either case, publish once (or connect a repo), then trigger the hook again. **Create one** (the URL is shown ONCE — store it immediately): ```sh bunx percher hook create sanity-publish ``` Dashboard: your app → Settings → Deploy hooks. MCP: `percher_hook` with `action: "create"`. Manage with `percher hook list` (name, id, created, last used) and `percher hook revoke `. **CMS recipes.** Sanity: manage.sanity.io → API → Webhooks → paste the hook URL, trigger on create/update/delete of published documents, method POST (no payload needed — Percher ignores the body). Contentful: Settings → Webhooks → paste the URL, trigger on Entry publish/unpublish. Strapi: Settings → Webhooks → paste the URL with the entry.publish / entry.unpublish events (or call it from an `afterPublish` lifecycle hook). **Rate limit.** 6 triggers per minute per hook — beyond that the endpoint returns 429. Rapid-fire CMS saves coalesce into at most a few rebuilds; deploys still count against your plan's daily deploy quota. **Security.** The URL is a bearer secret: anyone holding it can trigger builds (they can't read code, env vars, or anything else). Percher stores only a hash, so a lost URL can't be recovered — revoke the hook and create a fresh one to rotate. `lastUsedAt` in `percher hook list` makes a leaked URL observable: triggers you don't recognize mean it's time to rotate. Unknown tokens get a plain 404. Full guide: https://percher.app/docs/deploy-hooks ## Deploy instruction for AI agents Add this context to your project's `CLAUDE.md`, Cursor rules, or paste it when starting a project, so your AI assistant builds the app to deploy correctly on Percher. Requirements: the app must expose an HTTP server on a configurable port (default 3000) and a health endpoint that returns 200 (usually `GET /health`); a `percher.toml` must exist in the project root. Node/Bun build through Percher's multi-stage Dockerfile; Python builds use Percher's own multi-stage Dockerfile too: set `[web].command`, and ship `requirements.txt` or a supported lockfile with its project file (a bare `pyproject.toml` is rejected); `runtime = "docker"` uses your own Dockerfile. runtime is one of node, bun, python, static, docker (use docker for Go, Rust, PHP, Ruby, Java, .NET, Elixir). How to deploy: `bunx percher publish` or the `percher_publish` MCP tool handles auth, config, packaging, and deploy in one call and returns structured errors. The agentic happy path (MCP) is: ``` publish (waitForLive: false) -> wait_for_deploy -> if recovery says so: doctor -> exact action ``` `percher_publish` returns in seconds with `status: "queued"` and a `deployId`; `percher_wait_for_deploy` (args pre-filled in `recovery.args`) reports live/failed/replaced; ambiguous failures route to `percher_doctor`, the recovery hub. A `live` result can also carry `runtimeCapture: { status, errors? }` — console errors and uncaught exceptions captured from the deployed page. A GREEN deploy (the container serves HTTP) can still be broken on load: an unresolved module import, a CSP violation, a throw. `status: "pending"` means the capture is still running; call `percher_doctor` mode='runtime' a few seconds later to read them. Agent recovery contract: every MCP tool that can fail returns `recovery.nextAction`, one of: none, open_login, wait_auth, wait_deploy, run_doctor, set_env_vars, fix_problems, retry, reproduce_locally, fix_config, ask_user, inspect_build_log. Read `recovery.nextAction` (don't regex the suggestion text), call exactly the suggested tool with exactly `recovery.args` (keep `mode`/`deployId`), and repeat until it's `none` (success) or `ask_user`. Database: add `[data] mode = "pocketbase"` for a managed PocketBase (connect via `POCKETBASE_URL`). Env: set with `bunx percher env set KEY=VALUE` or `percher_env_set`; declare a `[env]` contract (required/optional/ignore) so undeclared keys block the deploy with `fix_config` instead of failing mid-build. Apps deploy to https://.percher.run with automatic SSL. Full guide: https://percher.app/docs/ai-agents ## MCP tools for AI assistants The CLI and local MCP packages use FSL-1.1-MIT from 1.0.0; older versions retain MIT, and generated app/AI files use 0BSD. See [Package versions and licences](/docs/licensing) for upgrades and npm deprecation notices. Percher ships an MCP (Model Context Protocol) server so AI assistants like Claude Code, Cursor, and Windsurf can deploy and manage apps directly. Run `bunx percher mcp` to print the config, or add it manually: ```json // Claude Code: ~/.claude.json (mcpServers) or project .mcp.json · Cursor: .cursor/mcp.json { "mcpServers": { "percher": { "command": "npx", "args": ["-y", "@percher/mcp"] } } } ``` All 54 local tools, by area, are available since npm release 1.0.2, including `percher_sleep`. Several families are single tools with an `action` parameter (e.g. `percher_domain { action: "add" | "verify" | "list" | "remove" }`). Deploy & recovery: `percher_publish` (primary; MCP defaults waitForLive=false, returns a queued deployId), `percher_wait_for_deploy`, `percher_wait_for_auth`, `percher_init`, `percher_appstore_kit` (app-store starter files), `percher_login`, `percher_redeploy`, `percher_reproduce`, `percher_rollback`, `percher_versions`, `percher_cache_reset`, `percher_doctor` (recovery hub), `percher_unsuspend`, `percher_sleep`. Inspect & discover: `percher_deploys`, `percher_deploys_inspect` (or latestFailed), `percher_diff` (version-to-version changed files), `percher_analytics` (cookie-free visitor stats, 1-90 days; requires the app owner to have Starter, Maker or Max, including for collaborators), `percher_logs`, `percher_app_insights` (idle / memory-pressure / overprovisioned / deploy-regression), `percher_app_topology`, `percher_app_resources`, `percher_capabilities`, `percher_inspect_link`, `percher_whoami`, `percher_open`. Env: `percher_env_set`, `percher_env_unset`, `percher_env_list` (masked). Data: `percher_data`, `percher_data_export`, `percher_export`, `percher_reset_superuser`, `percher_backups`, `percher_restore` (per-app + per-component, supports dryRun). Domains: `percher_domain { action: add | verify | list | remove }`. Git auto-deploy & previews: `percher_github { action: connect | setup_webhook }`, `percher_forgejo { action: connect | setup_webhook }`, `percher_set_preview_branch`. Webhooks (account-level, one URL per user): `percher_webhook { action: add | list | remove }` — add returns signingSecret once. Deploy hooks (per-app trigger URLs): `percher_hook { action: create | list | revoke }` — create returns the secret hook URL once; POSTing it rebuilds the app (headless CMS / cron / CI). Local MCP only — the hosted connector excludes it. Collaborators & transfer: `percher_share { action: list | invite | set_role | revoke }`, `percher_invites { action: list | accept | decline }`; ownership `percher_transfer { action: initiate | cancel | list }`, `percher_transfers { action: incoming | accept | decline }`. Migrate & import: `percher_import` (Vercel/Netlify), `percher_supabase { action: inspect | migrate_schema | rewrite_client | status }`. App lifecycle: `percher_create_from_template` (30 starters), `percher_claim_app`, `percher_rename`, `percher_delete_app` (requires confirm: true). Account: `percher_account_deletion_preview`, `percher_account_delete` (preview-gated, fail-closed). Billing: `percher_billing { action: upgrade | portal }`. Full reference: https://percher.app/docs/mcp-tools ## Hosted MCP connector Percher runs a hosted, OAuth-protected MCP server at `https://mcp.percher.app/mcp`. You add it to Claude.ai (and Cursor, Windsurf, and other clients) as a **custom connector** — there's nothing to install and no token to paste. The client runs the OAuth login for you, and you approve a Percher consent screen once. Deploy any container in the EU from the agent you already use: flat plans, no per-GB invoice, and a recovery path when the build fails. It lets an AI assistant operate your Percher apps **and publish an app it just built for you** — say "publish my app" in Claude and the app it generated goes live. The hosted surface is a deliberately narrow subset — 23 tools against the platform API, compared with 54 local tools available since npm release 1.0.2. Two differences matter: it can't read files on your local disk, and it has **no `percher_doctor` and no `percher_logs`**. An ambiguous failure can return `nextAction: "run_doctor"`; on the hosted connector, use `percher_deploys_inspect` with the known app and deploy ID to inspect build diagnostics. Ask the user when the target is ambiguous. For a project on your machine, deep diagnosis, or crash analysis, use the local package (see below). ## How to add it - **Claude.ai (web/desktop):** Customize → Connectors → + → Add custom connector → `https://mcp.percher.app/mcp` → approve the Percher login. On Team/Enterprise, an owner first adds the connector under Organization settings; members then select Connect. - **Cursor:** add `{ "mcpServers": { "percher": { "url": "https://mcp.percher.app/mcp" } } }` to `.cursor/mcp.json`, then authenticate. - **Antigravity:** add `{ "mcpServers": { "percher": { "serverUrl": "https://mcp.percher.app/mcp" } } }` to `.agents/mcp_config.json` (project) or `~/.gemini/config/mcp_config.json` (global), then authenticate from Customizations. - **Windsurf:** add the URL as a remote MCP server and complete OAuth. - **Codex:** `codex mcp add percher --url https://mcp.percher.app/mcp`, then `codex mcp login percher` (streamable HTTP + OAuth, no bridge). **ChatGPT web:** enable Developer mode under Settings → Security and login, then add the URL on the ChatGPT Plugins page (availability depends on account/workspace policy). - **Gemini (web app):** Settings → Connected apps → Custom apps → Add a custom app → paste the URL → approve the Percher sign-in (Dynamic Client Registration, no credentials to paste). Google offers custom apps to personal accounts, 18 or over, in the US for now; once connected it works in the Gemini mobile app too. - **A client that only speaks stdio:** bridge it with `npx -y mcp-remote https://mcp.percher.app/mcp` — `mcp-remote` handles the OAuth dance and presents a local stdio server the client can talk to. ## What the hosted connector can do The hosted server runs against the platform, not your local disk — it's the remote-safe subset of the tool set: - `percher_publish` — **publish an app from source the assistant passes inline**: the files it generated in your session (include a `percher.toml`). Percher packs them server-side, creates the app, and deploys. This is the hosted "I just said publish" path for an assistant with no local file access. Inline source is capped at ~25 MB; for a larger app, connect a repo. - `percher_upload` — **chunked, hash-verified upload + publish**, ONLY for apps that can't fit in one `percher_publish` call (over ~64 KB of content, or heavy binary assets — every chunk call costs a model turn plus a possible approval, so smaller apps are much faster in one call). The assistant declares a manifest (per file: path, size, sha256 of the bytes on disk) that can inline small files directly, streams 32 KB verified chunks for the big ones (utf8 for text, base64 for binary), then commits — the server assembles, re-verifies every file, and publishes through the same path as `percher_publish`. A corrupted chunk is re-sent alone; nothing broken can deploy. - `percher_deploy` — deploy the latest commit of a **repo you've connected** via the GitHub App. Percher clones it server-side. `percher_redeploy` re-runs the current version. - `percher_publish_from_git` — **publish a public repo by URL**: Percher clones it server-side (one-shot, no webhook), reads its `percher.toml`, creates the app, and deploys. For a public repo you didn't build inline. Private repos use the GitHub App + `percher_deploy`. - List apps, app status and open an app - Env set / list - Custom domains - Deploys: list, inspect and wait for a deploy - Version history and diffs - Visitor analytics (app owner on Starter, Maker or Max) - Collaborator and ownership-transfer lists (read-only) - Public inspect links - Backups and data status - Resume (unsuspend) an app, or put one to sleep Manage or revoke connected clients in the Percher dashboard under **Account → Connected apps**. ## Publishing inline source `percher_publish` takes the files the assistant generated as an array of `{ path, content }` (UTF-8) plus an optional `name`. Include a `percher.toml` that sets `[app].name` and `runtime` — or omit it and pass `name`, and Percher auto-detects the runtime from the files: ```json { "files": [ { "path": "percher.toml", "content": "[app]\nname = \"my-site\"\nruntime = \"static\"\n" }, { "path": "index.html", "content": "\n

Hello from Percher

" } ] } ``` Binary assets (images, fonts) can't travel as UTF-8 text — add `"encoding": "base64"` to that file and pass the base64-encoded bytes as `content`. **Integrity check (recommended for sandbox-generated files):** each file also accepts an optional `sha256` — the hex digest of the file's raw bytes (for base64 entries, the decoded bytes: hash the file on disk, not the base64 string). Because inline content travels as assistant-generated text, a long file can get corrupted in transcription without anyone noticing; with the hash declared, Percher verifies what it received and rejects the publish with a `fix_problems` recovery naming each mismatched file, so the assistant re-emits just those instead of deploying something silently broken. The call defaults to **async**: it returns `status: "queued"` with a `wait_deploy` recovery, so resume with `percher_wait_for_deploy` to follow the build to its live URL (no long silent block while it builds). ## Hosted connector vs the local package The hosted connector can publish source the assistant **hands it inline** (`percher_publish`) or a **connected repo** (`percher_deploy`). What it can't do is read files on your **local disk** — a project folder the assistant can't see, a large app, or anything you'd publish from a terminal. For that, use the **local** MCP server `npx -y @percher/mcp` (or the CLI `bunx percher publish`), which reads your project directory. In short: - **Local package** (`@percher/mcp` / `bunx percher publish`) — publish from files on your machine, full tool set, runs on your machine. - **Hosted connector** (`mcp.percher.app/mcp`) — publish an app the assistant built (inline) or a connected repo, operate existing apps, zero install. For the local setup, see the [MCP tools](https://percher.app/docs/mcp-tools) and [Deploy instruction for AI agents](https://percher.app/docs/ai-agents) sections. ## Security The hosted connector uses OAuth 2.1 with a per-user login. Tokens are scope-limited and **revocable in real time** from the dashboard — revoking a client disconnects it immediately. Publishing is bounded by your plan's app and deploy limits, the same as the CLI. Because any client can self-register (open Dynamic Client Registration), the consent screen shows the **exact redirect origin** the approval is sent to, plus an **"unverified client"** label. Check that origin before you approve. ## Managed AI inference Managed inference is runtime-configured. Check the authenticated `GET /capabilities` response: `platform.features.managedInference` is the authoritative availability flag and `managedInferenceStatus` explains disabled, misconfigured, provider-unreachable, quota-exhausted, or operational states. When the flag is false, `VITE_PERCHER_AI_URL` is absent from new builds and the proxy returns `503 INFERENCE_DISABLED` without contacting a provider. When operational, an app with a PocketBase sidecar can call an LLM through Percher without receiving a provider key. The capsule template exposes: ```ts import { complete, streamComplete } from "./lib/ai"; const answer = await complete("Summarize this note: " + note); await streamComplete(prompt, (token) => output.append(token), { signal }); ``` Enforced data path: - The browser sends text plus its signed-in user's PocketBase JWT to `POST /capsule//ai`. CORS permits only that app's Percher hostname or a verified custom domain. - Percher verifies the JWT against that app's own PocketBase, applies per-app and per-user rate limits, reserves the worst-case cost atomically, and rejects malformed, oversized, multimodal, and non-allowlisted requests. - Percher sends the text over TLS to Scaleway Generative APIs in Paris (`fr-par`). Scaleway hosts the model itself; model creators do not receive prompts. Scaleway's published default is zero prompt retention and no model training, with a documented exception: request content may be retained for up to two weeks to investigate rare harmful/misbehaving requests or unexpected service errors. Aggregated anonymized API metadata may be kept for six months. - The completion returns through Percher. Percher stores only metered token cost, not prompt or completion content. Reported usage settles the reservation down; failures release it, while an interrupted stream without usage settles to the conservative reserved bound. Limits: 30 requests/min per app, 20 requests/min per PocketBase user, a 100 KB request body, and 2,000 output tokens. Daily spend caps reset at 00:00 UTC: free €0.05, Starter €0.50, Maker €2, Max €10. Allowlisted text models: `gpt-oss-120b` (default), `qwen3.6-35b-a3b`, and `llama-3.3-70b-instruct`. Prices are pinned and reviewed; unknown models fail closed. Never place a provider key in a capsule, `VITE_*` variable, browser bundle, or public config. `VITE_PERCHER_AI_URL` is only a public Percher endpoint. Full guide: https://percher.app/docs/managed-inference ## Bring your own LLM provider Any OpenAI-compatible endpoint works from a Percher app with no platform configuration, as long as it answers inside the egress proxy's 30-second idle timeout (stream long completions, or a slow non-streaming call can be cut). Your app calls the provider with your own key, so the request never touches Percher's managed-inference service. It does still leave through Percher's egress proxy, like other server-side outbound traffic — use an HTTPS endpoint. Use this when you already have a provider account, need a specific model, or want your inference under a particular jurisdiction. Use [managed inference](https://percher.app/docs/managed-inference) instead when you want no provider account at all — Percher holds the key and meters the cost. Two env vars, then the standard SDK: ```sh bunx percher env set LLM_API_KEY --from-file ./key.txt bunx percher env set LLM_BASE_URL=https://your-provider.example/v1 bunx percher publish ``` ```ts import OpenAI from "openai"; const ai = new OpenAI({ apiKey: process.env.LLM_API_KEY, baseURL: process.env.LLM_BASE_URL, }); const res = await ai.chat.completions.create({ model: process.env.LLM_MODEL ?? "your-provider-model-id", messages: [{ role: "user", content: prompt }], }); ``` Use your provider's documented base URL verbatim — some publish it with a `/v1` suffix and some without, and the SDK appends the route path to whatever you give it. **Outbound traffic already works.** Apps reach the public internet through the forward proxy Percher injects — seven variables: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY=localhost,127.0.0.1,.local`, each one's lowercase twin, and `NODE_USE_ENV_PROXY=1`. The proxy does not filter destinations, so any public provider hostname works without configuration. Bun and Node 22.21+/24+ honor it automatically; Python and Go honor the proxy variables natively; Node 20 and the 21/23 lines need `setGlobalDispatcher(new ProxyAgent(process.env.HTTPS_PROXY))` from undici at startup. **Anonymous apps are the exception.** An unclaimed app has no outbound access at all — it runs on an isolated network the proxy is not attached to. Claim it, or publish while signed in, before expecting a provider call to connect. On a claimed app an `ENETUNREACH` means the runtime is not using the proxy; on an anonymous app it is expected. **Keep the key server-side.** `LLM_API_KEY` matches no public build prefix, so it is not forwarded to the build and will not reach the client bundle. That is the default you want. Two platform mechanisms can override it — renaming the key to `VITE_*`, `NEXT_PUBLIC_*` or another public prefix, and listing it in `[build] pass_env` — so do neither. Your own code can still leak it: don't serialize it into a response, a client bundle, or a custom Dockerfile layer. Call the provider from a server route, API handler, or server action — a key shipped to the browser is readable by every visitor, and browser-side calls bypass the egress proxy entirely. `LLM_BASE_URL` and `LLM_MODEL` are not secrets and may be public if your framework needs them at build time. **Fail before the build, not after.** Declare the keys so a missing value is caught at upload time instead of 90 seconds into a build: ```toml [env] required = ["LLM_API_KEY", "LLM_BASE_URL"] optional = ["LLM_MODEL"] ``` A missing required key returns `REQUIRED_ENV_MISSING` with the exact key list. **What this covers.** Anything speaking the OpenAI chat-completions format: hosted providers, EU- or Sweden-based endpoints such as staik, aggregator gateways, and self-hosted servers like Ollama or vLLM reachable over the public internet. Percher does not operate, vet, or have a commercial relationship with these providers — the list is illustrative of the API shape, not a recommendation. An endpoint on your own private network is not reachable: your app runs on Percher's infrastructure, not yours, so the provider needs a public hostname. **Data protection.** You remain the controller for prompt content, and you contract directly with the inference provider in addition to your agreement with Percher — Percher still hosts the process and handles the key. These calls do not go through Percher's managed-inference path, so its approval gate does not apply. Two things to be precise about. Outbound traffic traverses Percher's egress proxy — with an HTTPS endpoint the proxy carries an opaque tunnel and cannot read request or response bodies, which is the reason to insist on HTTPS. And your key is encrypted at rest, but Percher's control plane necessarily handles it in plaintext when you set it and again when it is injected at deploy time: it is a secret shared with the platform, exactly like any other env var. Check where your provider processes data and what it retains before sending personal data. Full guide: https://percher.app/docs/byo-inference ## Crash diagnostics Crash detection and basic crash reports continue to work. External AI analysis is currently disabled across Percher. An app's opt-in setting alone does not send logs to an external AI provider. App settings show the current availability. Anthropic Claude is an optional future provider. External analysis would require Percher to complete the provider-account and transfer safeguards, reactivate the service, and have the app owner's opt-in. OpenRouter remains withdrawn and is not a fallback. Redaction reduces exposure but cannot guarantee that every personal detail is removed from logs. The watchdog checks for exited Docker containers at a configured interval, every five seconds by default. A detected non-zero exit queues a report on the next poll, and OOM kills (exit code 137) get a deterministic memory-limit explanation without external AI. Reports are available in the app dashboard. No new external AI explanation is generated while the service is disabled. For AI assistants with MCP: call `percher_doctor` with `mode="runtime"` to retrieve runtime troubleshooting information and the latest applicable crash report. Basic reports remain available without external AI analysis. Stale reports (over 24h old) on apps that have recovered are filtered out. Full guide: https://percher.app/docs/crash-diagnostics ## Monitoring & alerts Percher pings every paid app from outside the cluster on a schedule and tells you when external probes start failing. This is distinct from crash diagnostics: the watchdog watches the container process, while uptime monitoring watches whether the app is reachable from the internet (DNS, Caddy, TLS, 5xx). Both can fire for the same incident. Probe cadence per plan: Free disabled, Starter every 5 minutes, Maker every minute, Max every 30 seconds. The probe fetches the app's primary URL (verified custom domain if any, else the `*.percher.run` subdomain) with an 8-second timeout; HEAD falls back to GET for hosts that 405 on HEAD. 2xx/3xx counts as up; 4xx, 5xx, and network errors count as down. Outage detection: a single bad probe is not an outage. Three consecutive failures flip the app to down and fire `app.unhealthy`; a later success flips it back and fires `app.recovered` (only when the matching unhealthy was actually delivered). A 15-minute cooldown gates re-firing during flapping. The current Health page shows the 30-day uptime percentage and current route latency. The uptime API returns the fuller day/week/month rollups, recent probes, and outage intervals; those extra series are not all rendered in the dashboard yet. Per-route 5xx tracking: the Analytics tab's Top error paths panel shows which routes returned 4xx/5xx, sorted by 5xx count with errorRate% per route. Aggregated daily from Caddy access logs and re-summed across the selected window, so a consistently-failing route rises to the top of the 30-day view even if it's never the worst on a single day. Updates every 15 minutes. Full guide: https://percher.app/docs/monitoring ## Visitor analytics Visitor analytics are included when the app owner has a **Starter, Maker or Max** plan. This also applies to collaborators: access follows the app owner's plan, not the collaborator's. Free and anonymous apps do not include visitor statistics. App health and logs remain available under their existing plan limits. There is no setup: no script to add, no cookie banner, no third-party account. The numbers are aggregated on our servers from your app's access logs, so analytics work even if your app has no JavaScript. Open the **Analytics** tab on your app in the dashboard, or ask your AI assistant ("how many people visited my app this week?" — the `percher_analytics` MCP tool), or run: ```sh bunx percher analytics --days 30 ``` What you get, over a 7 / 30 / 90-day window: - **Daily unique visitors (summed), sessions, bounce rate** — the selected-window visitor total adds each day's unique count. It is not a cross-period count of distinct people, because privacy salts deliberately prevent matching one person across days. A session ends after 30 minutes of inactivity; a bounce is a session that viewed one page. - **Top pages** and **entry pages** — where people land and what they read. - **Traffic sources** — the sites that link to you, by hostname. - **Countries**, and the **device / browser / OS** split. - **Top error paths** — routes returning 4xx/5xx, worst 5xx first. Known bots, crawlers and vulnerability scanners are filtered out of every number and reported separately, so a brand-new app doesn't show "300 visitors" that are all Googlebot. **How it stays cookie-free.** Nothing is written to your visitors' browsers. Unique visitors are counted with a salted hash of IP address and browser; a separate salt is used for each day and each one is deleted within 48 hours, so a visitor counts once per day and the identifiers stored for two days can't be matched to each other. No IP address is stored in the statistics, referrer paths and query strings are never stored, and location is country-level only. See the Privacy Policy for the full description, including how long the identifier lives and what happens in backups. Numbers update every 15 minutes. Days use UTC. Visitor counts estimate IP/browser identities, not distinct people. Only document requests reaching our servers count as page views; client-side navigation and fully local cache views are not visible. Top lists are marked approximate when older snapshots or storage limits leave counts incomplete. Full guide: https://percher.app/docs/analytics ## Cost optimization insights Percher has two suggestion surfaces. The dashboard overview shows a few immediate hints from data already loaded on that page, such as a high error rate or memory close to the account pool. The deeper cost-and-reliability analysis is available through the `percher_app_insights` MCP tool and `GET /apps/:app/insights`. It computes four rule kinds: - **idle**: zero requests and visitors in 30 days (app at least 14 days old). Consider deleting. - **memory-pressure**: 2+ OOM kills in 14 days. Upgrade the plan or reduce memory use. - **overprovisioned**: on maker/max with under 100 req/day averaged over 14 days and no OOMs. Consider downgrading. - **deploy-regression**: 3+ failed deploys in 24h. Something changed; review the latest build log. The API/MCP result includes a machine-readable `actionHint` when there is a safe next tool, such as opening billing or crash diagnostics. The dashboard overview does not currently show this four-rule result or its action hints; use MCP/API for the complete analysis. No suggestion changes a plan or deletes an app automatically. Full guide: https://percher.app/docs/cost-insights ## Multi-instance & auto-scaling Run multiple containers behind a single Caddy load balancer with active health checks. Configure it in percher.toml with either `[resources].instances` (a fixed count) or `[resources.autoscale]` (a range) — setting both is rejected when the file is parsed. One caveat, and it applies to autoscaling only: a fixed `instances = N` fan-out gives every container the same fully-assembled environment, but a replica that autoscaling adds LATER gets only your stored env vars plus `PORT` — not `PERCHER_APP_*`, the egress-proxy variables, or the PocketBase/Convex/Supabase connection vars. If your app needs outbound HTTPS or a managed database, prefer a fixed instance count over autoscale for now. Static fan-out: ```toml [resources] instances = 2 # runs 2 containers, load-balanced ``` Plan caps: free=1, starter=1, maker=2, max=4. Exceeding the cap is silently clamped with a note in the build log. CPU-based autoscaling: ```toml [resources.autoscale] min = 1 max = 4 ``` Percher samples CPU every 30s, evaluates every 50s, and scales by ±1 when the sustained average crosses the threshold (default: up at over 80% for 2 min, down at under 20% for 10 min). Scale-up is conservative (one instance per action) and a cooldown prevents thrashing. Active Caddy health checks route traffic around any unhealthy instance. A single-instance crash in a multi-instance deploy stays at severity "warning" (the app keeps serving); the app only becomes "crashed" when all instances are down at once. Per-deploy scale history lives on the deploy detail card. Full guide: https://percher.app/docs/multi-instance ## Event webhooks Percher POSTs signed JSON payloads to your webhook URL when events happen: useful for Discord/Slack bots, on-call pagers, or custom dashboards. Account-level: one URL per user, all events go there. Events: - `deploy.succeeded`: a deploy reached live (data: deployId, url, durationMs, kind "live"|"preview"). For "live" it fires only after the post-swap canary closes cleanly, so it's never contradicted by a deploy.failed for the same deployId. `url` is always the `.percher.run` domain; custom domains are looked up separately. - `deploy.failed`: any deploy fails (build, health, or canary). - `app.crashed`: the watchdog detects a process exit or OOM. - `app.unhealthy`: external uptime probe failed 3 times in a row (Starter and up). - `app.recovered`: app responds again after an unhealthy window (pairs with a delivered unhealthy event). - `domain.expiring`: a custom-domain SSL cert is within 7 days of expiry. - `security.finding`: a security scan first finds a red-tier fixable/exploitable vulnerability in a live app (data: tier "red", findings[] with cve, component, severity, fixedIn, tier, deadlineAt). Yellow findings remain visible in the dashboard but do not trigger the automatic webhook. Each red finding is sent once, not on every re-scan. `app.crashed` is container-level (process exited); `app.unhealthy` is reachability (DNS/Caddy/TLS/5xx). They're independent and both can fire for one incident. `app.unhealthy` has a 15-minute cooldown to avoid pager spam. Setup: Account → Devices & notifications, paste your receiver URL into the Notifications card. A signing secret is shown once; copy it into your receiver's `PERCHER_WEBHOOK_SECRET`. Changing the URL rotates the secret; clearing it clears the secret. Example delivery — every event uses the same envelope (`data` carries the per-event fields plus `appId`/`appName` when the event concerns an app): ``` POST /your-receiver HTTP/1.1 Content-Type: application/json X-Percher-Event: deploy.succeeded X-Percher-Delivery: wh_k3v9x2m8q4za X-Percher-Timestamp: 1782950400000 X-Percher-Signature: sha256=8f3a… { "type": "deploy.succeeded", "id": "wh_k3v9x2m8q4za", "timestamp": 1782950400000, "data": { "deployId": "dep_a1b2c3", "url": "https://my-app.percher.run", "durationMs": 18400, "kind": "live", "appId": "app_x9y8z7", "appName": "my-app" } } ``` Verify signatures: every delivery carries `X-Percher-Signature: sha256=`, an HMAC-SHA256 of `${timestamp}.${body}`. Reject anything older than a few minutes for replay protection. ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, req, body) { const sig = req.headers.get("X-Percher-Signature") ?? ""; const ts = req.headers.get("X-Percher-Timestamp") ?? ""; const expected = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex"); const provided = sig.replace(/^sha256=/, ""); if (expected.length !== provided.length) return false; return timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); } ``` Deliveries are best-effort with a 5-second timeout; redirects are not followed and failed responses are not retried. Use the final HTTPS receiver URL, return a 2xx response directly, and queue events in your receiver if you need retries or ordering. Full guide: https://percher.app/docs/webhooks ## Security scanning Percher scans built images for known vulnerabilities with Grype and Trivy and schedules re-scans of live apps. Scanner availability, coverage and freshness matter: a build, an accepted scan request or an empty findings list does not prove a completed clean scan. ## Your app and Percher's platform You manage your application code, dependencies and any Dockerfile/base image you supply. Percher manages its hosting platform, isolation, and the base images and language runtimes it chooses for generated builds. Platform-base findings are excluded from your actionable list and shown as a separate Percher-managed count. They are not resolved merely because they are excluded, and you are not asked to patch Percher's infrastructure. This responsibility split is not permission to edit your application or database. Review proposed changes and authorize deployment. Percher may contain a security threat through the existing security-hold policy; that is separate from changing your code or data. ## What the colours mean - Red: critical with a fix, plus an internet-exposed app or known exploitation. Investigate first. The exposure signal does not prove the vulnerable function is reachable. - Yellow: high with a fix, or critical with a fix without the red exposure/exploitation condition. Assess and plan a supported update. - Green: the remaining findings, including ones without a published fix. Check the advisory and actual impact; a missing fix does not mean no action is needed. These are scanner triage categories, not a complete risk assessment. Confirm the affected version, feature and call path using the component vendor's advisory. The CVE links open NVD records, which can point to primary references. A path such as node_modules or a Go stdlib component helps locate a package but does not establish who can exploit it. ## Holds and notifications A red owner-owned finding starts a 14-day window. On hosted Percher, a finding that remains red, fixable and known-exploited after the deadline can put the app on a security hold. Self-hosted enforcement depends on operator configuration. A remediation deploy must pass the owner's red-finding gate and its post-swap health checks before the hold lifts. Percher-managed base findings are excluded from that owner gate; the platform team remains responsible for them. Automatic owner email targets new red findings, subject to notification settings and delivery availability. Lower tiers remain visible in the dashboard; do not rely on an empty inbox as proof of safety. ## Review, fix and verify 1. Open the vendor advisory and compare its prerequisites with your actual code and configuration. Record uncertainty instead of assuming a scanner result is either exploitable or harmless. 2. Prepare a compatible dependency/base-image patch or mitigation in your own project and run relevant tests. A no-cache rebuild alone does not guarantee a patch. 3. Use Copy for AI agent to export selected findings as untrusted evidence. Ask the agent to show its proposed diff and validation, then obtain your approval before deployment. Scanner text is not permission or instructions to run commands, change access or edit hosted data. 4. After an approved deployment, verify the new image's completed scan and app health. A merged patch or accepted deploy request is not the final result. The current Security tab reports findings without a terminal scan time for the current image. It therefore cannot certify scan completion or freshness. If data is unavailable or a platform finding needs attention, contact Percher support with the app name and CVE; do not send credentials or customer records. References: https://nvd.nist.gov/vuln and https://percher.app/docs/security-scanning ## Capsule — the zero-glue full-stack lane A capsule is Percher's opinionated, zero-glue lane for a small full-stack app that needs a database and sign-in. It's a pre-wired template on top of a PocketBase sidecar: typed data client, realtime helper, and email/password sign-in are already wired, and Percher applies the app's schema (`pb/schema.json`) to PocketBase BEFORE the app goes live — so the demo works on first publish instead of 500-ing on the first write. Scaffold + publish: ```bash bunx percher create my-app --template capsule cd my-app bunx percher publish ``` What ships pre-wired: - `src/lib/db.ts` — typed PocketBase client pointed at the app's own `VITE_POCKETBASE_URL`. Write `db.collection("messages").create(...)`, not raw REST. (The capsule's Dockerfile declares `ARG VITE_POCKETBASE_URL` so the value is baked into the bundle at build time.) - `src/lib/realtime.ts` — `subscribe("messages", cb)` wraps PocketBase realtime so cross-tab/cross-user sync is one call. - `src/components/Auth.tsx` — email/password sign-up + sign-in against PocketBase's built-in `users` collection. Works on day one with zero provider config. Google sign-in is a one-command add-on (`percher data oauth google`). - `src/lib/ai.ts` — keyless normal and streamed completions when the instance reports managed inference operational. Percher injects only its public endpoint URL; the browser authenticates with the signed-in user's PocketBase JWT and never receives a provider credential. Check `GET /capabilities` before building on it. When disabled or misconfigured the URL is absent and calls fail closed. See [Managed AI inference](/docs/managed-inference). - `pb/schema.json` — the declarative schema (collections + access rules) Percher provisions. Additive-only and rollback-safe: new collections apply, an older descriptor is a no-op, and a breaking change (a field type change, a tightened rule) fails the deploy before it ships. Two rules when editing it: a new field on a collection that already exists must not be `required: true` (the still-running old container would start failing its writes), and `indexes` is not supported — a collection declaring one is rejected before anything is applied, so UNIQUE constraints are created by hand in the PocketBase admin UI for now. The capsule is one fast lane, not a replacement — Percher still hosts any app in any language. Reach for a capsule when you're building a new small app that needs a database and sign-in; use the host-anything lane for everything else. Full guide: https://percher.app/docs/capsule ## PocketBase — database, auth & files PocketBase is a managed SQLite-backed backend with a REST API, user auth, file storage, and realtime subscriptions. Each app gets its own sidecar. Use it when people need shared records, accounts, uploads, or data that follows them across devices. A static site or browser-only timer may not need a database. Keep an existing Supabase or Convex backend when your app depends on it; see [external databases](https://percher.app/docs/external-databases). Enabling PocketBase does not convert localStorage, replace another database, or create your app's collections automatically. ## Set up PocketBase Three ways to start: 1. Ask your connected assistant: "Add Percher PocketBase to this app. Create the collections and per-user access rules it needs, connect the app through the PocketBase SDK, publish, and verify that one user cannot read another user's private data." 2. Start a new app with the [Capsule template](https://percher.app/docs/capsule): `bunx percher create my-app --template capsule`, then `cd my-app` and `bunx percher publish`. It includes sign-in, a client, and a schema applied during publish. 3. Add it to an existing app: merge this section into percher.toml, keep the existing app name and runtime, then publish from that project's directory: ```toml [data] mode = "pocketbase" ``` ```sh bunx percher publish bunx percher data ``` The Data tab now shows the database and its admin link. To sign into the admin UI, use the Data tab's reset-password control or run `bunx percher data reset-superuser --reveal` from the project directory. Use the returned email and one-time password; keep them private. Redeploy if running server code uses the rotated superuser password. ## Create collections In the PocketBase admin UI, create a Base collection named tasks. Add title (Text), done (Bool), and user (required single Relation to users). Set API rules before connecting browser code: locked rules are superuser-only; empty rules allow anyone. For private tasks, use these rules: - List / View / Delete: `@request.auth.id != "" && user = @request.auth.id` - Create: `@request.auth.id != "" && @request.body.user = @request.auth.id` - Update: `@request.auth.id != "" && user = @request.auth.id && @request.body.user:changed = false` Use a signed-in user's ID when creating a task. Auth users belong in an Auth collection; a Base collection stores ordinary records. A File field stores uploads. Alternatively keep your schema in the project's `pb/schema.json`, as the [Capsule guide](https://percher.app/docs/capsule) describes, so supported additive changes apply during publish. Changing only data.mode does not create these fields or rules. [PocketBase rule reference](https://pocketbase.io/docs/api-rules-and-filters/). Install the SDK in your project with `npm install pocketbase` (or your project's package manager). Three env vars are injected automatically: `POCKETBASE_URL` (internal Docker URL for server-side calls), `POCKETBASE_PUBLIC_URL` (public URL with SSL), and `VITE_POCKETBASE_URL` (public URL, Vite convention). Connect with the official SDK: ```js import PocketBase from "pocketbase"; const pb = new PocketBase(process.env.POCKETBASE_URL); // server-side ``` Browser alternative: `const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL)`. VITE_* is supplied at build time for supported builds; a custom Dockerfile needs an ARG. Next.js browser code needs your own NEXT_PUBLIC_POCKETBASE_URL set to the public PocketBase URL before publishing. Never expose POCKETBASE_ADMIN_PASSWORD in browser code. For server-side user sessions, create a separate SDK client for each request rather than sharing mutable auth state. The SDK covers CRUD (`pb.collection("tasks").create/getList/update/delete`), auth (`authWithPassword`, `pb.authStore`), file uploads via FormData + `pb.files.getURL`, and realtime (`pb.collection("messages").subscribe`). Admin UI is at `pb-yourapp.percher.run/_/` (create collections, set API rules, manage data). Percher generates the superuser password at first deploy and stores it encrypted — **it is never displayed**, and `percher env list` shows it masked. To get one you can sign in with, run `bunx percher data reset-superuser --reveal`: it mints a new password, prints it once, and re-injects `POCKETBASE_ADMIN_PASSWORD` encrypted (redeploy to pick it up in the container). Don't use PocketBase's "forgot password" flow for superuser recovery; it needs SMTP, which isn't configured by default. Email (SMTP) is bring-your-own: Percher doesn't provide SMTP for the sidecar, so every PB feature that sends mail (signup verification, password reset, OAuth notifications) is a silent no-op until you configure a provider in the PB admin UI under Settings, Mail settings (Resend, Postmark, SES, Mailgun, SendGrid, or your own). Until then PocketBase's password-reset endpoint still returns HTTP 204 even though no mail leaves the box. ## Verify the deployed app Sign up and sign in, create and read a record, then check that signed-out visitors and a second user cannot read private records. Reload and publish again to confirm persistence. Test verification and reset email delivery if your app uses them. See [backup and restore](https://percher.app/docs/backup-and-restore) and [export data](https://percher.app/docs/export-data). Full guide: https://percher.app/docs/pocketbase ## Connect an external database Keep an external database when your app already uses one or needs provider-specific features. Percher hosts your app; the provider continues to store your data and handle its backups, access rules, billing, and availability. Choosing a data mode does not create a provider account, migrate records, deploy provider functions, or rewrite your app. For a new app needing records, sign-in and files together, see [PocketBase](https://percher.app/docs/pocketbase#setup). A static site may need no database. ## Supabase Create or select a Supabase project. Copy its project URL and public anon key from the provider dashboard into percher.toml: ```toml [data] mode = "supabase" [data.supabase] url = "https://your-project.supabase.co" anon_key = "YOUR_PUBLIC_ANON_KEY" ``` Percher supplies SUPABASE_URL, SUPABASE_ANON_KEY, VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY. Your app must read those names. If it expects different names, set them through Percher's environment editor. For Next.js browser code, set your own NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. Only put a public client key in anon_key; never a service-role or secret key. Keep row-level security and user authentication configured at Supabase. See [migration options](https://percher.app/docs/migrate-supabase). ## Convex Create or select a Convex deployment and deploy your Convex functions using Convex's tools. Put its deployment URL in percher.toml: ```toml [data] mode = "convex" [data.convex] deployment_url = "https://your-project.convex.cloud" ``` Percher supplies CONVEX_URL and VITE_CONVEX_URL. Set NEXT_PUBLIC_CONVEX_URL yourself if your Next.js browser client expects it. Configure provider authentication and redirect URLs for your app's final public domain. Publishing to Percher does not deploy your convex/ functions. See [migration options](https://percher.app/docs/migrate-convex). ## Other providers Use a provider with an HTTPS API compatible with your runtime and configure its SDK in your app: ```toml [data] mode = "external" ``` In the app's Settings, add the URL and keys under Environment variables, using exactly the names your code reads. Keep secrets server-side. Alternatively import a private, gitignored .env file with bunx percher env set --from-env-file .env.database. The file is read only when explicitly supplied. mode = "none" also leaves connections under your control; neither mode provisions a database or injects a connection URL. Server-side outbound connections use Percher's HTTPS proxy on port 443. A normal postgres:// connection on port 5432 or mysql:// on 3306 is not supported by that proxy. Use a provider's HTTPS data API/compatible driver, or [managed PocketBase](https://percher.app/docs/pocketbase). Browser requests go directly from the user's browser to the provider. See [network and environment setup](https://percher.app/docs/env-vars). ## Publish and verify ```sh bunx percher env list bunx percher publish ``` Public-prefixed variables (VITE_*, NEXT_PUBLIC_*, etc.) reach the build and are visible to visitors: never put a secret in one. For a custom Dockerfile, declare matching ARGs for public values used during the build. Environment changes need a new publish. Test a write and read from the deployed app, sign out and verify private data is inaccessible, then sign in as a different user. Configure auth redirects for the final app URL and use a separate provider project for preview data when needed. Percher's PocketBase Data browser and backups do not manage an external provider's records. Full guide: https://percher.app/docs/external-databases ## Per-app KV store Every app gets a small key/value store on the platform — handy for feature flags, counters, session state, and small caches without spinning up a table for them. Not a replacement for PocketBase: keep larger or structured data there. Endpoints (authenticate with a full-scope API token — KV is excluded from the narrow deploy/env/read scopes entirely): ``` GET /apps//kv # list keys (size + expiry, no values) GET /apps//kv/ # read a value (404 if missing or expired) PUT /apps//kv/ # write a value (optional ttlSeconds) DELETE /apps//kv/ # delete a key ``` Limits: keys up to 256 chars, values up to 1 MB. Keys belong to one app, but the size quota is shared across every app on the owning account: the owner's plan sets it (the `kvStorageMb` limit), never above 256 MB per account, and a collaborator's writes count against the owner's quota. Expiring keys: pass `ttlSeconds` alongside `value` on write; expired keys read as 404 and are cleaned up automatically. Access is owner + admin collaborators — viewers can't read values, since flags and session state can be sensitive. Example (a flag that expires after a day): ```sh curl -X PUT https://api.percher.run/apps/my-app/kv/launch-banner \ -H "Authorization: Bearer $PERCHER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value": "on", "ttlSeconds": 86400}' ``` Full guide: https://percher.app/docs/kv-store ## Export PocketBase data Export your PocketBase database and uploaded files as a `.tar.gz` archive, for local development, migrating to another app, or your own off-device backup. Trigger it from the dashboard (Data tab, Export data) or the CLI: ```sh bunx percher data export # app name from percher.toml bunx percher data export my-app # explicit app bunx percher data export --out backup.tar.gz # custom output path ``` The file streams straight to disk with backpressure (the worker holds only the chunk in flight), so it works at any size. The archive is a snapshot of the sidecar's `pb_data` directory: `data.db` (SQLite, all collections and rows), `data.db-wal` (if non-empty), `storage/` (uploaded files), and `pb_migrations/`. It is taken at rest: the export pauses the PocketBase sidecar for the few seconds the copy takes, verifies the copy, restarts PocketBase and checks it is serving before the archive streams — so `data.db` is always consistent and everything committed before the pause is included. Browse it locally with `sqlite3 pb_data/data.db`, or run a local PocketBase against it: `./pocketbase serve --dir pb_data` then open `http://127.0.0.1:8090/_/`. Importing into a new Percher app: the export is a raw `pb_data/` snapshot, not a native PocketBase backup ZIP, so it can't be uploaded directly via the PB backup UI. Either (A) run PocketBase locally on the extracted data, create a native backup (Admin UI, Settings, Backups), and upload that ZIP to the new app's PB admin UI; or (B) email support@percher.app with source and target app names and we copy the `pb_data` volume between containers. Export rate limits per app per 24h: Free 1, Starter 1, Maker 2, Max 5. Per app, not per account; each attempt counts even if the download is cancelled. Full guide: https://percher.app/docs/export-data ## Backup & restore Percher backs up the entire platform every night to encrypted off-site storage, so a corrupted database, deleted volume, or lost server restores from yesterday's archive rather than from scratch. What's backed up: every app's `/app/data` volume (databases, uploaded files, SQLite, caches), every PocketBase sidecar's `pb_data`, platform metadata (apps, users, deployments, env-var ciphertext, custom-domain config), Forgejo deploy-history repositories, and Caddy TLS certs (so HTTPS works immediately after a restore). Schedule and storage: daily at 02:00 UTC, encrypted with AES-256 (GPG symmetric), uploaded to off-site storage in a different EU datacenter from production. Encrypted backups are retained off-site for 30 days. Recent nightly copies are also kept on the production server; disk pressure can move older verified copies off-site while keeping at least three recent nightly generations locally. A failed backup pings on-call on Discord and emails the operations mailbox within minutes. Restoring is self-service. List what you can restore from with `bunx percher backups `, then `bunx percher restore latest ` (`source` also takes a UTC date `YYYY-MM-DD` or a full backup id). Add `--dry-run` first to see exactly what would happen, and `--components appdata,pocketbase` to restore only one half. Your AI assistant can do the same via `percher_backups` and `percher_restore`. Older archives held only off-site require support@percher.app to bring the selected copy back for restore. There's no dashboard button yet — for anything the CLI can't reach (a deleted app, a cross-account copy), email support@percher.app with the app name, what was lost, and roughly when. What it does not protect against: writes between 02:00 UTC and the failure (up to ~24h of data), application-level corruption you replicated into a backup, and loss older than the 30-day retention window. Note: `bunx percher delete` removes the app immediately, but the previous night's archive still has its metadata and data volume, so an accidental delete can usually be recovered within the 30-day window. For sub-daily protection, run your own dump (PocketBase's built-in backup endpoint, or copy your SQLite file out of `/app/data` on a schedule). Full guide: https://percher.app/docs/backup-and-restore ## Billing & plans Plan changes go through Polar. Three ways to change your subscription: - **Dashboard**: Account, Plan & billing. Upgrade opens a Polar checkout; existing subscribers get a Manage subscription button that opens the Polar customer portal. - **AI assistant (MCP)**: `percher_billing(action: "upgrade", plan)` returns a checkout URL, `percher_billing(action: "portal")` returns the portal URL. The assistant surfaces the URL; you click to confirm. While a paid tier is waitlisted, `upgrade` is refused with `PLAN_WAITLISTED` (409); join the waitlist on https://percher.app/pricing. - **Suggestions**: when insights detect overprovisioning, the Manage subscription action on the overview tab takes you to the Polar portal. Percher never changes subscriptions server-side. Every path ends with you confirming in the browser. Once Polar reports the new status via webhook, your plan limits update automatically, with no redeploy. ## Visitor analytics **Starter, Maker and Max** include cookie-free visitor analytics. Access follows the app owner's plan, including for collaborators. Free and anonymous apps do not include visitor statistics. App health and logs remain available under their existing plan limits. See [Visitor analytics](/docs/analytics) for the available metrics. ## Included bandwidth Bandwidth is counted per account, per calendar month (UTC). Everything your apps serve counts: the app itself, previews, PocketBase and custom domains. Each byte counts toward the account that owned the app when it was served, so deleting an app or creating it again does not reset the month. If you transfer an app, the traffic it served before the transfer stays on your account and the recipient's count starts at the transfer. At 80% of the allowance you get an email. At 100% public traffic to your apps pauses until the next month starts. ## Sleeping an app Free apps sleep after 20 idle minutes and wake on the next request; paid plans stay running. You can also put one to sleep yourself: the **Put to sleep** card on the app's Settings tab, `percher_sleep` from the hosted MCP connector, or `percher sleep ` and the local `percher_sleep` tool, which require CLI/local MCP 1.0.2 or later. Routes, custom domains, environment variables and data stay exactly as they are, and the next visit wakes it. Sleeping is how you hand RAM and CPU back to your plan's allowance when a start is refused because the allowance is in use, and the refusal names which apps are holding it. Disk is not freed, so a sleeping app still counts against your storage pool. Full guide: https://percher.app/docs/billing ## Migrating from Supabase Two options: keep your Supabase project as an external database, or switch to Percher's managed PocketBase. For most apps — and anything that leans on Postgres features — Option 1 is the right call. Option 1, keep Supabase (recommended, no rewrite): ```toml [data] mode = "supabase" [data.supabase] url = "https://your-project.supabase.co" anon_key = "eyJ..." ``` Percher injects `SUPABASE_URL`, `SUPABASE_ANON_KEY`, and their `VITE_` variants. Match the names your app reads; Next.js public variables must be set explicitly. Keep Supabase's auth, RLS, storage and functions at Supabase. Check the HTTPS connection method and auth redirect URLs before publishing. Follow the [connection guide](https://percher.app/docs/external-databases#supabase); native Postgres TCP connections are not supported by Percher's HTTPS proxy. Option 2, switch to PocketBase. Percher generates most of the migration for you: ```sh bunx percher migrate-from-supabase --project --token sbp_... ``` This writes a `./migration-preview/` folder (nothing touches your data yet): - `pb_schema.json` — your tables mapped to PocketBase collections - `pb_migrate.js` — a runnable data-import script that copies your rows over - `MIGRATION_NOTES.md` — every RLS policy, flagged column, and manual step Add `--rewrite-client` to also convert your Supabase SDK calls to the PocketBase SDK across the project (preview first; `--apply` writes in place and backs up the originals). It rewrites the high-confidence auth + CRUD calls, scaffolds a PocketBase client (`src/lib/pocketbase.ts`) and inserts `import { pb }` into the rewritten files, and writes `migration-preview/REWRITE_NOTES.md` — a checklist of what's left plus concrete PocketBase recipes for the auth calls it deliberately didn't fake (`getSession`, OTP, password reset, …). Anything with no clean equivalent (storage, rpc, realtime, edge functions) is left untouched and flagged, never stubbed. Hybrid is fine and expected. While any Supabase call remains, the migration keeps **both** SDKs and both env sets — that's correct, not a missed step. Dropping `@supabase/supabase-js` only happens once nothing Supabase is left. Check what's left any time with the local scoreboard (no Supabase token needed): ```sh bunx percher migrate-from-supabase --status ``` It reports remaining `@supabase` imports, any undefined `pb` references, auth/storage/rpc/realtime/edge counts, and whether the PocketBase dependency + env are wired — each pass/fail/warn, with the project's overall state (broken / hybrid / not-started / complete). Honest limits — read before committing: - Auth users can't be moved directly. Supabase password hashes aren't compatible with PocketBase, so every user has to reset their password. - It's a preview plus a script, not one click. You run the data-import yourself with a Supabase service-role key and review the flagged items. - Not portable: Edge Functions (use API routes), Postgres views/triggers/functions (rewrite as app logic), complex RLS (PocketBase API rules use a different model — the tool translates the simple `auth.uid() = column` case and flags the rest), realtime, PostGIS, and Supabase Vector/embeddings (use an external service). If your app depends on those Postgres features, keep Supabase (Option 1). Concept mapping for the parts you rewrite by hand: - `supabase.from('t').select()` -> `pb.collection('t').getList()` - `.insert({})` / `.update({})` / `.delete()` -> `.create({})` / `.update(id, {})` / `.delete(id)` - `supabase.auth.signUp()` -> `pb.collection('users').create({})` - `signInWithPassword()` -> `pb.collection('users').authWithPassword()` - `auth.getUser()` -> `pb.authStore.record` - `storage.upload()` -> `pb.collection('x').create(formData)` - `channel().subscribe()` -> `pb.collection('x').subscribe('*', fn)` - Row Level Security -> PocketBase API rules (per collection) - `.select('*, posts(*)')` -> `pb.getList({ expand: 'posts' })` Full guide: https://percher.app/docs/migrate-supabase ## Migrating from Convex Two options: keep your Convex deployment, or switch to Percher's managed PocketBase. Option 1, keep Convex (easiest): ```toml [data] mode = "convex" [data.convex] deployment_url = "https://your-project.convex.cloud" ``` `CONVEX_URL` and `VITE_CONVEX_URL` are injected. Your app must read those names; set `NEXT_PUBLIC_CONVEX_URL` explicitly for Next.js browser clients. Deploy Convex functions through Convex and configure authentication redirects for your final app URL. See the [connection guide](https://percher.app/docs/external-databases#convex). Option 2, switch to PocketBase. Concept mapping: - `useQuery(api.tasks.list)` -> `pb.collection('tasks').getList()` - `useMutation(api.tasks.create)` -> `pb.collection('tasks').create({})` - Convex schema (schema.ts) -> PocketBase collections (admin UI or API) - Convex functions (convex/) -> API routes in your app - Convex auth (Clerk/Auth0) -> PocketBase built-in auth - Convex file storage -> PocketBase file fields - Realtime (automatic) -> `pb.collection('x').subscribe('*', fn)` Key difference: Convex runs server functions in their cloud. With PocketBase your API routes run in your app container, so move Convex functions to Express/Hono/Next.js API routes. Full guide: https://percher.app/docs/migrate-convex ## Migrating from Vercel Most Vercel projects deploy on Percher with minimal changes. Works out of the box: Next.js (Pages and App Router), SvelteKit, Remix, Nuxt, Astro, static sites (Vite/React/Vue), API routes (Express/Hono/Fastify), environment variables, and custom domains (paid plans). Needs changes: - Vercel Serverless Functions -> run as a normal Node server (Express/Hono). Paid plans stay running; Free apps sleep after 20 idle minutes and wake on the next request. - Vercel Edge Functions -> not supported; use standard API routes - Vercel KV / Postgres / Blob -> PocketBase (`mode = "pocketbase"`) or an external service - `vercel.json` rewrites/redirects -> run `percher import` to translate supported rules, then review the generated `percher.toml` - Vercel Cron Jobs -> `[crons]` in percher.toml - Vercel Analytics -> Percher's built-in cookie-free Analytics tab (the app owner needs Starter, Maker or Max), or keep a third-party service if you need event-level product analytics - ISR / on-demand revalidation -> works; Next.js ISR runs in the container Steps: ```sh cd my-vercel-app bunx percher import # imports supported vercel.json settings bunx percher init # creates config if import did not create it # add a health endpoint if missing, e.g. Next.js app/api/health/route.ts bunx percher env set DATABASE_URL=... bunx percher publish bunx percher domains add myapp.com ``` Full guide: https://percher.app/docs/migrate-vercel ## Migrating from Lovable Build in Lovable, host on Percher. Lovable turns a chat into a working Vite + React + Supabase project; Percher gives it a live URL on your own domain with flat-rate hosting. The two layers stay independent — keep iterating in Lovable, redeploy to Percher when you hit a milestone. What carries over (without changes): - Your code — Lovable syncs the project to GitHub as a standard Vite + React app, no proprietary format. - Your Supabase backend — database schema, RLS policies, auth providers, and edge functions stay unchanged. Point Percher at the same project via env vars. - Your Lovable workflow — keep using the chat to iterate; sync to GitHub and `bunx percher publish` picks up the new state. - Existing user accounts — Supabase auth users stay in the same database. Only the React bundle moves. Steps: ```sh # 1. In Lovable: sync your project to GitHub # 2. Clone it locally git clone https://github.com/you/your-lovable-project cd your-lovable-project # 3. Generate percher.toml (init detects Vite + scaffolds Dockerfile + Caddyfile) bunx percher init # 4. Re-set your Supabase env vars (the VITE_ prefix auto-forwards to the build) bunx percher env set VITE_SUPABASE_URL=https://xxx.supabase.co bunx percher env set VITE_SUPABASE_ANON_KEY=eyJhbGc... # 5. Build and deploy bunx percher publish # 6. Custom domain (one DNS update — the CLI prints the records) bunx percher domains add yourdomain.com ``` The Vite project ships as `runtime = "docker"` with a multi-stage build: a slim Bun image compiles the bundle, a slim Caddy image serves it (~30 MB runtime image, automatic SSL via Let's Encrypt). Supabase auth: add your Percher URL to Supabase Authentication -> URL Configuration -> Site URL and Redirect URLs, or OAuth callbacks and email confirmations come back to the old domain. What changes on Percher: - You own the URL — `name.percher.run` right away, or bring your own domain on any paid plan. - Real logs — `bunx percher logs` streams runtime logs; `bunx percher logs --build` shows any deploy's build log. - Rollback — every deploy gets a content hash and stays in history; roll back with one CLI call or one dashboard click. Data is untouched. - Flat cost — no per-GB bill and no token meter; each plan includes a monthly bandwidth allowance. - EU data residency — Percher runs in a single EU region (Finland), with backups in Germany. Note: Percher is hosting for personal and small-scale apps: things you build for yourself, your family, a club or association, or a smaller business. It has no uptime SLA and no formal compliance attestation. For a monetised SaaS or anything with contractual uptime, Lovable's own production hosting or a higher-tier provider is the better fit. **Do I have to stop using Lovable?** No. Keep building in Lovable's chat, sync to GitHub at a milestone, then re-deploy with `bunx percher publish`. **What happens to my Supabase project?** Nothing. Copy `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` from Lovable and re-set them on Percher; the `VITE_` prefix bakes them into the bundle on the next deploy. **What about Supabase auth callbacks and OAuth?** Add your Percher URL to Supabase Authentication -> URL Configuration -> Site URL and Redirect URLs. **Is the migration reversible?** Yes. You keep the GitHub code and the Supabase backend; Percher is hosting with no lock-in. **Does Percher have an AI app builder like Lovable?** No. Pair Lovable (or another AI tool) for building with Percher for hosting. Full guide: https://percher.app/docs/migrate-lovable