# panel-api HTTP API wrapper around panelctl with a web UI. Default bind: `127.0.0.1:9911` ## Endpoints ### Health & UI | Method | Path | Description | |--------|------|-------------| | GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/health` | Health check | | GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | | GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | | POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | | GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | | GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | ### Apps — Read | Method | Path | Description | |--------|------|-------------| | GET | `/apps` | List all apps | | GET | `/apps/` | Show single app manifest | | GET | `/apps//routes` | Get parsed route entries | | GET | `/apps//status` | Container status (running/stopped) | | GET | `/apps//compose` | Read compose.yaml content | | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | | GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | | GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | | GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | | GET | `/apps//volumes` | Volumes the file browser can open | | GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | | GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | | PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | | DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | ### Apps — Write | Method | Path | Description | |--------|------|-------------| | POST | `/apps/init` | Create a new app | | POST | `/apps//routes` | Update routes (hot — Caddy reloads automatically); answers `needs_deploy` when a newly published port needs a deploy | | POST | `/apps//deploy` | Queue a deployment (compose up + caddy reload) — see *Deployments* | | POST | `/apps//restart` | Queue a restart (compose down + up) | | POST | `/apps//stop` | Stop (compose down) | | POST | `/apps//render-route` | Re-render Caddy route | | POST | `/apps//compose` | Save compose.yaml content | | POST | `/apps//validate-compose` | Validate compose file | | POST | `/apps//backup` | Create volume backup (zip) | | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | | POST | `/apps//repo-pull` | Git apps: queue a sync (fetch branch, hard-reset checkout to it, deploy) | | POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` (`deploy` queues a deployment) | | POST | `/apps//volume-clear` | Stop the app and empty its default data folder | | POST | `/apps//autodeploy` | Auto deploy on push: `{"enabled": true, "register": true, "regenerate": false}` | | POST | `/compose/inspect` | `{"content": "..."}` → services of a compose file and the container ports they mention | Write operations are serialised per app. While one runs, another write to the same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. Deploys, restarts and syncs are the exception: they are queued (see below). ### Deployments `deploy`, `restart` and `repo-pull` run in the background, one at a time per app, and answer `202` at once with the queued deployment: ```json {"ok": true, "deployment": {"id": 12, "app": "blog", "kind": "sync", "trigger": "manual", "title": "Sync from git", "status": "queued", ...}} ``` A newer request replaces one that is still queued (that one ends as `cancelled`, "superseded by #13"). Send `{"wait": true}` to block until it ends; the answer then has `ok`, the `deployment` and its log in `stdout` / `stderr`, like the old synchronous API. | Method | Path | Description | |--------|------|-------------| | GET | `/apps//deployments?limit=N` | History, newest first (the last 50 are kept, with logs) | | GET | `/deployments/` | One deployment | | GET | `/deployments//log` | Its full log | | GET | `/deployments//stream?offset=N` | Server-sent events: `status`, `log` (`{text, offset}`) while it runs, then `done` | | POST | `/deployments//cancel` | Cancel a queued or running deployment | | POST | `/deployments//redeploy` | Deploy that deployment again: its commit for git apps, its saved compose file otherwise (a rollback) | A deployment has `status` (`queued`, `running`, `success`, `failed`, `cancelled`), `kind` (`deploy`, `sync`, `restart`), `trigger` (`manual`, `webhook`, `rollback`), `commit_sha` / `commit_subject` / `commit_author` for git apps, `error`, and `created` / `started` / `finished` / `duration`. ### Webhooks (auto deploy) `POST /hooks/` deploys an app whose auto deploy is switched on. It is routed past Authelia by the NixOS module and authenticated with the app's secret instead: an `X-Forgejo-Signature` / `X-Gitea-Signature` / `X-Hub-Signature-256` HMAC of the body, or the secret itself as `X-Gitlab-Token`, `X-Panel-Token` or `?token=`. For git apps a push to another branch is ignored; everything else queues a sync. `GET /apps//autodeploy` returns `enabled`, the webhook `url` and `secret`, the `branch`, whether the panel registered the webhook on Forgejo (`forgejo.hook_id`) and the `last` delivery. Enabling it with a Forgejo token stored adds the webhook to the repository (the token needs write access to it). ### Logs, metrics and terminal | Method | Path | Description | |--------|------|-------------| | GET | `/apps//logs/stream?tail=N&service=S` | Server-sent events: `lines` (`{lines: [...]}`) as the containers write them, `end` when they stop | | GET | `/apps//stats` | Per container: current CPU / memory / network / block IO and an hour of `[time, cpu %, memory bytes]` history | | GET | `/apps//services` | Compose services with the container ports they mention | | GET (WebSocket) | `/apps//terminal?container=C` | Shell in a container (`podman exec`). Send `{"type": "input", "data": "..."}` and `{"type": "resize", "cols", "rows"}`; output arrives as binary frames | ### Route targets A route points at the container port it serves instead of an upstream: ```json {"domain": "blog.reudy.net", "target": "web:2368"} ``` `target` is a port (`2368`, for a compose file with one service), `service:port`, or `host:port` for something outside the app (`127.0.0.1:8081`, same as the older `upstream` field). For service targets the panel picks a free port in 18000–19999 and `panelctl` publishes the container port on `127.0.0.1:` through a generated `.panel-ports.yaml` compose override, so compose files need no `ports:`. Routes keep their port when they are saved again. In `/status` such routes have `service` and `port` next to the `upstream` Caddy uses. ### Create app (git repository, with environment variables) ```json { "name": "blog", "routes": [{"domain": "blog.reudy.net", "target": "web:2368"}], "auth": true, "source_type": "git", "repo_url": "https://git.reudy.net/reudy-net/blog.git", "repo_branch": "", "use_forgejo_token": true, "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], "env_inject": true } ``` - `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. - `repo_token` sets an https token explicitly; `use_forgejo_token` uses the token stored in Settings (only for URLs on the configured Forgejo host). - An empty branch uses the repository's default branch. The compose file must be at the repository root. - The older `source_type: "github"` with `github_url` / `github_branch` / `github_pat` is still accepted. ### Status response (`/status`) ```json { "ok": true, "time": 1790460650, "apps": [ { "name": "whoami", "routes": [{"domain": "whoami.reudy.net", "upstream": "127.0.0.1:18080"}], "auth": true, "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", "repo_url": "", "repo_branch": "", "autodeploy": false, "busy": null, "queued": false, "last_deployment": {"id": 7, "status": "success", "title": "Deploy", "finished": 1790460640, "...": "..."}, "status": { "state": "running", "running": true, "running_count": 1, "total": 1, "containers": [{"name": "whoami-app-1", "state": "running", "status": "Up 3 minutes", "image": "docker.io/traefik/whoami:latest", "running": true}] } } ] } ``` `state` is one of `running`, `partial` (some containers down), `stopped` or `unknown`. ## Example payloads ### Create app (single route) ```json { "name": "whoami", "routes": [ {"domain": "whoami.reudy.net", "target": "80"} ], "auth": true } ``` ### Create app (multiple routes, different services) ```json { "name": "myapp", "routes": [ {"domain": "app.reudy.net", "target": "web:3000"}, {"domain": "api.app.reudy.net", "target": "api:8080"} ], "auth": true } ``` ### Create app (multiple routes, different ports, with paths) ```json { "name": "pocketbase", "routes": [ {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"} ], "auth": true } ``` The `path` field is optional. When present, it generates a Caddy `reverse_proxy /_/* 127.0.0.1:8090` rule, letting you route requests to a specific path prefix within a domain. ### Create app (wildcard domain) ```json { "name": "wildcard", "routes": [ {"domain": "*.reudy.net", "upstream": "127.0.0.1:18082"} ], "auth": false } ``` Note: Wildcard domains require DNS challenge configuration in Caddy. ### Update routes (hot) ```json { "routes": [ {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, {"domain": "api.reudy.net", "upstream": "127.0.0.1:18081"}, {"domain": "pb.reudy.net", "upstream": "127.0.0.1:8090", "path": "/_/*"} ] } ``` The optional `path` field generates a Caddy `reverse_proxy ` rule for sub-path routing. Caddy reloads automatically via the systemd path watcher. Containers stay running. ### Save compose ```json { "content": "services:\n app:\n image: nginx:latest\n ports:\n - '127.0.0.1:18080:80'\n" } ``` ### Remove and keep volumes ```json { "keepVolumes": true } ``` ### Restore from backup ```json { "file": "whoami-20260101-120000.zip" } ``` ## Routes response ```json { "ok": true, "name": "myapp", "routes": [ {"domain": "app.reudy.net", "upstream": "127.0.0.1:18080"}, {"domain": "api.reudy.net", "upstream": "127.0.0.1:18081", "path": "/api/*"} ] } ``` The `path` field is only present when a route has a path configured. ## Response format All JSON responses include an `ok` boolean: ```json { "ok": true, "apps": [...] } ``` Error responses: ```json { "ok": false, "error": "description", "stderr": "panelctl error output" } ``` ## Status response ```json { "ok": true, "name": "whoami", "running": true, "containers": [ { "name": "whoami-app-1", "state": "running", "image": "docker.io/traefik/whoami:latest" } ] } ``` ## Local test ```bash curl -s http://127.0.0.1:9911/health | jq . curl -s http://127.0.0.1:9911/apps | jq . curl -s http://127.0.0.1:9911/apps/whoami/status | jq . curl -s http://127.0.0.1:9911/apps/whoami/logs?tail=50 | jq . curl -s http://127.0.0.1:9911/apps/whoami/backups | jq . ```