# Purgatory (p7y) > Self-hosted sandboxes for your team and their AI agents Purgatory (p7y) 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/p7y-dev/p7y/releases/latest/download/check-host.sh | bash -s -- dev.example.com ``` Or download it, read it, then run it: ``` curl -fsSLO https://github.com/p7y-dev/p7y/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 p7y && cd p7y curl -fsSLO https://github.com/p7y-dev/p7y/releases/latest/download/docker-compose.yml curl -fsSL https://github.com/p7y-dev/p7y/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 `p7y`: 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 `p7y.` (`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/p7y-dev/p7y`. It carries the sandbox templates and runtimes, and the files the other services read: on every `up` the one-shot `p7y-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/p7y-dev/p7y/blob/main/CONTRIBUTING.md). The stack runs Traefik v3.6 with the Sablier plugin, Sablier, a registry and the Purgatory API. The UI is at `http://p7y.` (by default `http://p7y.lvh.me`, which resolves to `127.0.0.1`), the API also on port `8081` of the host. 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. Sandboxes from older versions (`opt/users//`) are moved there at startup; a running one restarts once. > **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 Purgatory 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; Purgatory logs a startup warning if any of them is still in effect. All settings: [Configuration](https://p7y-dev.github.io/p7y/0.7/configuration/index.md). For HTTPS: [HTTPS](https://p7y-dev.github.io/p7y/0.7/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": "p7y-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://p7y.` 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://p7y.` | The address of the UI as users see it; `https://…` turns on the HTTPS checks (see [HTTPS](https://p7y-dev.github.io/p7y/0.7/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://p7y-dev.github.io/p7y/0.7/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; Purgatory 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 | | `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://p7y-dev.github.io/p7y/0.7/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://p7y-dev.github.io/p7y/0.7/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_DISK` | `20g` | Disk use above which a sandbox is flagged; a warning only, see [Disk](https://p7y-dev.github.io/p7y/0.7/resources/#disk) | | `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 Purgatory's version, stars and forks. The server fetches the counts at most once an hour (users' browsers never contact GitHub); `off` never asks GitHub, and the link and the version stay | | `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 Purgatory 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; an old `HOST_USERS_DIR` (the former `opt/users`) still works, its sibling `sandboxes` is used | | `P7Y_API_BIND` | `0.0.0.0` | Interface of the direct API port `8081`; `127.0.0.1` for a public deployment | | `P7Y_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 | # HTTPS Purgatory 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://p7y.` and strong `ADMIN_TOKEN`, `SESSION_SECRET` and `ADMIN_PASSWORD` in `.env`. 1. `docker compose up -d p7y traefik`. At startup Purgatory 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 Purgatory (`docker compose up -d p7y`) to go back to plain HTTP. ## Trying it locally with HTTPS (e.g. on Windows) With `PUBLIC_URL=https://p7y.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 p7y traefik ``` Then close the browser completely and open `https://p7y.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 `P7Y_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`, Purgatory **refuses to start** while `ADMIN_TOKEN`, `SESSION_SECRET` or `ADMIN_PASSWORD` is a default or `.env.example` value (or `SESSION_SECRET` is unset). Sandboxes created before HTTPS support only had an HTTP router. At startup Purgatory adds `websecure` to their router in the compose file and recreates just their socat container (a running sandbox's apps are unreachable for a few seconds; an asleep one stays asleep; the DinD container and its inner stack are not touched). If that fails, the address shows a "needs to be re-created" page and the next start retries. A running sandbox whose address no router matches shows "This address does not reach the sandbox" instead of an endless waiting page. # Using Purgatory # Web UI and sign-in Purgatory has a small web UI (`http://p7y.`) 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://p7y.lvh.me`, see [HTTPS](https://p7y-dev.github.io/p7y/0.7/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 Purgatory 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": ""}`). Purgatory never puts a sandbox to sleep on its own. Running sandboxes' web addresses do not go through Purgatory: they keep working while it is down; waking needs it, and so do the TLS TCP addresses and Docker access (they go through Purgatory'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 and a table per owner. From **Access tokens** (`/settings/tokens`), a signed-in user can mint a personal access token (`p7y_…`) for their own agents to use as `Authorization: Bearer p7y_…`; 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 Purgatory 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://p7y-dev.github.io/p7y/0.7/agent-guide/index.md). # 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://p7y-dev.github.io/p7y/0.7/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 Purgatory 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://p7y-dev.github.io/p7y/0.7/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 Purgatory 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://p7y-dev.github.io/p7y/0.7/networking/#tls-tcp-addresses); a port on `127.0.0.1` stays private to the sandbox; a `p7y.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 Purgatory 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 Purgatory settings in its environment, so an unknown `${VAR}` is empty. Sandboxes created before runtimes existed keep working from their own files; their panel shows the old template name (`dind-standard`) and no runtime. # 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 Purgatory's catch-all `/wake`, which wakes it within the owner's limits (see [Web UI and sign-in](https://p7y-dev.github.io/p7y/0.7/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. 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`): Purgatory 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 Purgatory (Docker restarting it, `docker compose start` by hand, or Purgatory restarted while it was starting one) has no session at first; Purgatory 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 Purgatory 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 details panel's **Settings** 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 — Purgatory reads it from the compose file. `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. Sandboxes created before limits existed get the default when Purgatory starts (a running one that already uses more keeps going and gets it from its next start). The panel shows the sandbox's current CPU and memory against its limits (sampled every 5 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. Purgatory 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://p7y-dev.github.io/p7y/0.7/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. Purgatory checks every `DEEP_SLEEP_CHECK_INTERVAL` (default `1m`). Requests for a deep-sleeping sandbox have no sandbox router, so they reach Purgatory'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 details panel's **Settings** 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 Purgatory 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/p7y-alice-2026-09-26T21-47-01-401Z mkdir -p opt/sandboxes/alice@example.com cp -r $A/config opt/sandboxes/alice@example.com/p7y-alice for f in $A/volumes/*.tar.gz; do v=$(basename $f .tar.gz) docker volume create --label com.docker.compose.project=p7y-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/p7y-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 Purgatory API itself requires authentication (see [Web UI and sign-in](https://p7y-dev.github.io/p7y/0.7/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://p7y-dev.github.io/p7y/0.7/roadmap/index.md). ## 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 Purgatory'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 `p7y.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 created since foxglove 0.6.0 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 `frpc.subdomain: shell` gets no TCP address. Sandboxes created before have no SSH. ## 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 Purgatory 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://p7y.example.com/sandboxes/p7y-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://p7y-dev.github.io/p7y/0.7/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 $P7Y_TOKEN" "https://p7y.example.com/sandboxes/p7y-shop/files/content?path=/root/notes.md" ``` A token limited to the sandbox may use all of them. Over [MCP](https://p7y-dev.github.io/p7y/0.7/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. # 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 p7y.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 Purgatory sandbox — settings in p7y.env.* The file holds `P7Y_URL`, `P7Y_TOKEN`, `P7Y_SANDBOX` and `P7Y_AGENT_GUIDE`. Or tell the agent everything yourself, for example: > Deploy this repo to my Purgatory sandbox `p7y-shop` at `https://p7y.example.com` with the token `p7y_…`. Follow https://p7y-dev.github.io/p7y/latest/agent-guide/index.md. [docs/agent-guide.md](https://p7y-dev.github.io/p7y/0.7/agent-guide/index.md) is written for the agent: every call it needs, tried against a live Purgatory. 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 Purgatory as an MCP server with the same token: see [MCP](https://p7y-dev.github.io/p7y/0.7/mcp/index.md). ## 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. Purgatory 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. Sandboxes created before this need new certificates once: **Access → Enable** (restarts the sandbox). - **Export for coding agents…** and **Copy as .env** sit at the top of the panel's **Access** tab. **Copy as .env** puts `p7y.env` (a new token for the sandbox) on the clipboard. **Export for coding agents…** downloads `p7y-.zip`; before a sandbox has Docker access it offers to enable it, or to export without the Docker keys. When the sandbox's Portainer is public, both also carry `P7Y_PORTAINER_URL` and a Portainer API token (`P7Y_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 Purgatory token does not revoke it: delete it in the sandbox's Portainer (My account → Access tokens). The zip holds `p7y.env` (with a new token limited to the sandbox, `P7Y_DOCKER_HOST`, `P7Y_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 Purgatory 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 Purgatory 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 Purgatory about pushes with a secret Purgatory 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. # 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: ``` claude mcp add --transport http p7y https://p7y.example.com/mcp --header "Authorization: Bearer p7y_…" ``` Other clients take the same URL and header, e.g. `{"mcpServers": {"p7y": {"type": "http", "url": "https://p7y.example.com/mcp", "headers": {"Authorization": "Bearer p7y_…"}}}}`. Left out: the log stream (it never ends) and the zip export (a file a tool result cannot carry). 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) # Purgatory 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 Purgatory sandbox. Every call below was run against a live Purgatory. Every sandbox has its own Docker daemon. The agent works with: - the **Purgatory 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 Purgatory API calls as tools instead: see [MCP](https://p7y-dev.github.io/p7y/0.7/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 | | --------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- | | Purgatory URL | `https://p7y.example.com` | The address of the Purgatory UI | | Personal access token | `p7y_…` | UI → **Access tokens** → **+ New token** | | Sandbox name | `p7y-shop` | The name in the UI list with the `p7y-` prefix (sandboxes created before the rename to Purgatory use `leander-`) | The user may instead give these in a `p7y.env` file in the project (UI → the new token → **Download p7y.env**; it may also be renamed `.env` or copied into one): `P7Y_URL`, `P7Y_TOKEN`, `P7Y_SANDBOX` (only for a token limited to one sandbox; otherwise ask which sandbox, or list them with `GET /sandboxes`) and `P7Y_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 Purgatory API call sends `Authorization: Bearer p7y_…`. 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/p7y-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` for a sandbox created before Docker access existed (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); - `ca_cert`, `client_cert`, `client_key`: the Docker client certificates (PEM); - `tunnel_urls`: the app addresses. If `status` is not `running`, wake the sandbox and poll until it is: ``` POST /sandboxes/p7y-shop/start GET /sandboxes/p7y-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 Save the three certificates as files exactly as they are (they end their lines with ; write them unchanged), then make a Docker context for the sandbox: ``` mkdir -p ~/.p7y/shop # ca.pem = ca_cert, cert.pem = client_cert, key.pem = client_key from GET /sandboxes/p7y-shop docker context create p7y-shop --docker "host=tcp://shop-docker.example.com:443,ca=$HOME/.p7y/shop/ca.pem,cert=$HOME/.p7y/shop/cert.pem,key=$HOME/.p7y/shop/key.pem" docker --context p7y-shop ps ``` Only TLS with these certificates gets in: Purgatory passes the connection through without opening it. Keep the key as secret as the token. If the user gave you an export folder instead (`p7y-/` with `p7y.env`, `docker/` and a README), the certificates are in its `docker/` and `p7y.env` has `P7Y_DOCKER_HOST`: ``` docker context create p7y-shop --docker "host=$P7Y_DOCKER_HOST,ca=$PWD/p7y-shop/docker/ca.pem,cert=$PWD/p7y-shop/docker/cert.pem,key=$PWD/p7y-shop/docker/key.pem" ``` ## 3. Deploy an app A compose project in the sandbox, built and run there: ``` docker --context p7y-shop compose -p shop up -d --build ``` ``` services: web: build: . ports: ["8080:8080"] restart: unless-stopped labels: frpc.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. - **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 `p7y-shop` on `example.com`: - with a `frpc.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/p7y-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://p7y-dev.github.io/p7y/0.7/networking/#tls-tcp-addresses). ## 4. Update the app Change the code or the image tag and run the same `docker --context p7y-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 Purgatory's registry, so an image can be pulled elsewhere too: ``` docker login registry.example.com -u shop # password: the token docker --context p7y-shop tag shop-web registry.example.com/shop/web:3 docker --context p7y-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://p7y-dev.github.io/p7y/0.7/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 `p7y.env` has `P7Y_PORTAINER_URL` and `P7Y_PORTAINER_TOKEN`, skip the sign-in: use `P7Y_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", …}] ``` In an older sandbox (created before its Portainer was started with `-H unix:///var/run/docker.sock`), the list can be empty until someone clicks **Get started** in the Portainer UI. 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 frpc.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 Purgatory 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 Purgatory 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 | | `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://p7y-dev.github.io/p7y/0.7/sleep-wake/index.md)), `cpus` and/or `memory` (see [CPU and memory](https://p7y-dev.github.io/p7y/0.7/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 **Settings** 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 p7y.env text | | `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://p7y-dev.github.io/p7y/0.7/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 | | `DELETE` | `/sandboxes/:name` | Archive the sandbox, then remove it (**Archive…** in the UI; see [Delete = archive](https://p7y-dev.github.io/p7y/0.7/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, your running sandboxes and limit, and `server_places`: the sandbox places on the server, `used` and `limit` (`null` without a server limit) | | `GET` | `/tokens` | List your personal access tokens (`hint`: the token's start and end, e.g. `p7y_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 | 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://p7y.example.com/swagger.json -o p7y.json npx openapi-typescript p7y.json -o p7y.d.ts # use it with openapi-fetch ``` or any language [openapi-generator](https://openapi-generator.tech/) supports (`openapi-generator-cli generate -i p7y.json -g python -o p7y-client`). The document's version is Purgatory's own, so regenerate after an upgrade. More reading: [architecture diagrams](https://p7y-dev.github.io/p7y/0.7/architecture.html), [how agents deploy](https://p7y-dev.github.io/p7y/0.7/agent-guide/index.md), [comparison with other sandbox platforms](https://p7y-dev.github.io/p7y/0.7/comparison/index.md), [roadmap](https://p7y-dev.github.io/p7y/0.7/roadmap/index.md). # Security Purgatory 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://p7y-dev.github.io/p7y/0.7/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`, Purgatory 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 created before 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 Purgatory 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://p7y-dev.github.io/p7y/0.7/resources/#disk)); CPU and memory limits exist (see [CPU and memory](https://p7y-dev.github.io/p7y/0.7/resources/#cpu-and-memory)). - The Purgatory container mounts the host's Docker socket, so whoever controls Purgatory controls the host. 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://p7y-dev.github.io/p7y/0.7/roadmap/index.md). Found a vulnerability? See [SECURITY.md](https://github.com/p7y-dev/p7y/blob/main/SECURITY.md). # Upgrading To a new version, in the install directory: ``` curl -fsSLO https://github.com/p7y-dev/p7y/releases/latest/download/docker-compose.yml docker compose pull && docker compose up -d ``` If `.env` sets `P7Y_VERSION`, change or remove it. Each release's notes are in the [changelog](https://github.com/p7y-dev/p7y/blob/main/CHANGELOG.md). ## From 0.4 (an install from a git clone) From 0.5 Purgatory no longer needs the checkout: the sandbox templates and runtimes, the Sablier themes, the error pages and the registry config come from the image. **First** check that Docker Compose is 2.17 or newer (`docker compose version`, or run `check-host.sh`): an older one refuses the new compose file, and the site stays down until it is updated. Then, in the checkout directory, `git pull` and **right away** `docker compose up -d --remove-orphans`. The pull removes `dynamic/errors.yml`, which `up` writes again; this first `up` also recreates Traefik once, so it reads the new `dynamic/`. `.env`, `data/`, `opt/`, `certs/` and `dynamic/` are used as they are. **A template or runtime you edited in the checkout no longer applies**: the image's are used. ## From Leander The project used to be called Leander. After pulling this version: - **Run** `docker compose up -d --remove-orphans`: the service is now called `p7y`, so the old `leander` container is replaced. The compose project name still comes from the directory, so an existing checkout keeps its volumes (registry, registry cache, Sablier sessions). - **If you renamed or moved the checkout** (for example `leander/` → `p7y/`): the compose project name changes with the directory, so stop the old stack first (`docker compose -p leander stop`), or it keeps ports 80/443. Sandbox compose files hold absolute paths to their files; at startup Purgatory points them at the new directory and recreates their containers (running ones and ones that failed to start are started, asleep ones stay asleep). - **UI address:** `p7y.`; the old `leander.` redirects to it. If you set `PUBLIC_URL`, change it, and register the new `$PUBLIC_URL/auth/callback` with your OIDC provider. Everyone signs in once more (the session cookie is now `p7y_session`). - **Existing sandboxes** (`leander-`, `leander.*` labels) keep working as they are; new ones are `p7y-`. A name is taken under either prefix, since both would get the same URLs. - **Personal access tokens:** existing `ldr_…` tokens keep working; new ones are `p7y_…`. - **Settings:** `LEANDER_API_BIND` still works; the new name is `P7Y_API_BIND`. The e2e scripts read `P7Y_API` / `P7Y_TOKEN` and fall back to `LEANDER_API` / `LEANDER_TOKEN`. # Why Purgatory, 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 Purgatory 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 `p7y.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 Purgatory 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.** **Copy as .env** or **Export for coding agents…** 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) Purgatory gives a team what these give one developer, on hardware the team already has. | | Vercel / Railway / Render / Fly.io | Purgatory | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | 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 Purgatory** 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**. | | Purgatory | 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 Purgatory sandbox, push the image to its registry, run it in production with Coolify or Dokploy. A Purgatory 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 Purgatory: write in Coder or on the laptop, run in Purgatory. - **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 Purgatory 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://p7y-dev.github.io/p7y/0.7/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://p7y-dev.github.io/p7y/0.7/api/#openapi-and-clients) to generate a client from, the Docker CLI and [agent-guide.md](https://p7y-dev.github.io/p7y/0.7/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 Purgatory 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)