# Purgatorium (p9m) > Self-hosted sandboxes for your team and their AI agents Purgatorium (p9m) gives each user of a team their own Docker daemon on demand: a sandbox with Portainer, a public URL for every app, sleep when idle and wake on the next request, archive on delete. A REST API and an MCP server (at /mcp) let coding agents create, wake and deploy to sandboxes. # Getting started # Install No git clone is needed: every file comes from a GitHub release, and the image carries the rest. First check the machine; it changes nothing (give it the `HOST_DOMAIN` you plan to use): ``` curl -fsSL https://github.com/csakaszamok/p9m/releases/latest/download/check-host.sh | bash -s -- dev.example.com ``` Or download it, read it, then run it: ``` curl -fsSLO https://github.com/csakaszamok/p9m/releases/latest/download/check-host.sh less check-host.sh bash check-host.sh dev.example.com ``` Then, on a Linux host with Docker Engine and the compose plugin (Compose 2.17 or newer): ``` mkdir p9m && cd p9m curl -fsSLO https://github.com/csakaszamok/p9m/releases/latest/download/docker-compose.yml curl -fsSL https://github.com/csakaszamok/p9m/releases/latest/download/setup.sh | bash -s -- dev.example.com # .env with your domain and random secrets docker compose up -d ``` The directory must be called `p9m`: the compose project name comes from it. For a given version, use `releases/download/v0.5.0/…` instead of `releases/latest/download/…`. `check-host.sh` checks Docker and Docker Compose, sysbox, the address pools, ports, disk and registry access, and changes nothing (its sysbox check runs one throw-away `alpine` container); with the `HOST_DOMAIN` you plan to use it also checks the DNS records and, if `certs/` has one, the certificate. Lines marked ✘ must be fixed first, ⚠ are worth reading. `setup.sh` works in the current directory: it creates `.env` from its release's `env.example`, sets `HOST_DOMAIN` to the domain you give it and `PUBLIC_URL` to `p9m.` (`https://` if `certs/` already holds a certificate, else `http://`: run it again with the domain after adding one; a `PUBLIC_URL` you set yourself is kept), fills in random secrets, creates `data/`, `opt/`, `certs/` and `dynamic/`, and prints the admin password and token. It keeps values you already set, so it is safe to run again. Without it: download `env.example` of the release as `.env` and set `HOST_DOMAIN`, `PUBLIC_URL` and the three secrets by hand. `docker compose up -d` pulls the released image `ghcr.io/csakaszamok/p9m`. It carries the sandbox templates and runtimes, and the files the other services read: on every `up` the one-shot `p9m-init` service copies the Sablier themes, the error pages, the registry config and `dynamic/errors.yml` from it, so they always match the running version. To work on the code, see [CONTRIBUTING.md](https://github.com/csakaszamok/p9m/blob/main/CONTRIBUTING.md). The stack runs Traefik v3.6 with the Sablier plugin, Sablier, a registry and the Purgatorium API. The UI is at `http://p9m.` (by default `http://p9m.lvh.me`, which resolves to `127.0.0.1`), the API also on port `8081` of the host. An app takes seconds to open the first time? In Chrome, *Settings → Privacy and security → Security → Use secure DNS*: with "your current service provider" each new `*.lvh.me` name can wait for a slow or filtering resolver. Turn it off, or pick a provider such as Cloudflare (1.1.1.1). Each sandbox's files (compose file, certificates, tunnel config) live in `opt/sandboxes///`, its archives in `opt/archive//`; `` is `admin` or the user's sign-in e-mail. > **Give the Docker daemon a larger address pool.** Every sandbox — sleeping ones included — keeps its own compose network until it is archived. Docker's default pools only allow about 30 networks; beyond that sandbox creation fails with `fully subnetted`. Set smaller subnets in the daemon config (`/etc/docker/daemon.json`, or Docker Desktop → Settings → Docker Engine) and restart Docker: > > ``` > { "default-address-pools": [ { "base": "10.10.0.0/16", "size": 28 } ] } > ``` > > A sandbox network needs only a handful of addresses, so `/28` subnets are enough: this allows 4096 networks (`/24` would allow 256). > > **Before exposing Purgatorium publicly**, set `ADMIN_TOKEN`, `SESSION_SECRET` and `ADMIN_PASSWORD` to real secrets. The compose defaults (`ADMIN_TOKEN=abc123`, and the `.env.example` placeholders) are only meant for local dev and the e2e scripts; Purgatorium logs a startup warning if any of them is still in effect. All settings: [Configuration](https://csakaszamok.github.io/p9m/0.12/configuration/index.md). For HTTPS: [HTTPS](https://csakaszamok.github.io/p9m/0.12/https/index.md). # First sandbox ``` # Create a sandbox curl -X POST http://localhost:8081/sandboxes \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name": "alice", "idle_timeout": "30m"}' # Response (abridged) { "name": "p9m-alice", "tunnel_urls": ["alice-inner-http-echo-port5678.lvh.me", "..."], "registry_url": "registry.lvh.me", "registry_username": "alice", "registry_password": "...", "docker_access": { "host": "alice-docker.lvh.me", "state": "ready" }, "extras": {} } ``` Sign in at `http://p9m.` to create sandboxes in the browser or mint a personal access token for your agents. With `"template": "portainer"` the sandbox also gets Portainer, and `extras` has its `portainer_url` and `portainer_password`. # Configuration Main settings in `.env`: | Variable | Default | Description | | --------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ADMIN_TOKEN` | `abc123` (dev only) | Bearer token with admin rights on the API | | `ADMIN_USER`, `ADMIN_PASSWORD` | `admin`, — | The local admin account of the UI | | `SESSION_SECRET` | — | Signs the session cookie; a long random string | | `HOST_DOMAIN` | `lvh.me` | Every address is under it: the UI, the registry, the sandbox apps (needs a wildcard DNS record) | | `HOST_ADDRESS` | `localhost` | The host's address, put into the sandbox TLS certificates and used for the registry token URL when `PUBLIC_URL` is not set | | `PUBLIC_URL` | `http://p9m.` | The address of the UI as users see it; `https://…` turns on the HTTPS checks (see [HTTPS](https://csakaszamok.github.io/p9m/0.12/https/index.md)) | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_PROVIDER_NAME` | — | Sign-in for everyone but the admin (see [Web UI and sign-in](https://csakaszamok.github.io/p9m/0.12/web-ui/index.md)) | | `OIDC_ALLOWED_DOMAINS`, `OIDC_ALLOWED_EMAILS` | — (anyone) | Who may sign in with OIDC, as comma lists: domains (`example.com,partner.org`, the exact domain, not its subdomains) and single addresses (`guest@gmail.com`). Without either, anyone with an account at the provider can sign in, with Google any Google account; Purgatorium says so in its log at startup. Checked at sign-in and on every request: someone taken off the list (after a restart) is signed out and their tokens stop working at once. The admin is never limited | | `APPS_ACCESS_DEFAULT` | `public` | Who opens a sandbox app without a `p9m.access` label: `public`, `domain` (anyone who can sign in to Purgatorium) or `owner` (see [App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)) | | `APP_ACCESS_SECRET` | generated into `data/app-access.key` | Signs the app access cookies and sign-in tickets | | `HUB_ENABLED` | `false` | The [Hub](https://csakaszamok.github.io/p9m/0.12/hub/index.md), the team's published apps at their own address: off unless `true`. | | `HUB_HOST` | `hub.` | The Hub's address. | | `HUB_ALLOWED_DOMAINS`, `HUB_ALLOWED_EMAILS` | (empty) | Who may look at the Hub besides the builders (`OIDC_ALLOWED_*`): viewers, who cannot sign in to Purgatorium itself. Empty: only builders. | | `HUB_TITLE` | `Hub` | Shown in the Hub's header. | | `SANDBOX_QUOTA` | `3` | Sandboxes a user may have running at once; asleep and deep-sleeping ones are not counted; `0` = unlimited | | `SANDBOX_QUOTA_MAX` | (empty) | How many a user may have running while the server has room, above the guaranteed `SANDBOX_QUOTA` (e.g. `10`). Above the guarantee a new sandbox, or one woken, needs room to spare: every other owner must still be able to reach their guarantee (their running sandboxes hold their places already), and so must one newcomer. Waking from plain sleep takes no new place, so only this maximum counts. Nobody is ever put to sleep to make room. Empty: nothing above `SANDBOX_QUOTA`; `0`: no maximum | | `FILES_MAX_UPLOAD` | `100m` | The largest file the **Files** tab (and `PUT /sandboxes/:name/files/content`) takes, like `512m` or `1g`. Every request body is read into memory first, so this (plus 1 MB) is also the cap on any request: a larger one gets 413 | | `SANDBOX_MAX_TOTAL` | `200` | Running sandboxes on the whole server, the admin's included; asleep, deep-sleeping and archived ones take no place, so letting a sandbox sleep frees it for everyone. When it is reached nobody but the admin can create one or wake one: the admin is never refused, but its running sandboxes take places too; `0` = no limit. Each sandbox, asleep too, keeps a Docker network until it goes into deep sleep: the host's Docker address pools must have room for them (see `check-host.sh`) | | `DEFAULT_RUNTIME` | `auto` | Runtime for new sandboxes when a request names none: `auto` picks `sysbox` where the host has sysbox installed (no privileged container) and `dind` otherwise; or name one (see [Runtimes and templates](https://csakaszamok.github.io/p9m/0.12/runtimes-templates/index.md)) | | `ALLOWED_RUNTIMES` | — (all) | Runtimes new sandboxes may use, as a comma list: `sysbox` forbids the privileged `dind` on a shared server. Others are left out of `GET /runtimes` and the **New sandbox** form, and a create asking for one is a 400 (so is one without a runtime when the default is not allowed). Existing sandboxes keep running, sleeping and waking | | `SANDBOX_CPUS`, `SANDBOX_MEMORY` | `2`, `4g` | CPU and memory limit of every new sandbox (see [CPU and memory](https://csakaszamok.github.io/p9m/0.12/resources/#cpu-and-memory)) | | `SANDBOX_MAX_CPUS`, `SANDBOX_MAX_MEMORY` | `4`, `8g` | How far a user can raise their sandbox's limits; the admin can go up to the host | | `SANDBOX_MAX_IDLE_TIMEOUT`, `SANDBOX_MAX_DEEP_SLEEP_AFTER` | — (no limit) | How long a user's sandbox may stay awake without traffic, and asleep before deep sleep, at most (e.g. `2h`, `1d`); with a limit, *never* (`off`) is not allowed either. A template's longer default is cut to it; a sandbox that already has a longer value keeps it until it is changed. The admin is not limited | | `SANDBOX_DISK` | `20g` | Disk use above which a sandbox is flagged; a warning only, see [Disk](https://csakaszamok.github.io/p9m/0.12/resources/#disk) | | `HOST_MOUNT_ROOTS` | (empty: off) | Host folders the admin may mount into a sandbox, as a comma list of absolute paths (e.g. `/srv/shared,/data/projects`): a mount's host path must be one of them or under one (`/srv/shared2` is not under `/srv/shared`). Entries that are not absolute or have `..` are ignored, with a warning at startup; `/` (the whole host) is allowed, with a warning. **A mount gives whoever owns the sandbox that host folder, as root** (read-only if mounted so), and to every app in it. Empty: nobody can add a mount; ones already there stay until the admin removes them. See [Host folders](https://csakaszamok.github.io/p9m/0.12/web-ui/#host-folders) | | `LOGIN_CAPACITY` | `on` | The sign-in page shows the sandbox places on the server: taken and free of `SANDBOX_MAX_TOTAL`, with a bar (orange from 90%, red when full). The page is public, so `off` hides it; nothing is shown without a server limit. Counted at most every 30 seconds | | `GITHUB_STATS` | `on` | The topbar links to the GitHub repo and shows Purgatorium's version, stars and forks; the admin also sees when a newer release is out (one of the running series, else a later series). The server fetches the counts and the releases at most once an hour (users' browsers never contact GitHub, and nothing about the installation is sent); `off` never asks GitHub, and the link and the version stay | | `USER_VIEW_SWITCH` | `false` | `true` gives users the \*\*Simple | | `GITHUB_LINK` | `on` | `off` takes the GitHub card out of the topbar (and GitHub is never asked for the counts) | | `UI_TITLE` | `Purgatorium` | The name at the top left, on the sign-in page and in the browser tab titles; `none` shows no name | | `UI_LOGO` | Purgatorium's logo | The logo next to the name and the browser tab icon, on the Hub too: an `https://` address, or an image file in the container (`.svg`, `.png`, `.jpg`, `.webp`, `.gif`, `.ico`), e.g. `/app/data/logo.png` for `./data/logo.png`, served as `/assets/custom-logo`; `none` shows no logo. Its height is fixed, its width follows the image | | `DEFAULT_TEMPLATE` | `starter` | Template for new sandboxes when a request names none. An old value such as `dind-standard` makes every create without a template fail with `Unknown template` | | `DEEP_SLEEP_CHECK_INTERVAL` | `1m` | How often Purgatorium looks for sandboxes to take into deep sleep | | `HOST_SANDBOXES_DIR` | auto-detected | Host path of `opt/sandboxes` (every sandbox's compose file mounts from it). Set it only if auto-detection fails | | `P9M_API_BIND` | `0.0.0.0` | Interface of the direct API port `8081`; `127.0.0.1` for a public deployment | | `P9M_VERSION` | the release the `docker-compose.yml` came from | The image version to run. Pin it only to stay on a version: an upgrade with a new `docker-compose.yml` then needs it changed too | | `REGISTRY_PUBLIC_PULL` | `true` | Images whose versions all passed the secret scan can be pulled without a login; `false`: tokens only | | `REGISTRY_CACHE_TTL` | `720h` (30 days) | How long the sandboxes' Docker Hub cache (`registry-cache`) keeps an image, counted from its **first** download: pulling it again does not extend it, so an image is downloaded again once its time is up, even in the middle of a workshop. In hours (`168h` = 7 days); `0` keeps everything for ever (the cache only grows: watch the disk). Not in hours (e.g. `7d`): the cache does not start, and the sandboxes pull from Docker Hub directly | # HTTPS Purgatorium serves HTTPS with a certificate you copy in (no automatic issuing): 1. Copy a wildcard certificate for `*.` (ideally also ``) to `certs/tls.crt` (PEM, full chain: certificate + intermediates) and its key to `certs/tls.key` (PEM, unencrypted). `certs/` is git-ignored. 1. Set `PUBLIC_URL=https://p9m.` and strong `ADMIN_TOKEN`, `SESSION_SECRET` and `ADMIN_PASSWORD` in `.env`. 1. `docker compose up -d p9m traefik`. At startup Purgatorium writes `dynamic/tls.yml` (the certificate + an HTTP→HTTPS redirect for every host); Traefik serves the UI, API, registry and every sandbox app on port 443. To renew, overwrite the two files and run `docker compose restart traefik`. Remove them and restart Purgatorium (`docker compose up -d p9m`) to go back to plain HTTP. ## Trying it locally with HTTPS (e.g. on Windows) With `PUBLIC_URL=https://p9m.lvh.me` but no certificate in `certs/`, Traefik answers with its own self-signed one: the browser says *Not secure*, and although you can click through for the pages, it refuses the connections pages open themselves — the browser **Terminal** stays *disconnected*. (`lvh.me` and every `*.lvh.me` resolve to 127.0.0.1, so nothing else is needed.) Make a certificate your machine trusts with [mkcert](https://github.com/FiloSottile/mkcert). In PowerShell: ``` winget install FiloSottile.mkcert # winget changes PATH for new shells only: pick it up in this one $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') mkcert -install # adds a local CA to Windows' trusted roots (confirm the dialog) cd New-Item -ItemType Directory -Force certs | Out-Null mkcert -cert-file certs\tls.crt -key-file certs\tls.key "*.lvh.me" lvh.me docker compose restart p9m traefik ``` Then close the browser completely and open `https://p9m.lvh.me` again. On macOS / Linux the same with `brew install mkcert` / your package manager, and `certs/tls.crt`, `certs/tls.key`. The TLS TCP addresses (`psql`, SSH) use this certificate too, so their *self-signed certificate* warnings go away. Firefox keeps its own trust store: `mkcert -install` covers it when `certutil` (NSS tools) is installed. For a public deployment also set `P9M_API_BIND=127.0.0.1` (the direct plain-HTTP API port 8081 is then only reachable on the host) — with `PUBLIC_URL` set, docker clients get registry tokens from `$PUBLIC_URL/v2/auth`, i.e. over HTTPS. With an `https://` `PUBLIC_URL`, Purgatorium **refuses to start** while `ADMIN_TOKEN`, `SESSION_SECRET` or `ADMIN_PASSWORD` is a default or `.env.example` value (or `SESSION_SECRET` is unset). A running sandbox whose address no router matches shows "This address does not reach the sandbox" instead of an endless waiting page. # Tutorials # Tutorials Step by step, from nothing to an app with a link you can send to anyone. No server knowledge needed: your coding agent does the technical part. - **[Put your app online with Claude Code](https://csakaszamok.github.io/p9m/0.12/tutorials/claude-code/index.md)**: install the p9m plugin once, approve each project in the browser, then say *deploy this app*. The easiest way. - **[Put your app online with any agent](https://csakaszamok.github.io/p9m/0.12/tutorials/other-agents/index.md)**: Codex, Cursor, or Claude Code without the plugin, with a zip from Purgatorium. - **[Share your app with your team on the Hub](https://csakaszamok.github.io/p9m/0.12/tutorials/hub/index.md)**: list it where colleagues find, open and vote for it. You need a Purgatorium account: the address your team uses (for example `https://p9m.example.com`) and a sign-in. Someone in your team set Purgatorium up; to set up your own, see [Install](https://csakaszamok.github.io/p9m/0.12/install/index.md). # Put your app online with Claude Code You have an app on your computer (a website, a dashboard, a small tool) and you want a link to it that works for anyone. Claude Code builds and runs it in your Purgatorium sandbox and gives you that link. **What you need:** Claude Code (in a terminal, or the **Code** tab of the Claude desktop app), [Docker](https://docs.docker.com/get-docker/) installed on your computer, and a Purgatorium account. ## 1. Make a sandbox 1. Open your Purgatorium address in the browser and sign in. 1. Click **+ New sandbox**, give it a short name (for example `shop`), keep the template and click **Create**. It takes 10–30 seconds. A sandbox is your own small server: your app runs there, not on your computer. Simple or Advanced The switch at the top right changes how much the screen shows. **Simple** is enough for everything here. If you see no switch, you are in Simple: the server lets only the admin switch. ## 2. Install the p9m plugin in Claude Code (once) 1. In Purgatorium, click your sandbox, then **Let your agent deploy…**. 1. In the window's step **1**, click **Copy** next to each of the two lines, open a terminal (in VS Code: **Terminal → New Terminal**; on Windows also PowerShell, on a Mac the Terminal app), paste them one after the other and press Enter after each: ``` claude plugin marketplace add csakaszamok/p9m claude plugin install p9m@p9m ``` It installs the plugin for you, in all your projects. It asks for no address and no token. Good to know - `claude` not found? Claude Code is not installed for the terminal: see [Claude Code's setup](https://code.claude.com/docs/en/setup). An *unknown option* means an older Claude Code: run `claude update`. - Already in Claude Code? **In Claude Code instead** in the same window shows the same two lines as `/plugin marketplace add csakaszamok/p9m` and `/plugin install p9m@p9m`. (The Claude Code panel in VS Code has no `/plugin`: use the terminal.) You install the plugin once. Each project then gets its sandbox in the next step. ## 3. Connect your project folder to the sandbox (once per folder) Claude Code needs to know which sandbox belongs to which folder. You tell it once per folder; after that it remembers. 1. Open your app's folder in Claude Code: start `claude` in that folder, or open the folder in VS Code or the desktop app. 1. In Purgatorium, in the **Let your agent deploy…** window, step **2**, click **Copy**. Paste the line into Claude Code and press Enter. It looks like this, with your Purgatorium's address and your sandbox's name: ``` /p9m:init https://p9m.example.com shop ``` 1. Claude Code answers with a link and a code, for example: > Open **https://p9m.example.com/activate?code=TGMH-BSGQ** and approve the code **TGMH-BSGQ**. 1. Open the link in your browser (sign in if Purgatorium asks). The page shows the code and the sandbox. Check that the code is the same one Claude Code printed, then click **Approve**. 1. Go back to Claude Code. Within a few seconds it says the folder is connected to the sandbox. That is all. Approve only your own code Approve a code only if you just ran `/p9m:init` yourself and Claude Code shows that same code. If someone sends you a link like this, do not approve it: whoever started that code would get into your sandbox. Click **Deny** instead. What happened behind the scenes: Claude Code got a token for this one sandbox and the sandbox's Docker keys, and wrote them into a `.p9m` folder in your project. You never see or copy them. The folder is secret, and it keeps itself out of git on its own: leave it there. The token works for 90 days (then run `/p9m:init` again). You can revoke it in Purgatorium: Advanced → **Access tokens**, where it is named `Claude Code init `. If something goes wrong - **The code ran out:** it works for 10 minutes and only once. Run the same `/p9m:init` line again for a new code. - **You clicked Deny by mistake:** run `/p9m:init` again. - **The button says *Enable Docker access and approve*:** the sandbox does not let Docker in yet. Click it: the sandbox restarts (about 30 seconds) and Claude Code carries on by itself. - **The folder was connected to another sandbox already:** Claude Code asks before it replaces that connection. - **A wrong sandbox in the list:** if the line names a sandbox that is not yours, the page lets you pick one of your own sandboxes instead. ## 4. Deploy In the same folder, say in your own words: > Deploy this app. Claude Code wakes the sandbox, builds your app there, starts it and checks that it answers. At the end it gives you a link like `https://shop-web.example.com` (your sandbox's name first). Open it: that is your app, and it works for anyone you send it to. ## 5. Change and update Changed something? Say: > Update the app. The same link shows the new version a few seconds later. ## Good to know - **Sleep:** when nobody uses the app for a while (30 minutes by default), the sandbox sleeps to free the server. The next visit wakes it in a few seconds: the visitor sees *waking up* and then your app. Nothing is lost. - **Sleep later:** *let it sleep only after 2 hours without visitors* works too, or set it in Purgatorium: the sandbox → **Details** → **Sleep** → **Change…** (*never* is there as well). - **It does not answer?** Ask Claude Code *why does my app not answer?*: it reads the app's logs in the sandbox. The usual cause is an app that listens only on `localhost`; Claude Code fixes that. - **Only for you or your colleagues?** Say *make it visible only to me* (or *only to my colleagues*): Claude Code sets `p9m.access` ([App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)), and opening the link then asks the visitor to sign in to Purgatorium. - **Docker is needed on your computer** only for its command line: the build runs in the sandbox. ## More projects Each folder is tied to one sandbox by its own `.p9m` folder, so different folders can use different sandboxes, even on different Purgatorium servers. For another folder, do step 3 there: in Purgatorium, on that folder's sandbox, **Let your agent deploy…**, copy the `/p9m:init` line and paste it into Claude Code in that folder. The plugin needs no new setup. To move a folder to another sandbox, run that sandbox's `/p9m:init` line in the folder: Claude Code asks, then replaces the connection. Used the earlier plugin, which asked for an address and a token? Run `/plugin marketplace update p9m` once, then `/p9m:init` in each project. More details: [Claude Code plugin](https://csakaszamok.github.io/p9m/0.12/claude-plugin/index.md). # Put your app online with any agent For Codex, Cursor, Claude Code without the plugin, or any agent that can run commands on your computer. You download a small zip from Purgatorium; the agent uses it to build and run your app in your sandbox, and gives you its link. **What you need:** the agent, [Docker](https://docs.docker.com/get-docker/) installed on your computer, and a Purgatorium account with a sandbox (**+ New sandbox** in Purgatorium; see [step 1 of the Claude Code tutorial](https://csakaszamok.github.io/p9m/0.12/tutorials/claude-code/#1-make-a-sandbox)). ## 1. Download the zip In Purgatorium, click your sandbox, then **Let your agent deploy…** → **Download**; in the Advanced view the same window also has **Options** (the token's name and expiry, a zip without the Docker keys, **Copy as .env**). You get `p9m-.zip`. It holds a token for this sandbox and its keys: treat it like a password. Do not email it or commit it. ## 2. Hand it to the agent Open your app's folder with the agent and say: > Set up the p9m zip from my Downloads in this project and deploy. The agent unpacks it into a `.p9m` folder in your project, deletes the zip, builds your app in the sandbox, starts it and gives you its link. The `.p9m` folder keeps itself out of git on its own (it has its own `.gitignore`); leave it there, the agent uses it for every later update. If the agent does not know what to do, point it to the instructions inside: *read .p9m/README.md and deploy this app to my sandbox*. ## 3. Update Changed something? *Update the app in my sandbox.* The same link shows the new version. ## Good to know - The zip's token expires in 90 days (in the Advanced view you can choose 30 days or never). Download a new zip then. - Lost the zip, or someone else got it? In Purgatorium (Advanced view): the sandbox → **Access** → **Rotate Docker keys…** makes the old keys useless, and **Access tokens** lets you revoke its token. - Sleep and wake work the same as with Claude Code: see [Good to know](https://csakaszamok.github.io/p9m/0.12/tutorials/claude-code/#good-to-know) there. For agent builders: everything the agent does is in the [agent guide](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md). # Share your app with your team on the Hub The Hub is your team's showcase: a page at `https://hub.` where colleagues find the apps you published, open them with one click, and vote for the ones they like. Colleagues who only use apps can sign in to the Hub alone. **What you need:** an app already online in your sandbox (see [Put your app online with Claude Code](https://csakaszamok.github.io/p9m/0.12/tutorials/claude-code/index.md)), and the Hub turned on in your Purgatorium (ask whoever runs it; it is `HUB_ENABLED=true`). ## 1. Publish Tell your agent: > Publish the web app on the Hub, titled "Stock room", described as "Who took what from the stock room". It adds labels to the app's service in its compose file and deploys again: ``` services: web: labels: p9m.publish: "true" p9m.title: "Stock room" p9m.description: "Who took what from the stock room" ``` Within about a minute and a half the app is on the Hub. In Purgatorium its **Apps** tab marks it **On the Hub**. ## 2. A picture on its card > Use /screenshot.png as its Hub image. That adds `p9m.image: "/screenshot.png"`: a picture the app itself serves (PNG, JPEG, WebP or GIF, up to 2 MB). Without it the Hub uses the page's own preview image, if it has one, or the title's first letter. ## 3. Share Send colleagues the Hub's address. They see whether your app is running or asleep; **Open** wakes it if it sleeps. Everyone signed in can vote once for each app, yours too. To take it off the Hub: *remove the app from the Hub* (the agent removes `p9m.publish`), or archive the sandbox. All labels and settings: [Hub](https://csakaszamok.github.io/p9m/0.12/hub/index.md). # Using Purgatorium # Web UI and sign-in Purgatorium has a small web UI (`http://p9m.`) for creating and managing sandboxes without the API directly, alongside the REST API. Two ways to sign in: - **Admin**: a local account, set via `ADMIN_USER` / `ADMIN_PASSWORD`. Signs in with the "Administrator sign-in" form and sees every sandbox (My sandboxes + All sandboxes). - **Everyone else**: OIDC (Google or any OpenID Connect provider). Configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and register the redirect URI `$PUBLIC_URL/auth/callback` with the provider — character for character (scheme, no trailing slash), under Google's *Authorized redirect URIs*, otherwise sign-in fails with `redirect_uri_mismatch`. Google accepts `http://` redirect URIs only for `localhost`, so with Google use an `https://` `PUBLIC_URL` (locally: a self-signed `*.lvh.me` certificate in `certs/` and `PUBLIC_URL=https://p9m.lvh.me`, see [HTTPS](https://csakaszamok.github.io/p9m/0.12/https/index.md)). `OIDC_PROVIDER_NAME` labels the sign-in button (default `Google`). Signed-in users only see their own sandboxes. `SESSION_SECRET` signs the session cookie — set it to a long random string; sessions issued under an old value stop working if it changes. Anyone with an account at the OIDC provider can sign in and create sandboxes, unless `OIDC_ALLOWED_DOMAINS` or `OIDC_ALLOWED_EMAILS` limits it (e.g. to `example.com`): with Google, set one of them, or any Google account gets in. An account not on the list is told so on the sign-in page; one taken off the list is signed out, and its tokens stop working, once Purgatorium restarts with the new list. Each user may have `SANDBOX_QUOTA` sandboxes running at once (default 3, `0` = unlimited), guaranteed, and with `SANDBOX_QUOTA_MAX` more while the server has room to spare for the others' guarantees (*Running 4 / 3 · up to 10* beside **New sandbox**); asleep, deep-sleeping and archived ones are not counted, so letting a sandbox sleep frees its place, and the admin is not limited. At the limit a user can neither create a sandbox nor wake one, from sleep or deep sleep: its address shows *Running limit reached*, its address shows *Running limit reached* with the running sandboxes, and **Wake** in the panel lets the owner put one to sleep instead (`POST /sandboxes/:name/start` with `{"sleep": ""}`). Purgatorium never puts a sandbox to sleep on its own. Running sandboxes' web addresses do not go through Purgatorium: they keep working while it is down; waking needs it, and so do the TLS TCP addresses and Docker access (they go through Purgatorium's TCP gateway). `SANDBOX_MAX_TOTAL` (default 200) caps the running sandboxes on the whole server, the admin's included (asleep ones take no place): when it is reached nobody but the admin can create one or wake one (*The server is full*). The sign-in page shows the sandbox places above the sign-in buttons (taken and free; `LOGIN_CAPACITY=off` hides them), and once signed in the top bar does, on every page (e.g. *142 / 200 places*, the free ones on hover; orange from 90%, red when full). The **Overview** page shows the counts as cards: running of how many allowed, asleep, deep sleep and archived; for the admin the sandbox places used against the server limit (the admin's own sandboxes take places too), the host's CPU and memory (used now and reserved by running sandboxes), the disk use, a table per owner and a **Users** table. Users lists every account that has signed in (by e-mail; `admin` for the local admin): when it was last seen (a page load, at most every 10 minutes), its last sign-in and how many, its sandboxes now and created, and its last API use (its tokens); *no sandbox yet* marks someone who signed in but never created one. The rows are kept in `data/user-stats.json` until the admin deletes one (**Delete**: only the row; the account and its sandboxes stay). Only the admin sees them (`GET /users`, `DELETE /users/:account`). From **Access tokens** (`/settings/tokens`), a signed-in user can mint a personal access token (`p9m_…`) for their own agents to use as `Authorization: Bearer p9m_…`; the token acts as that user for the API. The full token is shown once, at creation. Tokens can be set to expire in 30 days, 90 days, or never, and can be revoked at any time. A token can also be limited to one sandbox (**Access** in the dialog, or **Token for this sandbox…** on a sandbox panel's **Access** tab). Such a token lists and opens only that sandbox, and can start, stop and restart it and change its sleep times; it cannot create or delete sandboxes or manage tokens (403), and any other sandbox answers 404. Deleting the sandbox leaves the token in place: if you restore the sandbox, it works again. With such a token an agent can deploy and update apps in the user's sandbox: the Purgatorium API gives it the sandbox's Docker client certificates, and the `docker` command line reaches the sandbox's own Docker daemon (or, in a sandbox from the `portainer` template, the Portainer API). Step by step: [agent guide](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md). ## Simple and Advanced The top bar has a **Simple | Advanced** switch, remembered for each account (users start in Simple, the admin in Advanced). Users have it only with `USER_VIEW_SWITCH=true`; by default they stay in Simple, see [Configuration](https://csakaszamok.github.io/p9m/0.12/configuration/index.md). Simple is for people who only want their app online: the menu shows *My sandboxes* only; the list shows the name, status, *Sleeps in* and age; a new sandbox asks for a name and a template; a sandbox panel has **Wake** or **Sleep**, an **Apps** tab (the web address of each app and **Let your agent deploy…**: the Claude Code plugin or a zip for other agents, the same window as in Advanced, which adds its **Options**) and a **Details** tab (when it last ran, the sleep times, warnings, archiving). Advanced shows everything: Overview, tokens, SSH keys, the Terminal and Logs, and the Access, Files, Resources and Registry tabs. Simple only hides: what an account may do is the same in both, and the API and MCP do not change. `PUT /me/view` with `{"view": "simple"}` or `{"view": "advanced"}` sets it (403 for a user without `USER_VIEW_SWITCH=true`); `GET /me` returns it as `view`, and whether the account may switch as `view_switch`. ## Host folders The admin can mount folders of the host into a sandbox, e.g. a shared data folder, when `HOST_MOUNT_ROOTS` lists where they may come from (e.g. `HOST_MOUNT_ROOTS=/srv/shared`; see [Configuration](https://csakaszamok.github.io/p9m/0.12/configuration/index.md)). On a sandbox panel's **Resources** tab, **Host folders** lists them, with **Remove** for each, and a form to add one: the host folder (at or under one of the roots), where it appears in the sandbox (empty: `/mnt/`) and **read-only**. The folder is bind-mounted into the sandbox container, so the apps in the sandbox's own Docker can bind it from there too (e.g. `/mnt/shared:/data`). Adding or removing one recreates the sandbox container, after a confirmation: a running sandbox restarts (its apps are unreachable for a few seconds), an asleep one stays asleep, one in deep sleep gets the change when it wakes. A host folder that does not exist is not created: Docker refuses it, the panel shows Docker's message and nothing changes. The target cannot be `/`, a system directory (`/etc`, `/usr`, `/proc`, `/dev`, `/run`…) or on, above or under one of the sandbox's own mounts (`/var/lib/docker`, `/opt`, `/root`, `/home`, `/srv`, …). The mounts are kept in the sandbox's compose file (and its `p9m.mounts` label), so they survive sleep, deep sleep and upgrades. **A mount gives whoever owns the sandbox that host folder**, as root, and every app and agent in it: mount read-only what they need not change, and only what they may see. The owner sees the list on the **Resources** tab, in Simple on **Details** (read-only); only the admin adds or removes them. With `HOST_MOUNT_ROOTS` unset no mount can be added; ones already there stay until the admin removes them. API: `GET`, `POST` and `DELETE /sandboxes/:name/mounts` (see [API](https://csakaszamok.github.io/p9m/0.12/api/index.md)), over MCP `list_sandbox_mounts`, `add_sandbox_mount` and `remove_sandbox_mount`. # Runtimes and templates A sandbox is a **runtime** (how it runs) plus a **template** (what runs inside), picked separately on create (`runtime`, `template`; the **New sandbox** form has a select for each): | Runtime | Isolation | Use | | -------- | ------------------------------------------ | --------------------------------------------------------------------------------- | | `dind` | `privileged: true` | development, any Docker host (incl. Docker Desktop / WSL) | | `sysbox` | `runtime: sysbox-runc`, no privileged mode | production; requires [sysbox](https://github.com/nestybox/sysbox) on a Linux host | | Template | What runs inside | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `starter` (default) | a hello app; deploy yours with `docker`, SSH or an agent | | `portainer` | the same with Portainer, a web UI for the sandbox's Docker (its address and password in the API and the panel; it runs with `-H unix:///var/run/docker.sock`, so its environment exists from the start) | | `tcp-demo` | a demo of [TLS TCP addresses](https://csakaszamok.github.io/p9m/0.12/networking/#tls-tcp-addresses): Postgres, Redis, SSH and pgweb | | `empty` | nothing: paste your own compose file in the dialog | Both runtimes have the same wiring (FRP tunnel, Traefik route, Sablier sleep/wake, `docker_data` volume, archive on delete). The sysbox image runs plain `dockerd`, so its TLS listener is configured through the runtime's `daemon_json` field, which Purgatorium merges into the generated `daemon.json`. Both run in production. On a shared server use `sysbox` only: install it on the host and set `ALLOWED_RUNTIMES=sysbox` (see [Security](https://csakaszamok.github.io/p9m/0.12/security/index.md)). **Runtimes** are `runtimes/.yaml`: a `description`, the sandbox's own `docker_compose` (the DinD container, frps, socat, and their Traefik and Sablier labels) and an optional `daemon_json`. **Templates** are directories, `templates//`: - `compose.yaml` (required): the stack deployed into the sandbox's own Docker on creation, a plain compose file. - `template.yaml` (optional, every field optional): `description` (default: the directory name), `before_script` (JavaScript run on create; what it returns becomes `${variables}` for both compose files and the sandbox's `extras`, e.g. a generated password), `idle_timeout` (Go duration, default `30m`: how long a sandbox may go without HTTP traffic before it sleeps; a create request can override it), `deep_sleep_after` (default `7d`), `inner_project_name` (default `inner`). A new template is a new directory; it shows up in `GET /templates` and the form without a restart. In both files Purgatorium fills in `${name}`, `${raw_name}`, `${host_domain}`, `${template_name}`, `${runtime_name}` and whatever `before_script` returned; any other `${…}` is left for docker compose (write `$$` for a literal `$`). Publish a port on all interfaces (`"5432:5432"`) to give it a link and a [TLS TCP address](https://csakaszamok.github.io/p9m/0.12/networking/#tls-tcp-addresses); a port on `127.0.0.1` stays private to the sandbox; a `p9m.connect.user` label puts its login into the **Connect…** commands. Pass passwords through variables whose name contains `PASSWORD` (or `TOKEN`, `SECRET`, `KEY`): **Starter stack…** masks those. The **New sandbox** dialog shows the chosen template's `compose.yaml`; edit it there, or pick `empty` and paste your own (`empty` without a compose text is refused). The sandbox then gets that text (comments kept, the same variables filled in, the template's `before_script` still run) and shows "(edited)" in the list and its panel. The text is checked first: it must be YAML with at least one service, at most 256 KB, that docker compose accepts. Keys that would make the compose CLI (which runs in the Purgatorium container) read the server's files are refused: `build`, `env_file`, `label_file`, `extends.file`, `include`, and `secrets`/`configs` from a `file` or `environment` — build images through `DOCKER_HOST` or Portainer instead. The CLI gets no Purgatorium settings in its environment, so an unknown `${VAR}` is empty. # Sleep and wake A sandbox is **running**, **asleep** (containers stopped, data intact), in **deep sleep** (containers and network removed, data intact) or **archived** (after an explicit delete). There is no fixed-time expiry: a sandbox only sleeps when nobody uses it, and only a person deleting it removes it. Handled entirely by [Sablier](https://github.com/sablierapp/sablier). The sandbox's outer containers (DinD, frps, socat) form one Sablier group and its Traefik router carries the Sablier middleware. After `idle_timeout` without HTTP traffic the group is stopped — which stops everything inside the sandbox too. The sandbox's router exists only while it runs, so the next request reaches Purgatorium's catch-all `/wake`, which wakes it within the owner's limits (see [Web UI and sign-in](https://csakaszamok.github.io/p9m/0.12/web-ui/index.md)) and shows a waiting page until the router is back; the socat healthcheck only passes once the FRP tunnel is online (an app's HTTP proxy, or for a stack that publishes no port, frpc's readiness marker for 15 seconds). Sablier still measures the idle time and puts the sandbox to sleep. **Reset** next to *Sleeps in* in the panel (`POST /sandboxes/:name/keep-awake`) starts the countdown over, as a visit would; it does not wake a sleeping sandbox. > **Apps must set a restart policy to come back after sleep.** When the sandbox wakes, its inner Docker daemon only restarts containers with `restart: unless-stopped` or `restart: always`. Without one, the app stays stopped and has to be started by hand (`docker start`, or from Portainer): > > ``` > services: > web: > image: my-app > restart: unless-stopped > db: > image: postgres:16 > restart: unless-stopped > ``` HTTP traffic through Traefik counts as activity, and so does traffic on a Docker connection (`DOCKER_HOST=tcp://-docker.:443`): Purgatorium renews the session once a minute while bytes go through it. A Docker connection that is only open does not count, and a Docker connection never wakes a sleeping sandbox: Docker Desktop keeps every context connected for its Builds view and reconnects at once, so it would wake the sandbox each time it fell asleep. Wake it first (**Wake**, `POST /sandboxes/:name/start`, or open one of its apps); until then `docker` cannot connect. Sablier persists its sessions (`--storage.file`), so restarting Sablier does not put running sandboxes to sleep. A sandbox started without Purgatorium (Docker restarting it, `docker compose start` by hand, or Purgatorium restarted while it was starting one) has no session at first; Purgatorium notices within two minutes and opens one, so it still sleeps after its `idle_timeout`. After a host reboot, sandboxes that were asleep stay asleep. The sandbox list (`GET /sandboxes` adds `stops_at`, `deep_sleep_at` and `deep_sleep_after` to each row) and the details panel show **Sleeps in** (time until Sablier stops a running sandbox; every request resets it — read from Sablier's `/metrics`, which Purgatorium reaches on `sablier-net`) and **Deep sleep in** (time until an asleep sandbox is taken down). Both times can be changed after creation — **Change…** next to *Sleep* on the panel's **Details** tab, or `PATCH /sandboxes/` with `{"idle_timeout": "45m", "deep_sleep_after": "14d"}` (either field; same formats as at creation; owner or admin). The values are written into the sandbox's `docker-compose.yml`. A new `idle_timeout` recreates only the socat container, which carries the Sablier middleware labels: a running sandbox's apps are unreachable for a few seconds, an asleep one stays asleep, and the DinD container and its inner stack are not touched; it applies from the next request's Sablier session. A new `deep_sleep_after` applies immediately — Purgatorium reads it from the compose file. `idle_timeout` takes seconds, minutes, hours or days (`30s`, `30m`, `2h`, `1d`); days are kept as hours (`1d` becomes `24h`), because Sablier's durations have no days. `deep_sleep_after` takes `m`, `h` or `d`. The server can set a ceiling for users' sandboxes: `SANDBOX_MAX_IDLE_TIMEOUT` and `SANDBOX_MAX_DEEP_SLEEP_AFTER` ([Configuration](https://csakaszamok.github.io/p9m/0.12/configuration/index.md)); a value above it, or never, is refused (*at most 2h on this server*). `0` or `off` means never, for both — at creation and when changing them. `deep_sleep_after: off` never takes the sandbox down. Sablier has no "never", so `idle_timeout: off` is written as a 10-year session (`87600h`): the sandbox keeps running, and if it is stopped by hand the next request still wakes it. The panel and the list show "never" for either. # CPU, memory and disk ## CPU and memory Every sandbox runs with a CPU and memory limit: the sandbox and everything inside it share it (the inner containers live in the sandbox container's cgroup). New sandboxes get `SANDBOX_CPUS` / `SANDBOX_MEMORY` (default 2 CPUs, 4 GB); on create (**Advanced**) or later (**Resources…** in the panel, `PATCH /sandboxes/:name` with `cpus`, `memory`) a user can raise their sandbox's limits up to `SANDBOX_MAX_CPUS` / `SANDBOX_MAX_MEMORY` (default 4 and 8 GB), the admin up to the host's size. A change applies at once without a restart (`docker update`) and is kept in the sandbox's compose file. Memory below what a running sandbox uses now is refused (stop it first). There is no swap beyond the memory limit. The panel shows the sandbox's current CPU and memory against its limits (sampled every 10 s), and the list a short *CPU · Mem* column for running sandboxes. The API gives `limits` (`cpus`, `memory` in bytes) and, for a running sandbox, `usage` (`cpu` in cores, `memory`, `memory_limit`). ## Disk Everything a sandbox keeps (its inner images, containers and volumes) is in its own `docker_data` volume. Purgatorium measures it from outside with `du`: a running sandbox every 15 minutes, an asleep one once after it stopped (its size does not change while it sleeps); the results are kept in `data/disk-usage.json`. The **Resources** tab shows the use against the sandbox's disk limit, `SANDBOX_DISK` (20 GB by default); the admin can set another one per sandbox (**Change limits…**, or `PATCH /sandboxes/:name` with `disk`). Above the limit the panel and the sandbox list show ⚠ and suggest cleaning up (`docker system prune` in the sandbox). It is **only a warning**: nothing is stopped or refused, and a sandbox can use more until the host's disk is full. A hard limit (a fixed-size disk per sandbox) is on the [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). # Deep sleep A sandbox that has been asleep for `deep_sleep_after` (template field, default `7d`, `off` disables; overridable per sandbox on create) is taken down with `compose down`: its containers and its network are removed, its volumes and `opt/sandboxes///` stay. The volumes hold everything a user keeps: the sandbox's inner Docker (`docker_data`: images, inner containers and their volumes) and the sandbox's own `/opt`, `/root`, `/home` and `/srv`, so nothing written there is lost when the container is recreated (deep sleep, or a container removed by hand). Anything else in the sandbox container's file system (e.g. packages installed with `apk add`) is not kept. This frees Docker networks, which are limited. Purgatorium checks every `DEEP_SLEEP_CHECK_INTERVAL` (default `1m`). The panel's **Details** tab says when a sandbox that is asleep or in deep sleep last ran (*Last ran: 3 weeks ago*; `last_running_at` in `GET /sandboxes/:name`), and so do its apps on the [Hub](https://csakaszamok.github.io/p9m/0.12/hub/index.md). Purgatorium keeps that time when it takes the sandbox down; for one taken down before it did, the time is an estimate, marked `~`. Requests for a deep-sleeping sandbox have no sandbox router, so they reach Purgatorium's catch-all `/wake`, which rebuilds the stack (`compose up`) and shows a waiting page that updates until the app answers — usually 20–60 s. From then on Sablier handles it as usual. The API reports such sandboxes with `status: "deep_sleep"`, and `POST /sandboxes/:name/start` (**Wake** in the UI) brings them up. **Deep sleep now** on the panel's **Details** tab (`POST /sandboxes/:name/deep-sleep`) takes a sandbox down right away, without waiting for `deep_sleep_after`. An asleep sandbox whose containers point at a network that no longer exists is taken down the same way at the next check, whatever its `deep_sleep_after`. This happens after `docker compose down` on the Purgatorium stack, which removes and re-creates `traefik-net`: without it, Sablier could never start the sandbox again (exit code 128, "network … not found") and its waiting page would spin forever. The next request rebuilds it on the current networks; its data volume is kept. # Delete = archive `DELETE /sandboxes/:name` never discards data. It: 1. stops the sandbox; 1. writes every Docker volume of the sandbox's compose project to `opt/archive//-/volumes/.tar.gz` (the DinD data volume holds all inner images, containers and volumes); 1. copies `opt/sandboxes///` (compose files, certs, credentials) to `.../config/` and writes `manifest.json` (with the owner); 1. only then removes the containers, the volumes and `opt/sandboxes///` (and the owner's directory once it is empty). If any archive step fails nothing is removed and the call returns 500. Afterwards the name is free again and the sandbox URL returns 404. Archives are never pruned automatically; expect a few hundred MB per sandbox, more with large images. Restoring by hand: ``` A=opt/archive/alice@example.com/p9m-alice-2026-09-26T21-47-01-401Z mkdir -p opt/sandboxes/alice@example.com cp -r $A/config opt/sandboxes/alice@example.com/p9m-alice for f in $A/volumes/*.tar.gz; do v=$(basename $f .tar.gz) docker volume create --label com.docker.compose.project=p9m-alice $v docker run --rm -v $v:/data -v "$PWD/$A/volumes":/archive alpine tar xzf /archive/$v.tar.gz -C /data done docker compose -f opt/sandboxes/alice@example.com/p9m-alice/docker-compose.yml up -d ``` # Isolation and TCP addresses ## Isolation between sandboxes An app in one sandbox must not reach another sandbox's apps. Today: - inner containers' private addresses are only reachable inside their own sandbox; - Sablier's API is on its own network (`sablier-net`) shared only with Traefik; - a port an inner container *publishes* on all interfaces (`9000:9000`) gets a link (its tunnel) and a TLS TCP address, and other sandboxes can also reach it directly on its `traefik-net` address; a port published on `127.0.0.1` (`127.0.0.1:9000:9000`) is private to the sandbox: no link, no TCP address, no neighbour. A link whose app does not answer is marked ⚠ in the panel — usually the app listens on 127.0.0.1 inside its container; make it listen on 0.0.0.0. The Purgatorium API itself requires authentication (see [Web UI and sign-in](https://csakaszamok.github.io/p9m/0.12/web-ui/index.md)). Two endpoints of each sandbox are not published on the host but are reachable from other sandboxes over `traefik-net`: its Docker API (protected by per-sandbox TLS client certs) and its frps API (`:7500`, which lists the sandbox's proxies; it has a per-sandbox password, kept in the sandbox's `frps.toml`, so a neighbour gets 401). Closing published ports to neighbours is on the [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). Who may open an app's **web address** can be limited per app with `p9m.access` ([App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)); that applies to HTTP through Purgatorium only, not to the TCP addresses below, nor to the direct `traefik-net` path from other sandboxes. ## App web addresses Each TCP port an inner container publishes on all interfaces gets a web address, `---port.` (`shop-inner-web-port8080.`). A label on the container gives it a short one instead: | Label | What it does | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `p9m.subdomain: web` | The app's address becomes `-web.`. No other label does (`p7y.subdomain` and `frpc.subdomain` are ignored). `shell` gets no TCP address: that name is the sandbox's own SSH ([Shell and logs](https://csakaszamok.github.io/p9m/0.12/shell-and-logs/index.md)). | | `p9m.access` | Who may open the address ([App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)). | | `p9m.connect.user` | The login the **Connect…** recipes use (below). | | `p9m.publish`, `p9m.title`, `p9m.description`, `p9m.image` | Lists the app on the [Hub](https://csakaszamok.github.io/p9m/0.12/hub/index.md). | ``` services: web: build: . ports: ["8080:8080"] labels: p9m.subdomain: web ``` A container with `p9m.subdomain` that publishes several ports has one web address for all of them; each port's TCP address (below) gets `-port` too. ## TLS TCP addresses A port an inner container publishes on all interfaces (`"5432:5432"`) also gets a TCP address, its HTTP name plus `-tcp`, on port 443, TLS only: `shop-inner-db-port5432-tcp.:443`. Ports published on `127.0.0.1` stay private: no TCP address and no link. - Postgres: `psql "host=shop-inner-db-port5432-tcp. port=443 sslmode=require …"` - Any client that speaks TLS (Redis `--tls`, MQTTS): connect with that name as SNI. - SSH through TLS: `ProxyCommand openssl s_client -quiet -connect %h:443 -servername %h`. Traefik passes these names through to Purgatorium's TCP gateway, which terminates TLS (with `certs/tls.crt`, or a self-signed `*.` kept in `data/`), wakes the sandbox if needed and connects to the sandbox container over `traefik-net`. There is no extra authentication: the service's own login protects it. Only web traffic keeps a sandbox awake: without it the sandbox sleeps after its idle timeout even with an open TCP connection, which then drops; the next connection wakes it. Right after a wake the service itself may still be starting (Postgres answers `the database system is starting up`), so let the client retry. The details panel lists the addresses with ready-made commands (**Connect…**), picked by the container's own port (5432 Postgres, 6379 Redis, 22 or 2222 SSH; anything else gets the generic TLS recipe). A `p9m.connect.user` label on the container puts its login into those commands. The API gives the same as `tcp_addresses` (`address`, `port`, `user`) next to `tcp_urls`. The `tcp-demo` template (on either runtime) shows it end to end. Its starter stack has a Postgres with a sample `orders` table (`-db-tcp`), a Redis (`-redis-tcp`), an SSH server with the user `demo` (`-ssh-tcp`) and pgweb in the browser (`https://-pgweb.`, user `demo`), all behind one generated password (**Demo password** in the details panel and in **Connect…**). Insert a row with psql and it shows up in pgweb: ``` psql "host=-db-tcp. port=443 sslmode=require user=postgres dbname=postgres" -c "insert into orders (customer, item, amount) values ('me', 'espresso', 3)" redis-cli -h -redis-tcp. -p 443 --tls --sni -redis-tcp. -a ping # add --insecure with the self-signed certificate ssh -o ProxyCommand="openssl s_client -quiet -connect %h:443 -servername %h" demo@-ssh-tcp. ``` The three TCP containers also get an HTTP link in **Apps** (frpc tunnels every published port); those links do not lead anywhere useful. # SSH, terminal and logs ## SSH into a sandbox Every sandbox runs an sshd: root, keys only, at `-shell-tcp.:443` (the same TLS gateway as the TCP addresses). Add your public keys under **SSH keys** (`/settings/ssh-keys`, API `GET/POST/DELETE /ssh-keys`): they let you into all your sandboxes, and a change takes effect at once. A create request can add keys for that sandbox only (`ssh_keys`, or **Advanced → Extra SSH public keys**). Inside, `docker` talks to the sandbox's own Docker. ``` ssh -o ProxyCommand="openssl s_client -quiet -connect %h:443 -servername %h" root@-shell-tcp. ``` Each new sandbox also has a key of its own: **Download key** in its panel (API `GET /sandboxes/:name/ssh-key`; `POST` makes one for an SSH sandbox that has none yet), so nobody has to make or upload a key first. The **Connect…** recipe then reads `ssh -i ~/Downloads/.key …`. The name `shell` is reserved: an inner service with `p9m.subdomain: shell` gets no TCP address. ## Terminal in the browser **Terminal** in a sandbox's panel opens a root shell in the sandbox in a new tab: nothing to install, no key. It wakes the sandbox if it is asleep, and keeps it awake while the tab is open. Inside, `docker` talks to the sandbox's own Docker. Only the sandbox's owner (and the admin) can open it, from the Purgatorium pages themselves (signed in; not with a token); at most 5 at once per user. ## App logs **Logs** in a sandbox's panel opens, in a new tab, what the apps in the sandbox write: the last 100 lines of every container (time, service name; errors in red), then new lines as they come. A filter shows one service; Pause holds new lines, Clear empties the view. It does not wake the sandbox or keep it awake: a sleeping one says *asleep* with a **Start** button. With a token: `curl -N -H "Authorization: Bearer $TOKEN" https://p9m.example.com/sandboxes/p9m-shop/logs/stream`. At most 5 viewers per sandbox at once. # Files The **Files** tab of a sandbox's panel shows its files: open folders, upload files (**Upload…**, or drop them on the list, several at once), download a file, or a whole folder as a `.tar` (**Download folder**), make folders and delete. It shows four folders, the ones that are kept when the sandbox goes into deep sleep or its container is recreated: `/root` (where it opens), `/home`, `/srv` and `/opt`. Anything else in the sandbox container is gone then, so it is not offered here. ## The apps' volumes **Volumes**, next to the four folders, lists the volumes of the apps in the sandbox's own Docker (a database's data, an app's uploads), with their compose project and the containers that use them; open one to browse, upload and download as above. Their files are in the sandbox container already (the sandbox's Docker keeps them under `/var/lib/docker/volumes//_data`, in the `docker_data` volume), so nothing extra is started, and they are kept over a deep sleep too. Only the local driver's volumes are shown; a container's own files outside its volumes are not (use `docker cp` in the sandbox for those). - **A running app:** while a container that uses the volume runs, the tab says so. A copy of a database's files taken while it runs may not be consistent, and the app may not expect files changed under it: stop it first (`docker compose stop db` in the sandbox), or use the database's own dump. - **Owners:** uploaded files and new folders belong to root, readable by everyone (`0644`). An app running as another user that has to write them needs a `chown` in the sandbox. - A volume's own folder cannot be deleted here: remove the volume with `docker volume rm`. The sandbox has to be running: asleep, the tab offers to wake it. A file can be up to `FILES_MAX_UPLOAD` (100 MB by default, see [Configuration](https://csakaszamok.github.io/p9m/0.12/configuration/index.md)). ## API and MCP | Method | Path | | | -------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/sandboxes/:name/files?path=/root/app` | The folder's entries (folders first): `name`, `type` (`dir`, `file`, `link`, `other`), `size`, `mtime` | | `GET` | `/sandboxes/:name/files/content?path=…` | A file's bytes, or a folder as a tar | | `PUT` | `/sandboxes/:name/files/content?path=…` | Upload: the body is the file (`curl -T report.pdf`) | | `GET` | `/sandboxes/:name/files/text?path=…` | A text file as `{ path, text }` (UTF-8, up to 1 MB) | | `POST` | `/sandboxes/:name/files/mkdir` | `{"path": "/srv/data"}` | | `DELETE` | `/sandboxes/:name/files?path=…` | A file, or a folder with everything in it | | `GET` | `/sandboxes/:name/volumes` | The apps' volumes: `name`, `path` (e.g. `/var/lib/docker/volumes/shop_db/_data`, for the routes above), `project`, `containers` (`name`, `service`, `running`, `mount`) | ``` curl -T notes.md -H "Authorization: Bearer $P9M_TOKEN" "https://p9m.example.com/sandboxes/p9m-shop/files/content?path=/root/notes.md" ``` A token limited to the sandbox may use all of them. Over [MCP](https://csakaszamok.github.io/p9m/0.12/mcp/index.md) they are `list_sandbox_files`, `read_sandbox_file`, `write_sandbox_file` (text), `create_sandbox_folder`, `delete_sandbox_file` and `list_sandbox_volumes`; a download is left out, since a tool result carries no file. # Hub The **Hub** lists the apps your team has built, in one place, for the people who *use* them: colleagues who never run a sandbox find an app and open it with one click. It is a web app of its own, at its own address (by default `hub.`), served by Purgatorium but apart from its UI: no sandboxes, no *New sandbox*, no quota. An app appears on the Hub when a label on its container says so; it shows its title, description, owner and whether it runs or sleeps, and **Open** opens it in a new tab. An app that is asleep wakes on that click, as any request wakes it. ## Turn it on ``` # .env HUB_ENABLED=true # HUB_HOST=hub.example.com # default: hub. # HUB_TITLE=Example Ltd # shown in its header ``` then `docker compose up -d`. HTTPS uses the same wildcard certificate as everything else under the domain. **Single sign-on:** add `https://hub./auth/callback` at your provider as a second allowed redirect URI (next to Purgatorium's `https://p9m./auth/callback`). The administrator can also sign in to the Hub with the password, so it can be tried without single sign-on. ## Who sees it | Who | Settings | May | | -------- | --------------------------------------------- | ----------------------------------------------------------- | | Builders | `OIDC_ALLOWED_DOMAINS`, `OIDC_ALLOWED_EMAILS` | use Purgatorium, create sandboxes, publish, and use the Hub | | Viewers | `HUB_ALLOWED_DOMAINS`, `HUB_ALLOWED_EMAILS` | sign in to the Hub only | - With `HUB_ALLOWED_*` empty, only builders (and the administrator) use the Hub. - A viewer who tries Purgatorium's own sign-in is refused with *"You have access to the Hub only: https://hub.example.com"*, and gets no Purgatorium session or token. - Taken off a list: signed out of the Hub at the next request. ## Publish an app Add labels to its container in the stack you deploy into the sandbox: ``` services: web: image: my-stock-room ports: ["8080:8080"] # on all interfaces: it needs a web address labels: p9m.publish: "true" # "true" or "1" publishes; anything else does not p9m.title: "Stock room" # optional, at most 80 characters; default: the service name p9m.description: "Who took what from the stock room" # optional, at most 280 ``` - A description over several lines keeps its line breaks on the Hub (YAML's `|`): ``` p9m.description: | Who took what from the stock room. Ask Anna for access. ``` - **An image on its card:** `p9m.image: "/screenshot.png"`, a path inside the app (PNG, JPEG, WebP or GIF, at most 2 MB and 4096 × 4096 pixels). Without it the Hub uses the page's own `` if it has one, and otherwise the title's first letter on a colour. Purgatorium fetches the image in the background straight from the sandbox, only while the app runs (it never wakes an app for it), refreshes it once a day or when the label changes, and never fetches anything from another site: a URL in the label, or an og:image on another host, is ignored. - What is published is the app's **web address**, the one its sandbox's *Apps* tab shows; with several ports, the lowest one published on all interfaces. A published container with no such port is not on the Hub: the sandbox's *Details* tab warns *published, but has no web address*. - It appears on the Hub within half a minute, without approval. Remove the label (and deploy again) to take it off; archiving the sandbox does too. Deep sleep keeps it on the Hub. - Apps started by hand or by an agent count as well: Purgatorium reads the labels from the sandbox's own Docker. - On the *Apps* tab, a published app has an **On the Hub** marker. ## Votes Everyone signed in to the Hub can vote for an app once (👍), their own too (as a GitHub star), and take the vote back. The Hub sorts by votes (*Popular*) or by date (*New*); only the count is shown, not who voted. Votes stay when an app leaves the Hub and count on if it is published again at the same address. ## Feed The Hub has an RSS feed (`/feed.xml`) and a JSON Feed (`/feed.json`), so a new app shows up by itself where the team looks: a Slack, Teams or Mattermost channel (their RSS integrations), Linkwarden, an RSS reader, or n8n and bots for anything else. Readers cannot sign in, so the feed takes a secret key: the administrator creates the link in Purgatorium's **Overview → Hub feed → Create feed link**. Only the key's hash is kept, so the link is shown once; creating a new one ends the old link. Without the key the feed answers 404. ## Not yet A webhook on new apps, a Hub per team (with teams), and a Hub open on the internal network without signing in are on the [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). ## App access An app open only to its owner (`p9m.access: owner`, see [App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)) is not published, even with `p9m.publish`: its **Details** tab says so. A `domain` app shows to Hub viewers who can sign in to Purgatorium itself; viewers allowed only on the Hub (`HUB_ALLOWED_DOMAINS`, `HUB_ALLOWED_EMAILS`) see public apps only. Opening a `domain` app makes the usual sign-in hop. # App access By default every web app in a sandbox is public: anyone with its address opens it. A builder can choose, app by app, who opens it, with a label on the app's container: | `p9m.access` | Who opens the app's web address | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | | `public` | Anyone. | | `domain` | Anyone who can sign in to this Purgatorium (its sign-in allowlist, `OIDC_ALLOWED_DOMAINS` and `OIDC_ALLOWED_EMAILS`), and the admin. | | `owner` | The sandbox's owner, and the admin. | ``` services: admin: build: . ports: ["8080:8080"] restart: unless-stopped labels: p9m.access: owner # or domain, or public ``` An app without the label gets the server's default, `APPS_ACCESS_DEFAULT` (`public` unless set; a company may set `domain`). A value that is not one of the three (a typo such as `privat`) means `owner`, and the sandbox's **Details** tab says so. The **Apps** tab marks each app that is not public: *Owner only*, *Your domain*. ## What a visitor sees The first time someone opens an app that is not public, their browser makes a short hop to Purgatorium: they sign in if they are not signed in yet, and come straight back to the app. That gives them a cookie for that one app's address, for 8 hours. Someone who may not open it sees a page that says so (*This app is open only to its owner*) and which account they are signed in with. Purgatorium's own sign-in never reaches an app: the cookie an app's address gets says only that this person may view this app, and Purgatorium takes it off every request before the app sees it, so an app cannot use it to act as its visitor. An asleep app is woken only for someone who may open it: an anonymous visitor of an owner-only app gets the sign-in hop, not a waking sandbox. ## The app knows who is viewing Behind `domain` and `owner`, every request to the app carries `X-P9m-User: ` (`admin` for the admin). An app can greet its visitor with it, or use it as its own sign-in. Purgatorium removes an `X-P9m-User` that a visitor sends themselves, also for public apps, so the header can be trusted, but only behind Purgatorium. ## Not covered - **The TCP addresses** (databases, SSH, Docker) are not HTTP: this does not apply to them. They are protected by their own TLS certificates and passwords, as before; see [Isolation and TCP addresses](https://csakaszamok.github.io/p9m/0.12/networking/index.md). - **Other sandboxes on the internal network.** A port published on all interfaces is also reachable from other sandboxes directly on the internal `traefik-net` address ([Isolation and TCP addresses](https://csakaszamok.github.io/p9m/0.12/networking/index.md)), without going through Purgatorium's check. App access protects the web address, not that path: for an app no other sandbox must reach, publish its port on `127.0.0.1` and nothing else (it then has no web address either). - **Webhooks and API clients without a browser** cannot make the sign-in hop. Keep an app that receives webhooks, or that a script or a mobile app calls, `public` for now. - **A coding agent's own `curl`** to an app that is not public gets the sign-in redirect. The agent checks whether an app answers from inside (`GET /sandboxes/:name` → `apps[].answers`), which is not affected. ## Timing and failures - A new or changed label applies within about 12 seconds of the deploy (Purgatorium reads the running sandboxes' labels every 10 s; Traefik asks Purgatorium every 2 s). Until then the sandbox goes by what Purgatorium last knew of it: in a sandbox whose apps were all public, a newly deployed `owner` app is open for those seconds. For an app that must never be open, not even briefly, publish its port on `127.0.0.1` first and on all interfaces only after a few seconds, or deploy it into a sandbox that already has a `domain` or `owner` app. - A sandbox whose apps are all public does not depend on Purgatorium while it runs, as before. One with a `domain` or `owner` app does: while Purgatorium is down, those apps do not open (it fails closed); public sandboxes keep working. - The check is per sandbox (one router serves all its apps): in a sandbox with a `domain` or `owner` app, its **public** apps also need Purgatorium. Keep an app that must stay reachable while Purgatorium is down (a webhook receiver, a status page) in a sandbox whose apps are all public. - If Traefik restarts while Purgatorium is down, no sandbox app opens until Purgatorium is back (Traefik has not yet read the access middlewares). ## On the Hub An owner-only app is not published on the [Hub](https://csakaszamok.github.io/p9m/0.12/hub/index.md), even with `p9m.publish` (the **Details** tab says so: set `p9m.access: domain`). A `domain` app shows to Hub viewers who can sign in to Purgatorium itself; viewers who can sign in only to the Hub (`HUB_ALLOWED_*`) see the public apps. ## Settings | Variable | Default | | | --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `APPS_ACCESS_DEFAULT` | `public` | The level of an app without `p9m.access`: `public`, `domain` or `owner`. | | `APP_ACCESS_SECRET` | generated | Signs the app cookies and the one-time sign-in tickets. Without it Purgatorium generates one into `data/app-access.key` and keeps it. | # Agents # Let your agent deploy Give a coding agent (Claude Code, Codex, Cursor…) a personal access token and a sandbox, and it can ship your app there on its own: wake the sandbox, deploy a compose project to the sandbox's own Docker daemon, build images inside it, read the logs and roll out new versions. The app gets its public URL from the tunnel. 1. In the UI: create a sandbox (say `shop`), then in its panel's **Access** tab **Token for this sandbox…** — a token that works only for that sandbox. 1. **Download p9m.env** in the token's dialog and save it in your project folder (keep it out of git), then tell the agent: *Deploy this app to my Purgatorium sandbox — settings in p9m.env.* The file holds `P9M_URL`, `P9M_TOKEN`, `P9M_SANDBOX` and `P9M_AGENT_GUIDE`. Or tell the agent everything yourself, for example: > Deploy this repo to my Purgatorium sandbox `p9m-shop` at `https://p9m.example.com` with the token `p9m_…`. Follow https://csakaszamok.github.io/p9m/latest/agent-guide/index.md. [docs/agent-guide.md](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md) is written for the agent: every call it needs, tried against a live Purgatorium. A token limited to one sandbox keeps the agent away from your other sandboxes; a token for all your sandboxes (Access tokens → + New token) acts as you. An agent with MCP can also use Purgatorium as an MCP server with the same token: see [MCP](https://csakaszamok.github.io/p9m/0.12/mcp/index.md). In Claude Code, the [p9m plugin](https://csakaszamok.github.io/p9m/0.12/claude-plugin/index.md) needs no token typed in: install it once (`/plugin marketplace add csakaszamok/p9m`, `/plugin install p9m@p9m`), then run `/p9m:init
` in the project and approve in the browser. ## Docker, registry, export A coding agent (Claude Code, Codex…) can work on a sandbox with plain `docker` commands: - **Docker from outside:** `-docker.:443` reaches the sandbox's own Docker daemon. Purgatorium does not open that TLS: it only reads the name and passes the connection through, so the Docker command line and the sandbox talk to each other with the sandbox's own certificates (only whoever holds its client key gets in). It does not wake a sleeping sandbox (wake it first: **Wake**, `POST /sandboxes/:name/start`, or the MCP tool `wake_sandbox`); traffic on the connection keeps a running one awake, a connection that is only open (e.g. Docker Desktop keeping a context connected) does not. - **Let your agent deploy…**, on a sandbox's **Apps** tab (and in the Advanced view also at the top of its **Access** tab), is the same window in both views. For the [Claude Code plugin](https://csakaszamok.github.io/p9m/0.12/claude-plugin/index.md): the two lines that install it (once), and the `/p9m:init
` line to paste into Claude Code in the project; you approve in the browser, and the project gets its `.p9m` folder with a token for the sandbox. For other agents: **Download** gives `p9m-.zip`, with a `.p9m` folder to unpack in the project's root folder (its own `.gitignore` of `*` keeps it out of git; add `.p9m` to `.dockerignore` too, so that a build never copies it into an image), and what to tell the agent (*set up the p9m zip from my Downloads in this project and deploy*). Before a sandbox has Docker access it offers to enable it, or to export without the Docker keys. In the Advanced view the window has **Options**: the token's name and expiry (for the zip), a zip without the Docker keys, and **Copy as .env**, which puts `p9m.env` (a new token for the sandbox) on the clipboard. When the sandbox's Portainer is public, both also carry `P9M_PORTAINER_URL` and a Portainer API token (`P9M_PORTAINER_TOKEN`, sent as `X-API-Key`), made for the export, so the agent never gets the Portainer admin password; making it wakes the sandbox. That token does not expire and revoking the Purgatorium token does not revoke it: delete it in the sandbox's Portainer (My account → Access tokens). The `.p9m` folder holds `p9m.env` (with a new token limited to the sandbox, `P9M_DOCKER_HOST`, `P9M_REGISTRY`), the Docker client keys and a README with the `docker context create` command for Windows and macOS/Linux. It is a secret; **Rotate Docker keys…** makes the keys of earlier exports useless. - **Registry:** `docker login registry.` with a Purgatorium token as the password (a token limited to a sandbox pushes to `/…`, a personal one to its owner's sandboxes); the password returned at create still works. - **Anonymous pull after a secret scan:** every push is scanned (files such as `.env`, private keys, and well-known key formats, in every layer and in the image config, where Dockerfile `ENV` lines end up). Anonymous pull is decided per repository: one whose versions are all clean — and that has nothing in the registry Purgatorium has not scanned — can be pulled by anyone without logging in; one flagged, unscanned or failed version keeps the whole repository private until it is deleted. The registry tells Purgatorium about pushes with a secret Purgatorium generates (`data/registry-notify.secret`). Garbage collection runs weekly; a push at that very moment may fail and needs a retry. The **Registry** tab shows the images, the scan results (masked) and a Delete button. `REGISTRY_PUBLIC_PULL=false` keeps everything private. ## An agent inside a sandbox A coding agent can also run inside one sandbox (say, in a VS Code or code-server container there) and deploy to another: give it that other sandbox's access as usual (the [Claude Code plugin](https://csakaszamok.github.io/p9m/0.12/claude-plugin/index.md) with `/p9m:init` for that sandbox, or its zip). It needs two names to reach from inside the sandbox: Purgatorium's (`p9m.`, the API and MCP) and the other sandbox's Docker address (`-docker.`), plus its apps' addresses if it opens them. **With a real domain** (a wildcard DNS record for `*.`), these names point at the server from inside a sandbox too: nothing to set. The server must reach its own public address from inside, which it usually does ("hairpin NAT"; most clouds and Docker do it). **On a local development stack on `lvh.me`** they do not: `lvh.me` names point at `127.0.0.1`, which inside a sandbox's container is that container itself, not Purgatorium. With Docker Desktop (Windows, macOS), point them at the host, `192.168.65.254` (what `host.docker.internal` resolves to; a fixed address), with `extra_hosts` on the agent's service in that sandbox's compose file: ``` extra_hosts: - "p9m.lvh.me:192.168.65.254" - "shop-docker.lvh.me:192.168.65.254" # the other sandbox's Docker address - "shop-web.lvh.me:192.168.65.254" # its app, if the agent opens it ``` It has to be an address: `extra_hosts` takes no names, and `host-gateway` inside a sandbox is the sandbox's own container, not the host. The names stay as they are, because the TLS certificates and Traefik's routing go by them. On a Linux host without Docker Desktop there is no such address: use a real domain there. # MCP [![](../media/mcp-agent-poster.webp)](../media/mcp-agent.mp4) The same API is an MCP server at `/mcp` (Streamable HTTP): each endpoint is a tool, such as `list_sandboxes`, `create_sandbox`, `wake_sandbox`, `sleep_sandbox`, `get_sandbox` (with its app links), `set_sleep_settings`, and for the sandbox's files `list_sandbox_files`, `read_sandbox_file`, `write_sandbox_file`, `create_sandbox_folder`, `delete_sandbox_file` and `list_sandbox_volumes` (the apps' volumes, whose paths the other file tools take). A tool call runs the endpoint with the caller's token, so it can do exactly what that token can do on the REST API; a token limited to one sandbox stays limited to it, and is offered only the tools it may call (no `create_sandbox`, no token or SSH key management). Only tokens are accepted (not a browser session). For Claude Code, add it by hand (the [p9m plugin](https://csakaszamok.github.io/p9m/0.12/claude-plugin/index.md) is a deploy skill and does not add it): ``` claude mcp add --transport http p9m https://p9m.example.com/mcp --header "Authorization: Bearer p9m_…" ``` Other clients take the same URL and header, e.g. `{"mcpServers": {"p9m": {"type": "http", "url": "https://p9m.example.com/mcp", "headers": {"Authorization": "Bearer p9m_…"}}}}`. Left out: the log stream (it never ends) and the zip export (a file a tool result cannot carry). A client that can only start a local process (stdio) can use `scripts/mcp-stdio.mjs` from this repository: it needs only Node.js 18+, no packages, and passes each message to the server's `/mcp` with your token: ``` {"mcpServers": {"p9m": {"command": "node", "args": ["/path/to/p9m/scripts/mcp-stdio.mjs"], "env": {"P9M_URL": "https://p9m.example.com", "P9M_TOKEN": "p9m_…"}}}} ``` Under the hood it is JSON-RPC over HTTP: one `POST /mcp` per call, no session to open first, so even `curl` can call a tool (`tools/list`, then `tools/call` with the tool's `name` and `arguments`): [![](../media/mcp-rpc-poster.webp)](../media/mcp-rpc.mp4) # Claude Code plugin New to this? [Put your app online with Claude Code](https://csakaszamok.github.io/p9m/0.12/tutorials/claude-code/index.md) walks through it step by step. The `p9m` plugin teaches Claude Code to deploy: you say *put my app online*, it deploys the project to your sandbox's own Docker, checks that the app answers and gives you its address. The plugin itself keeps no address and no token: each project says which Purgatorium and which sandbox it belongs to, in its own `.p9m` folder. ## Install Once on your computer, from a terminal (a sandbox's **Let your agent deploy…** shows these lines ready to copy). Claude Code in an editor (the VS Code extension) has no `/plugin`, so there this is the way: ``` claude plugin marketplace add csakaszamok/p9m claude plugin install p9m@p9m ``` Or in Claude Code: ``` /plugin marketplace add csakaszamok/p9m /plugin install p9m@p9m ``` Claude Code asks where to install it: **user** (every folder of yours; usually what you want), **project** (this repository, for everyone who uses it, through its `.claude/settings.json`) or **local** (this folder, only you). It asks for nothing else: no address, no token. Updates come with Purgatorium's releases (`/plugin marketplace update p9m`). ## Connect a project: `/p9m:init` In Claude Code, in the project's root folder, run the line that **Let your agent deploy…** shows for the sandbox: ``` /p9m:init https://p9m.example.com shop ``` Claude Code prints a link. Open it in your browser (sign in first if Purgatorium asks). The page shows a code and the sandbox; check that the code is the one Claude Code printed and click **Approve** (or **Deny**). Only the sandbox's owner (or the admin) can approve. The code works once and expires in 10 minutes. Then Claude Code unpacks the project's `.p9m` folder straight to disk: a new token for that sandbox only (in `.p9m/p9m.env`, 90 days) and the sandbox's Docker keys. The token never shows on the screen or in the chat. The folder keeps itself out of git, and the token is listed in Purgatorium under **Access tokens** as `Claude Code init `, where you can revoke it. Run `/p9m:init` again whenever the token has expired or was revoked. A project can be tied to one sandbox at a time; another project in another folder can be tied to another sandbox, or to another Purgatorium. ## The `.p9m` folder The `.p9m` folder in the project's root folder says which Purgatorium and which sandbox the project belongs to: `p9m.env` (the address, the sandbox's name and its token) and the sandbox's Docker keys. `/p9m:init` writes it, or you unpack it from an export zip. It holds secrets, and it keeps itself out of git: its own `.gitignore` is a single `*`. Add `.p9m` to `.dockerignore` too, so that a build never copies it into an image; the agent does when it writes one. ## Use In your project's folder, ask in your own words, e.g. *deploy this app to my sandbox* or *give me a link to show this to someone*, or run `/p9m:deploy`. You need Docker installed on your machine (the build runs in the sandbox, but the `docker` command line talks to it). In a project without a `.p9m` folder, the agent tells you to run `/p9m:init` first. The plugin has two skills: - **`/p9m:init`**: ties the project to a sandbox, as above; - **`/p9m:deploy`**: the steps of the [agent guide](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md) in short, for an agent that may be talking to someone who is not a developer. It works from `.p9m/p9m.env` with the REST API, and gets fresh Docker keys through a single-use link (unpacked to the disk, so a key never passes through the model: copied by a model a key gets garbled, and it would end up in the transcript). The plugin does not bring the MCP server. Purgatorium still has one, for other MCP clients: see [MCP](https://csakaszamok.github.io/p9m/0.12/mcp/index.md). ## Where it works The plugin works fully in Claude Code: in the terminal, and in the Claude desktop app's **Code** tab; also in Claude Code inside a sandbox, deploying to another one (on a local `lvh.me` stack see [An agent inside a sandbox](https://csakaszamok.github.io/p9m/0.12/agents/#an-agent-inside-a-sandbox)). Claude chat and Cowork can install it too (**Customize → Plugins**, add the marketplace `csakaszamok/p9m`), but they cannot run commands on your computer, so there it does not do much. Without the plugin, any agent can deploy with an export (**Let your agent deploy…** on a sandbox) or a token: see [Let your agent deploy](https://csakaszamok.github.io/p9m/0.12/agents/index.md). # Purgatorium for agents: deploy and update apps in a sandbox This guide is for a coding agent (for example Claude Code on the user's machine) that deploys or updates apps in the user's Purgatorium sandbox. Every call below was run against a live Purgatorium. Every sandbox has its own Docker daemon. The agent works with: - the **Purgatorium API**, with the user's personal access token, to find the sandbox, wake it and get its Docker client certificates; - the sandbox's **Docker daemon**, with the plain `docker` command line, to deploy, build and update. An agent with MCP can make the Purgatorium API calls as tools instead: see [MCP](https://csakaszamok.github.io/p9m/0.12/mcp/index.md). A sandbox made from the `portainer` template also has a Portainer API: see [With Portainer](#with-portainer) at the end. ## What the user gives the agent | Value | Example | Where it comes from | | --------------------- | ------------------------- | ---------------------------------------------- | | Purgatorium URL | `https://p9m.example.com` | The address of the Purgatorium UI | | Personal access token | `p9m_…` | UI → **Access tokens** → **+ New token** | | Sandbox name | `p9m-shop` | The name in the UI list with the `p9m-` prefix | The user may instead give these in a `p9m.env` file in the project (UI → the new token → **Download p9m.env**; it may also be renamed `.env` or copied into one): `P9M_URL`, `P9M_TOKEN`, `P9M_SANDBOX` (only for a token limited to one sandbox; otherwise ask which sandbox, or list them with `GET /sandboxes`) and `P9M_AGENT_GUIDE` (this guide). Read them from there; do not print the token or commit the file. The token may be limited to one sandbox. Then `GET /sandboxes` lists only that one, `GET /me` returns it as `sandbox`, and creating or deleting sandboxes answers 403. Everything in this guide works with such a token. Every Purgatorium API call sends `Authorization: Bearer p9m_…`. The token acts as the user: it sees and changes only the user's own sandboxes. ## 1. Find the sandbox and wake it ``` GET /sandboxes/p9m-shop ``` The answer includes: - `status`: `running`, `exited` (asleep) or `deep_sleep`; - `docker_access`: `host`, the address of the sandbox's Docker daemon (for example `shop-docker.example.com`), and `state`: `ready`, or `needs-certs` when its certificates do not cover that address (the user enables it once: **Access → Enable**, which restarts the sandbox); and, while the sandbox is not running, `hint`: a Docker connection does not wake it, so start it first (`docker` would only get EOF); - with `?include_keys=true` only: `ca_cert`, `client_cert`, `client_key`, the Docker client certificates and private key (PEM). Write them to files with a program (below), never by copying them through a model: copied by hand a key gets garbled (and still looks like a key), and in an answer it goes into the model's context and transcript. A single-use link (below) puts them on disk without them passing through an answer; - `tunnel_urls`: the app addresses. If `status` is not `running`, wake the sandbox and poll until it is: ``` POST /sandboxes/p9m-shop/start GET /sandboxes/p9m-shop → repeat until "status": "running" ``` Waking from `exited` takes a few seconds; from `deep_sleep` about 30 seconds. Connecting to the Docker daemon does **not** wake it: while the sandbox sleeps, `docker` fails to connect (connection reset or EOF), so wake it first. A request to an app address while the sandbox is not ready returns an HTML **waiting page** ("Sandbox is waking up") with status 200, instead of the real answer: treat it as "not ready yet" and retry after a few seconds. ## 2. Connect the docker command line Ask for a single-use link to the project's `.p9m` folder (MCP: `project_dir_link`). Purgatorium checks the keys first; the link works once, for 5 minutes: ``` POST /sandboxes/p9m-shop/project-link → { "url": "https://p9m.example.com/project-dir/…", "expires_in": 300, "command": "curl -sf \"https://p9m.example.com/project-dir/…\" | tar -x -C ", "context_commands": ["docker context rm -f p9m-shop", "docker context create p9m-shop --docker \"host=tcp://shop-docker.example.com:443,ca=/.p9m/docker/ca.pem,…\""] } ``` Run `command`, then `context_commands`, with the project's root folder for ``. The download writes `.p9m/.gitignore` (`*`: git leaves the folder out), `.p9m/p9m.env` (`P9M_URL`, `P9M_SANDBOX`, `P9M_DOCKER_HOST`) and `.p9m/docker/{ca,cert,key}.pem`. On Windows: `curl.exe --ssl-no-revoke`; `tar` comes with Windows 10 and later. A used or expired link answers **410**: ask for a new one. ``` docker --context p9m-shop compose ls ``` A context keeps its own copies of the files: after new keys run both again, with a new link. Check it with a compose command, as above: `ps` can work with a key that compose cannot read (`tls: failed to parse private key`). On a TLS error get a new link; do not repair a key file. Add `.p9m` to `.dockerignore`, so that a build never copies the keys into an image. Only TLS with these certificates gets in: Purgatorium passes the connection through without opening it. Keep the key as secret as the token. If the user gave you an export instead, it is a `.p9m` folder for the project's root folder (from the zip `p9m-.zip`; an older export is a `p9m-/` folder): `p9m.env`, `docker/` with the certificates, a README, and a `.gitignore` of `*` that keeps the folder out of git. `p9m.env` has `P9M_DOCKER_HOST`. Unpack a zip into the project's root and delete the zip (it holds a token); add `.p9m` to `.dockerignore`, so that a build never copies the keys into an image: ``` docker context create p9m-shop --docker "host=$P9M_DOCKER_HOST,ca=$PWD/.p9m/docker/ca.pem,cert=$PWD/.p9m/docker/cert.pem,key=$PWD/.p9m/docker/key.pem" ``` ## 3. Deploy an app A compose project in the sandbox, built and run there: ``` docker --context p9m-shop compose -p shop up -d --build ``` ``` services: web: build: . ports: ["8080:8080"] restart: unless-stopped labels: p9m.subdomain: shop ``` - **Publish ports on all interfaces** (`8080:8080`): that is what gives the app its public address. A port published on `127.0.0.1` stays private to the sandbox: no address at all. The app itself must listen on `0.0.0.0` inside its container (not `127.0.0.1`), or its address answers with an error; `GET /sandboxes/:name` → `apps[].answers` says whether it answers. An app labelled `p9m.access: domain` or `owner` ([App access](https://csakaszamok.github.io/p9m/0.12/app-access/index.md)) answers your own `curl` with a redirect to Purgatorium's sign-in: check `apps[].answers` instead. Set the label the user asks for (*only for me* → `owner`, *for my colleagues* → `domain`) before the first deploy. - **Set a restart policy** (`restart: unless-stopped`): when the sandbox wakes from sleep, only containers with one start again. - Pass secrets when the container runs (`environment:`, `env_file:` read on your machine by `docker compose`), **never in the image**. ### The app's public address Every published TCP port gets an HTTP address. With the sandbox `p9m-shop` on `example.com`: - with a `p9m.subdomain: shop` label: `https://shop-shop.example.com`, that is `-.`; - without the label: `https://shop---port.example.com`. The container name is `--1` unless `container_name` is set. The app is reachable a few seconds after the container starts. `GET /sandboxes/p9m-shop` lists the addresses in `tunnel_urls`. A database or other TCP service published the same way also gets a TLS address on port 443: see [Isolation and TCP addresses](https://csakaszamok.github.io/p9m/0.12/networking/#tls-tcp-addresses). ## 4. Update the app Change the code or the image tag and run the same `docker --context p9m-shop compose -p shop up -d --build` again: compose recreates the changed containers and keeps the volumes. Use a fixed tag for every release (`:1.4.2`, `:1.4.3`, …) so you can roll back by deploying the previous one. ### Push to the registry Every sandbox can push to Purgatorium's registry, so an image can be pulled elsewhere too: ``` docker login registry.example.com -u shop # password: the token docker --context p9m-shop tag shop-web registry.example.com/shop/web:3 docker --context p9m-shop push registry.example.com/shop/web:3 ``` - Every push is scanned for secrets, and an image with a `.env` file or a key in it stays private (`GET /sandboxes//registry` shows what was found). - Anyone can pull the image from any machine (`docker pull registry./shop/web:3`) once **every** version of it passed the scan: one flagged version keeps the whole image private until it is deleted. ## 5. Other ways in - **SSH:** `ssh root@-shell-tcp.` through port 443 ([SSH, terminal and logs](https://csakaszamok.github.io/p9m/0.12/shell-and-logs/index.md)); inside, `docker` talks to the sandbox's Docker. - **Logs** of every app in the sandbox: `GET /sandboxes//logs/stream` (Server-Sent Events). ## With Portainer A sandbox made from the `portainer` template (or one with Portainer in its own compose) also has Portainer, which proxies the sandbox's Docker daemon over HTTPS: an agent can deploy through its API without the `docker` command line. `GET /sandboxes/` then has: - `extras.portainer_url`, for example `https://shop-portainer.example.com` (if its host is not in `tunnel_urls`, Portainer is published on `127.0.0.1` and private to the sandbox: ask the user to publish it on all interfaces, `9000:9000`, in **Starter stack…**); - `extras.portainer_password` (user `admin`). `GET /api/status` returning JSON means Portainer is ready (an HTML answer is the waiting page). Portainer traffic wakes an asleep sandbox and keeps it awake. ### Sign in to Portainer If `p9m.env` has `P9M_PORTAINER_URL` and `P9M_PORTAINER_TOKEN`, skip the sign-in: use `P9M_PORTAINER_URL` as `` and send `X-API-Key: ` instead of `Authorization: Bearer ` on every Portainer call below. (A line `# Portainer: not included (…)` says why there is none; then sign in as below, or use the Docker command line, [2](#2-connect-the-docker-command-line).) Otherwise sign in with `extras.portainer_password`: ``` POST /api/auth Content-Type: application/json {"username": "admin", "password": ""} ``` The answer is `{"jwt": "…"}`. Send `Authorization: Bearer ` on every Portainer call below. Then find the environment (endpoint) ID. It is the sandbox's Docker daemon, usually `1`: ``` GET /api/endpoints → [{"Id": 1, "Name": "primary", …}] ``` If the list is empty (nobody clicked **Get started** in the Portainer UI yet), create the environment once: ``` POST /api/endpoints Content-Type: multipart/form-data Name=local EndpointCreationType=1 ``` ### Deploy an app as a stack A Portainer stack is a compose project that Portainer can update later. Deploy it from compose text: ``` POST /api/stacks/create/standalone/string?endpointId=1 Content-Type: application/json { "name": "shop", "stackFileContent": "services:\n web:\n image: registry.example.com/shop:1.4.2\n ports: [\"8080:8080\"]\n labels:\n p9m.subdomain: shop\n", "env": [{"name": "LOG_LEVEL", "value": "info"}] } ``` The answer includes the stack `Id`; keep it for updates. `GET /api/stacks` lists the stacks with their IDs. The same rules hold as in [3. Deploy an app](#3-deploy-an-app): publish ports on all interfaces, set a restart policy. ### Update a stack Change the image tag in the compose text and redeploy. `pullImage: true` pulls the image again even if the tag did not change (for `:latest`): ``` PUT /api/stacks/?endpointId=1 Content-Type: application/json {"stackFileContent": "", "env": [], "prune": true, "pullImage": true} ``` `prune: true` removes services that are no longer in the compose text. Portainer recreates the changed containers; the volumes are kept. To build in the sandbox, send the build context as a tar archive to the Docker build endpoint through Portainer's Docker proxy: ``` POST /api/endpoints/1/docker/build?t=shop:1.4.3 Content-Type: application/x-tar ``` The answer streams the build log as JSON lines; the last line is `{"stream":"Successfully tagged shop:1.4.3\n"}` on success, or an `{"error": …}` line on failure. Then redeploy the stack with `image: shop:1.4.3` and `pullImage: false` (the image only exists locally). ### Everything else Docker can do `/api/endpoints/1/docker/` is the sandbox's Docker Engine API. Examples: | Call | Docker Engine API | | ---------------------------------------------------------------- | ----------------------- | | `GET …/docker/containers/json` | list running containers | | `GET …/docker/containers//logs?stdout=1&stderr=1&tail=200` | container logs | | `POST …/docker/containers//restart` | restart a container | | `POST …/docker/images/create?fromImage=alpine&tag=3.20` | pull an image | | `DELETE …/docker/images/` | remove an image | The stack Purgatorium deployed when it created the sandbox (Portainer + `http-echo`) was deployed with `docker compose` outside Portainer: Portainer shows it but cannot edit it as a stack. ## Things to keep in mind - **Sleep:** the sandbox sleeps after its idle timeout without HTTP traffic, 30 minutes by default. The user can change this or turn it off in the UI (**Sleep settings…**) or with `PATCH /sandboxes/` `{"idle_timeout": "2h"}`, where `0` or `off` means never. Docker traffic keeps a running sandbox awake (a build, `docker logs -f`, a pull), but a connection that is only open does not; plain HTTP traffic to its apps (or to Portainer) counts too. - **Archive** (`DELETE /sandboxes/`, **Archive…** in the UI) archives the sandbox: its configuration and data are kept in the archive, and it is removed from the list. - **The Purgatorium API reference** is at `/swagger`. # Reference # API [![](../media/api-curl-poster.webp)](../media/api-curl.mp4) | Method | Path | Description | | -------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `POST` | `/sandboxes` | Create a sandbox (`name`; optional `runtime` like `dind`, `template` like `starter`, `compose` (your own compose text instead of the template's; its `before_script` and defaults still apply), `idle_timeout` like `30m`, `deep_sleep_after` like `7d` or `off`, `ssh_keys` (extra SSH public keys for this sandbox only), `cpus` like `2`, `memory` like `4g`, `create_inner_stack: false` for no template stack) | | `GET` | `/sandboxes` | List all sandboxes | | `GET` | `/sandboxes/summary` | How many sandboxes: `quota`, `total`, `free`, `running`, `asleep`, `deep_sleep`, `archived`; your own (and `server_full`), or for the admin the whole server, `by_owner`, `server_limit` / `server_used` and `resources` (not with a token limited to one sandbox) | | `GET` | `/sandboxes/:name` | Get sandbox details; the Docker client certificates and key (`ca_cert`, `client_cert`, `client_key`) only with `?include_keys=true` | | `GET` | `/sandboxes/:name/compose` | The starter stack deployed into the sandbox (compose YAML), secrets masked (**Starter stack…** in the UI) | | `PATCH` | `/sandboxes/:name` | Change `idle_timeout` and/or `deep_sleep_after` (see [Sleep and wake](https://csakaszamok.github.io/p9m/0.12/sleep-wake/index.md)), `cpus` and/or `memory` (see [CPU and memory](https://csakaszamok.github.io/p9m/0.12/resources/#cpu-and-memory)) | | `POST` | `/sandboxes/:name/start` | Wake a sandbox, also from deep sleep (**Wake** in the UI); 409 at the owner's limit with `reason` and `running`; `{"sleep": ""}` puts that running one to sleep first | | `POST` | `/sandboxes/:name/stop` | Put a sandbox to sleep now (**Sleep** in the UI) | | `POST` | `/sandboxes/:name/deep-sleep` | Put a sandbox into deep sleep now, also a running one (**Deep sleep now** on the panel's **Details** tab) | | `POST` | `/sandboxes/:name/keep-awake` | Start a running sandbox's sleep countdown over (**Reset** next to *Sleeps in*); 409 if it is not running | | `POST` | `/sandboxes/:name/export` | A zip for coding agents: a new token for the sandbox, its Docker client keys, a README (owner/admin; not with a token limited to one sandbox); `docker: false` without the keys, `format: "env"` the p9m.env text | | `POST` | `/sandboxes/:name/project-link` | A single-use link (5 minutes) to the project's `.p9m` folder with the Docker client keys, checked first; answers `url`, `command` (`curl … \| tar -x`) and `context_commands`, never the keys | | `GET` | `/project-dir/:token` | The `.p9m` folder as a tar, once (no token: the link is the authorization); `410` when used or expired | | `POST` | `/device/code` | Start a device approval for `/p9m:init` (no token; `sandbox` optional): answers `device_code` (the agent's secret), `user_code`, `verification_uri_complete`, `expires_in` (600) and `interval` (3); both codes live 10 minutes | | `POST` | `/device/poll` | Ask whether it was approved (no token; `device_code` in the body): `{"status": "pending"}` or `"denied"`, `410` `"expired"` (also for a used code), or `approved` with the same answer as `project-link`, once | | `GET` | `/activate` | The page where a signed-in owner approves or denies a code (`?code=`); `POST` sends the decision | | `POST` | `/sandboxes/:name/docker-keys` | New Docker access certificates (**Enable** / **Rotate Docker keys…**); restarts the sandbox | | `GET` | `/sandboxes/:name/registry` | The sandbox's images in the registry, with secret-scan results | | `DELETE` | `/sandboxes/:name/registry/:app/:digest` | Delete one version of an image | | `POST` | `/sandboxes/:name/restart` | Restart a sandbox | | `GET` | `/sandboxes/:name/files` | List a folder of the sandbox's files (`path`, default `/root`): `/root`, `/home`, `/srv` and `/opt`, the folders kept over a deep sleep, and the apps' volumes; the sandbox must be running (409 `not_running` otherwise). See [Files](https://csakaszamok.github.io/p9m/0.12/files/index.md) | | `GET` | `/sandboxes/:name/files/content` | Download a file (`path`), or a folder as a tar | | `PUT` | `/sandboxes/:name/files/content` | Upload a file to `path` (the body is the file; its folder must exist), up to `FILES_MAX_UPLOAD` | | `GET` | `/sandboxes/:name/files/text` | A text file's content as JSON (UTF-8, up to 1 MB): for agents | | `POST` | `/sandboxes/:name/files/mkdir` | Create a folder (`{"path": "/srv/data"}`) | | `DELETE` | `/sandboxes/:name/files` | Delete a file or a folder (`path`), not the four folders themselves (nor a volume's folder) | | `GET` | `/sandboxes/:name/volumes` | The volumes of the apps in the sandbox's own Docker: `name`, `path` (for the routes above), `project`, `containers` that use it | | `GET` | `/sandboxes/:name/mounts` | The host folders mounted into the sandbox: `mounts` (`host`, `target`, `readonly`), `enabled` (`HOST_MOUNT_ROOTS` is set); the admin also gets `roots`. Owner, admin, and a token limited to the sandbox. See [Host folders](https://csakaszamok.github.io/p9m/0.12/web-ui/#host-folders) | | `POST` | `/sandboxes/:name/mounts` | Mount a host folder (admin only, `HOST_MOUNT_ROOTS` set, else 403 / 409): `{"host": "/srv/shared/team", "target": "/mnt/team", "readonly": true}`, `target` defaulting to `/mnt/`. The sandbox container is recreated (a running one restarts). 400 for a path outside the roots or a target that is not allowed; 409 with Docker's words when Docker refuses it (e.g. the host folder does not exist), nothing changed | | `DELETE` | `/sandboxes/:name/mounts?target=/mnt/team` | Unmount it (admin only; also with `HOST_MOUNT_ROOTS` unset); the sandbox container is recreated | | `DELETE` | `/sandboxes/:name` | Archive the sandbox, then remove it (**Archive…** in the UI; see [Delete = archive](https://csakaszamok.github.io/p9m/0.12/archive/index.md)) | | `GET` | `/runtimes` | List the runtimes new sandboxes may use (`name`, `description`, `default`; only those in `ALLOWED_RUNTIMES` when it is set) | | `GET` | `/templates` | List the templates (`name`, `description`, `default`) | | `GET` | `/ssh-keys` | Your SSH public keys (`id`, `name`, `type`, `fingerprint`, `created_at`) | | `POST` | `/ssh-keys` | Add a public key (`key`, optional `name`); it lets you into all your sandboxes with SSH | | `GET` | `/sandboxes/:name/ssh-key` | The sandbox's generated SSH private key (download) | | `POST` | `/sandboxes/:name/ssh-key` | Generate it for an SSH sandbox that has none yet | | `GET` | `/sandboxes/:name/terminal` | The browser terminal (page; the same path as a websocket: the shell) | | `GET` | `/sandboxes/:name/logs` | The logs page (signed-in browsers) | | `GET` | `/sandboxes/:name/logs/stream` | The sandbox's app logs as Server-Sent Events: the last 100 lines of every container, then live (token too) | | `DELETE` | `/ssh-keys/:id` | Delete one of your keys (it stops working at once) | | `GET` | `/templates/:name` | A template's `compose.yaml` as written (`compose`), to show or edit before a create | | `GET` | `/me` | Who you are signed in as, Purgatorium's own address (`url`), your running sandboxes and limit, and `server_places`: the sandbox places on the server, `used` and `limit` (`null` without a server limit) | | `PUT` | `/me/view` | The web UI's view for this account: `{"view": "simple"}` or `"advanced"` (`GET /me` returns it as `view`, and whether the account may switch as `view_switch`); a user gets 403 unless `USER_VIEW_SWITCH=true` | | `GET` | `/tokens` | List your personal access tokens (`hint`: the token's start and end, e.g. `p9m_aB3x…9fQz`; null for tokens made before hints) | | `POST` | `/tokens` | Mint a personal access token (`name`, optional `expires_in` like `30d`/`90d`/`never`) | | `DELETE` | `/tokens/:id` | Revoke a personal access token | | `GET` | `/users` | Accounts (admin): first and last seen, last sign-in, sign-ins, sandboxes now and created, last API use | | `DELETE` | `/users/:account` | Forget an account's stats row (admin); the account and its sandboxes stay | All endpoints require `Authorization: Bearer ` or a signed-in browser session. Users only see their own sandboxes. ## OpenAPI and clients The whole API is described as OpenAPI 3.0 at `/swagger.json` (no token needed to read it), and you can try it in Swagger UI at `/swagger`. There is no official SDK yet; generate a client in your language from that document instead, for example typed TypeScript: ``` curl -s https://p9m.example.com/swagger.json -o p9m.json npx openapi-typescript p9m.json -o p9m.d.ts # use it with openapi-fetch ``` or any language [openapi-generator](https://openapi-generator.tech/) supports (`openapi-generator-cli generate -i p9m.json -g python -o p9m-client`). The document's version is Purgatorium's own, so regenerate after an upgrade. More reading: [architecture diagrams](https://csakaszamok.github.io/p9m/0.12/architecture.html), [how agents deploy](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md), [comparison with other sandbox platforms](https://csakaszamok.github.io/p9m/0.12/comparison/index.md), [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). # Security Purgatorium is built for a team that trusts each other's intentions but not each other's mistakes: it keeps one person's broken compose file, runaway container or exposed port from affecting everyone else. It is **not** a boundary against hostile code. What it does: - each user gets their own Docker daemon, so their containers, images, volumes and networks are separate from everyone else's and from the host's Docker; - an app's private addresses are reachable only inside its own sandbox, and ports published on `127.0.0.1` only through its tunnel (see [Isolation between sandboxes](https://csakaszamok.github.io/p9m/0.12/networking/#isolation-between-sandboxes)); - users see and change only their own sandboxes, through the UI, the API or a personal access token; the admin sees all; - with an `https://` `PUBLIC_URL`, Purgatorium refuses to start with default secrets. **On a shared server** (several people, or agents running code you have not read): install [sysbox](https://github.com/nestybox/sysbox) on the host and set `ALLOWED_RUNTIMES=sysbox` in `.env`. New sandboxes then always run under sysbox, nobody can create a privileged `dind` one, and `check-host.sh` tells whether sysbox works on the machine. Sandboxes that already exist keep their runtime: recreate the `dind` ones. What it does not do: - **Without sysbox, sandboxes run `privileged` (the `dind` runtime).** Code with root inside such a sandbox can escape to the host. By default (`DEFAULT_RUNTIME=auto`) new sandboxes use the `sysbox` runtime wherever [sysbox](https://github.com/nestybox/sysbox) is installed on the (Linux) host, which runs Docker in an unprivileged container; Docker Desktop cannot run sysbox, so there they fall back to `dind`, and Purgatorium says so at startup. Only let people and agents you would give a shell on the host run code in `dind` sandboxes. - **`sysbox` is not a VM:** its sandboxes run unprivileged, in their own user namespace, but share the host's kernel, so a kernel exploit would still reach the host. - There is no network policy between a sandbox and the internet, and no hard disk quota per sandbox: only a warning above its disk limit (see [Disk](https://csakaszamok.github.io/p9m/0.12/resources/#disk)); CPU and memory limits exist (see [CPU and memory](https://csakaszamok.github.io/p9m/0.12/resources/#cpu-and-memory)). - The Purgatorium container mounts the host's Docker socket, so whoever controls Purgatorium controls the host. - A [host folder](https://csakaszamok.github.io/p9m/0.12/web-ui/#host-folders) the admin mounts into a sandbox (`HOST_MOUNT_ROOTS`) is the sandbox owner's, as root, and every app's and agent's in it: read-only only limits writing. Keep the roots narrow. For untrusted code (public sign-up, code from strangers), use sysbox or a microVM-based sandbox instead; a Kata Containers runtime (a VM per sandbox) is on the [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). Found a vulnerability? See [SECURITY.md](https://github.com/csakaszamok/p9m/blob/main/SECURITY.md). # Upgrading To a new version, in the install directory: ``` curl -fsSLO https://github.com/csakaszamok/p9m/releases/latest/download/docker-compose.yml docker compose pull && docker compose up -d ``` If `.env` sets `P9M_VERSION`, change or remove it. Each release's notes are in the [changelog](https://github.com/csakaszamok/p9m/blob/main/CHANGELOG.md). ## From Purgatory (p7y) There is no upgrade from Purgatory (p7y), the project's former name: [install](https://csakaszamok.github.io/p9m/0.12/install/index.md) Purgatorium afresh, in a new directory called `p9m`. Nothing is carried over: sandboxes, archives, users, tokens and settings start empty, and `P7Y_*` settings and `p7y.*` labels are not read. # Why Purgatorium, and how it compares *As of October 2026. What is said about other products comes from the public sources listed at the bottom, and can change quickly.* ## What Purgatorium is for **A place where a team's apps run: the ones people build with AI agents or vibe-code in an afternoon.** Everyone gets their own space, every app gets a URL colleagues can open, and what nobody uses costs nothing. On your own server, not in someone else's cloud. Think of it as **a self-hosted Vercel or Railway for the whole team**: you hand an agent a `p9m.env`, it deploys a `docker-compose.yml`, and a minute later `https://shop-web.example.com` works. ## How teams do this today, and what goes wrong | Today | What goes wrong | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **It runs on someone's laptop** | No URL to show it, it stops when the lid closes, and it only works on that machine. | | **A cloud PaaS** (Vercel, Railway, Render, Fly.io, Netlify) | Every experiment is another project on the bill; code and data leave the company network; a multi-container stack (app + database + worker) has to be rebuilt service by service; Vercel runs request-driven functions, not always-on processes. | | **One shared VPS with Docker or Portainer** | Everyone is on one Docker daemon: they see each other's containers, ports collide, one runaway stack slows everyone down, one wrong `docker rm` hits someone else. | | **Coolify or Dokploy** | Built for running a team's production apps: an admin sets up projects, everything keeps running all the time, teams on one server still share the daemon. | | **A VM for everyone** | Expensive while idle, and someone has to create, patch and clean up every one of them. | ## What Purgatorium does instead - **Self-service, like a cloud PaaS.** Anyone signs in (Google or any OIDC) and creates a sandbox; a quota and CPU / memory limits keep one person from taking the whole server. - **Its own Docker for everyone.** Every sandbox has its own Docker daemon: what an agent produces (`docker-compose.yml` with app, database, cache) runs as it is, and nobody sees or breaks anyone else's containers. - **A URL for every app, automatically.** Whatever a sandbox publishes gets `https://-.`; databases and SSH get a TLS address on port 443. No DNS, no proxy rules per app. - **Idle costs nothing.** A sandbox without traffic falls asleep and wakes on the next request; after a longer sleep its containers are freed. Twenty experiments, three in use: the server carries three. - **Made for coding agents.** **Let your agent deploy…** (the Claude Code plugin with `/p9m:init`, or a zip for any agent) gives the agent a token for that one sandbox, Docker access from outside and a registry login; every pushed image is scanned for secrets before anyone can pull it anonymously. - **Your server, your data, no bill per app.** One `docker compose up` on one Linux host, also inside a company network; no paid edition. - **Nothing is lost.** Delete archives the sandbox's configuration and data. ## Against a cloud PaaS (Vercel, Railway, Render, Fly.io) Purgatorium gives a team what these give one developer, on hardware the team already has. | | Vercel / Railway / Render / Fly.io | Purgatorium | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Where it runs, where the data is | Their cloud | Your server, also inside a company network | | What you pay for | Every project, service and its usage | The server | | What you deploy | Mostly one service per app (Vercel: request-driven functions, also from a Dockerfile); Fly.io's Docker Compose support is basic | A whole `docker-compose.yml`, as it is, with its own Docker daemon | | Idle apps | Railway, Render (free tier) and Fly.io can scale to zero; otherwise they run and are billed | Sleep by default, wake on the next request | | Many people on one account | A team plan; everyone sees the team's projects | Everyone sees only their own sandboxes; tokens limited to one sandbox | | Production features | Global edge, autoscaling, managed databases, backups, zero operations | None of these: one host, you run it | **Pick a cloud PaaS** for an app in production that has to scale and be up at 3 a.m. without you. **Pick Purgatorium** for the many apps before that point: experiments, demos, internal tools, vibe-coded apps, test environments, and for whatever must not leave the company network. ## Against self-hosted tools (Coolify, Dokploy, Portainer) These also run on your own server, but they are built for **one team running its apps**, not for **many people each having their own space**. | | Purgatorium | Coolify | Dokploy | Portainer CE | | ------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | | Separation between teams on **one** server | each sandbox has its own Docker daemon and network | teams get their own servers; on a shared server the daemon is shared | a shared daemon; members get access per project | a shared daemon; roles (read-only, operator) only in the paid Business Edition | | What the dashboard hides, Docker also hides | yes | no | no | no | | Who creates an environment | anyone, within a quota | an admin or team member, in the team's project | an admin, or a member given the permission | an admin | | Idle apps | sleep | keep running | keep running | keep running | | Roles and access control | free | free (admin, member) | free (owner, admin, member); custom roles in Enterprise | basic in CE, RBAC paid | On a shared Docker daemon, anyone who may deploy an arbitrary compose file can, for example, bind-mount host paths or attach to another project's network: the separation in the dashboard does not reach the daemon. **They work well together:** try and build in a Purgatorium sandbox, push the image to its registry, run it in production with Coolify or Dokploy. A Purgatorium sandbox can also have its own Portainer (the `portainer` template), for that sandbox's daemon alone. ## Looks similar, does a different job These come up in the same searches ("sandbox", "dev environment", "AI agent"), but they solve another problem: - **Coder, GitHub Codespaces, Gitpod / Ona, DevPod:** a workspace for **writing code** (an IDE, a repo, a terminal), not a place where the finished app keeps running for others. They fit next to Purgatorium: write in Coder or on the laptop, run in Purgatorium. - **E2B, Daytona, Docker Sandboxes, Modal, Northflank sandboxes, kubernetes-sigs/agent-sandbox, Alibaba OpenSandbox:** they let an agent **execute code** in an isolated box for minutes or hours, driven from an SDK, usually inside a microVM. None of them is meant to keep a team's app online at a URL for weeks. ## Where Purgatorium is weaker 1. **Isolation is not yet a boundary against hostile code.** The `dind` runtime runs the sandbox `privileged`: root inside a sandbox can escape to the host. That is fine for colleagues and their own agents, not for strangers. The `sysbox` runtime removes `privileged` and is used in production, but still shares the host's kernel; a **Kata Containers** runtime, a lightweight VM with its own kernel per sandbox, is on the [roadmap](https://csakaszamok.github.io/p9m/0.12/roadmap/index.md). 1. **One host for now.** No multi-host scheduling and no warm pool; a new sandbox takes about 10 seconds once its images are pulled (the first one on a new server a minute or two), a wake from deep sleep about 30. **Kubernetes support** is on the roadmap. 1. **Sleep stops the containers.** On wake the stack restarts by its restart policies; what was only in memory is gone. 1. **Missing pieces:** no teams yet (a sandbox has one owner), no hard disk quota (only a warning above a disk limit; CPU and memory limits exist), no egress rules, no SDK (a REST API with an [OpenAPI document](https://csakaszamok.github.io/p9m/0.12/api/#openapi-and-clients) to generate a client from, the Docker CLI and [agent-guide.md](https://csakaszamok.github.io/p9m/0.12/agent-guide/index.md)). 1. **A young, small project:** no community yet, no external security audit. ## When to choose something else - **Production that must scale and stay up without you** → a cloud PaaS, or Coolify / Dokploy on your own servers. - **Untrusted code from strangers** (public sign-up) → a microVM sandbox (E2B, Docker Sandboxes, Northflank), until Purgatorium has a Kata runtime. - **Writing code in a cloud IDE** → Coder or Codespaces. - **An agent running short scripts from an SDK** → E2B, Daytona or agent-sandbox. - **GPU or ML work** → Modal. ## Sources - Cloud PaaS: [Railway serverless (app sleeping)](https://docs.railway.com/deployments/serverless), [Railway vs Vercel: containers and serverless](https://render.com/articles/railway-vs-vercel), [Vercel: Docker Compose concepts](https://vercel.com/kb/guide/docker-compose-concepts-on-vercel), [Vercel runs Dockerfiles](https://cloudnativenow.com/features/vercel-now-lets-you-deploy-any-dockerfile-straight-to-production/), [Docker on Render](https://render.com/docs/docker), [Railway vs Render vs Fly.io](https://devtoolpicks.com/blog/railway-vs-render-vs-fly-io-solo-developers-2026), [Fly.io: Docker Compose compatibility](https://community.fly.io/t/docker-compose-compatibility-the-journey-begins/25285) - Coolify: [concepts: teams and servers](https://coolify.io/docs/get-started/concepts), [roles and permissions](https://coolify.io/docs/core/team/roles-and-permissions), [security model](https://coolify.io/docs/core/security-model) - Dokploy: [GitHub](https://github.com/dokploy/dokploy), [permissions](https://docs.dokploy.com/docs/core/permissions), [custom roles (Enterprise)](https://docs.dokploy.com/docs/core/enterprise/custom-roles) - Portainer: [CE vs Business Edition](https://oneuptime.com/blog/post/2026-03-20-portainer-ce-vs-business-edition/markdown), [multi-tenant setup](https://oneuptime.com/blog/post/2026-03-20-portainer-multi-tenant-setup/markdown) - Coder: [GitHub](https://github.com/coder/coder), [workspace scheduling](https://coder.com/docs/user-guides/workspace-scheduling) - Agent sandboxes: [Docker Sandboxes](https://www.docker.com/products/docker-sandboxes/), [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox), [OpenSandbox](https://github.com/alibaba/OpenSandbox), [Northflank: Daytona vs E2B](https://northflank.com/blog/daytona-vs-e2b-ai-code-execution-sandboxes), [Upstash: 15 providers compared](https://upstash.com/blog/ai-agent-sandbox-providers-compared-2026) - Kata Containers: [limitations](https://github.com/kata-containers/kata-containers/blob/3.2.0/docs/Limitations.md), [Kata with Docker](https://oneuptime.com/blog/post/2026-02-08-how-to-use-kata-containers-with-docker-for-enhanced-isolation/markdown)