panel/API.md
agent 71fed39f17 Deployments, auto deploy, service routes, live logs, metrics and a terminal
Deployments: deploy, restart and git sync now run in the background, one at
a time per app (a newer request replaces a queued one). Each run is recorded
in SQLite with its log, streamed to the UI while it runs, and can be
cancelled. Any earlier deployment can be deployed again, which rolls back to
its commit, or to its saved compose file for compose apps.

Auto deploy: POST /hooks/<app>, verified with the app's secret (Forgejo,
Gitea and GitHub HMAC signatures, or the secret as a token for CI). With a
Forgejo token stored, the panel adds the webhook to the repository itself.
The NixOS module routes /hooks/* past Authelia. Caddy matches the cleaned
path but forwards the original, so the panel refuses dot segments and only
accepts webhook deliveries from that route (tagged with X-Panel-Hook).

Domains: a route can point at a compose service's container port
("web:8080"). The panel picks a free 127.0.0.1 port and panelctl publishes
it through a generated .panel-ports.yaml override, so compose files need no
ports: section. Existing host:port upstreams keep working.

Logs stream live over server-sent events, with service and text filters.
A sampler keeps an hour of CPU and memory per container for the new
Monitoring tab. The Terminal tab opens `podman exec` in a container over a
WebSocket, using xterm.js bundled by the Nix package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
2026-09-27 19:05:19 +00:00

12 KiB
Raw Blame History

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/<name> Show single app manifest
GET /apps/<name>/routes Get parsed route entries
GET /apps/<name>/status Container status (running/stopped)
GET /apps/<name>/compose Read compose.yaml content
GET /apps/<name>/logs?tail=N Fetch last N log lines (default 100)
GET /apps/<name>/backups List available backups
GET /apps/<name>/backups/<file> Download backup zip
GET /apps/<name>/env Environment variables: {"vars": [{"key", "value"}], "inject": true}
GET /apps/<name>/repo Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh)
GET /apps/<name>/repo?fetch=1 Same, plus fetches the remote and reports behind / remote
GET /apps/<name>/volumes Volumes the file browser can open
GET /apps/<name>/volume/files?vol=&path= List a folder in a volume
GET /apps/<name>/volume/download?vol=&path= Download a file from a volume
PUT /apps/<name>/volume/files?vol=&path= Upload a file (raw body)
DELETE /apps/<name>/volume/files?vol=&path= Delete a file or folder

Apps — Write

Method Path Description
POST /apps/init Create a new app
POST /apps/<name>/routes Update routes (hot — Caddy reloads automatically); answers needs_deploy when a newly published port needs a deploy
POST /apps/<name>/deploy Queue a deployment (compose up + caddy reload) — see Deployments
POST /apps/<name>/restart Queue a restart (compose down + up)
POST /apps/<name>/stop Stop (compose down)
POST /apps/<name>/render-route Re-render Caddy route
POST /apps/<name>/compose Save compose.yaml content
POST /apps/<name>/validate-compose Validate compose file
POST /apps/<name>/backup Create volume backup (zip)
POST /apps/<name>/restore Restore from backup
POST /apps/<name>/remove Remove app
POST /apps/<name>/repo-pull Git apps: queue a sync (fetch branch, hard-reset checkout to it, deploy)
POST /apps/<name>/env Replace environment variables: {"vars": [...], "inject": true, "deploy": false} (deploy queues a deployment)
POST /apps/<name>/volume-clear Stop the app and empty its default data folder
POST /apps/<name>/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:

{"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/<name>/deployments?limit=N History, newest first (the last 50 are kept, with logs)
GET /deployments/<id> One deployment
GET /deployments/<id>/log Its full log
GET /deployments/<id>/stream?offset=N Server-sent events: status, log ({text, offset}) while it runs, then done
POST /deployments/<id>/cancel Cancel a queued or running deployment
POST /deployments/<id>/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/<name> 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/<name>/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/<name>/logs/stream?tail=N&service=S Server-sent events: lines ({lines: [...]}) as the containers write them, end when they stop
GET /apps/<name>/stats Per container: current CPU / memory / network / block IO and an hour of [time, cpu %, memory bytes] history
GET /apps/<name>/services Compose services with the container ports they mention
GET (WebSocket) /apps/<name>/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:

{"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:<port> 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)

{
  "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)

{
  "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)

{
  "name": "whoami",
  "routes": [
    {"domain": "whoami.reudy.net", "target": "80"}
  ],
  "auth": true
}

Create app (multiple routes, different services)

{
  "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)

{
  "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)

{
  "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)

{
  "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 <path> <upstream> rule for sub-path routing.

Caddy reloads automatically via the systemd path watcher. Containers stay running.

Save compose

{
  "content": "services:\n  app:\n    image: nginx:latest\n    ports:\n      - '127.0.0.1:18080:80'\n"
}

Remove and keep volumes

{
  "keepVolumes": true
}

Restore from backup

{
  "file": "whoami-20260101-120000.zip"
}

Routes response

{
  "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:

{
  "ok": true,
  "apps": [...]
}

Error responses:

{
  "ok": false,
  "error": "description",
  "stderr": "panelctl error output"
}

Status response

{
  "ok": true,
  "name": "whoami",
  "running": true,
  "containers": [
    {
      "name": "whoami-app-1",
      "state": "running",
      "image": "docker.io/traefik/whoami:latest"
    }
  ]
}

Local test

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 .