nixos/panel/API.md
agent c1ff6c8176 panel: Forgejo integration, ssh deploy key and per-app environment variables
Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

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

8.1 KiB

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)
POST /apps/<name>/deploy Deploy (compose up + caddy reload)
POST /apps/<name>/restart 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: fetch branch, hard-reset checkout to it, redeploy
POST /apps/<name>/env Replace environment variables: {"vars": [...], "inject": true, "deploy": false}
POST /apps/<name>/volume-clear Stop the app and empty its default data folder

Write operations are serialised per app. While one runs, another write to the same app returns 409 with {"ok": false, "error": "...", "busy": "deploy"}. deploy returns the compose output in stdout (or stderr on failure).

Create app (git repository, with environment variables)

{
  "name": "blog",
  "routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}],
  "auth": true,
  "source_type": "git",
  "repo_url": "https://git.srazka.com/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.

Sync response (repo-pull)

{
  "ok": true,
  "stdout": "HEAD is now at d7df557 Bump image tag\n...compose output...",
  "before": {"sha": "4fb7976...", "short": "4fb7976", "subject": "Initial compose", "author": "reudy", "time": 1790460618},
  "after": {"sha": "d7df557...", "short": "d7df557", "subject": "Bump image tag", "author": "reudy", "time": 1790460643},
  "changed": true
}

Status response (/status)

{
  "ok": true,
  "time": 1790460650,
  "apps": [
    {
      "name": "whoami",
      "routes": [{"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}],
      "auth": true,
      "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml",
      "repo_url": "",
      "repo_branch": "",
      "busy": null,
      "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.srazka.com", "upstream": "127.0.0.1:18080"}
  ],
  "auth": true
}

Create app (multiple routes, different ports)

{
  "name": "myapp",
  "routes": [
    {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.app.srazka.com", "upstream": "127.0.0.1:18081"}
  ],
  "auth": true
}

Create app (multiple routes, different ports, with paths)

{
  "name": "pocketbase",
  "routes": [
    {"domain": "pb.srazka.com", "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": "*.srazka.com", "upstream": "127.0.0.1:18082"}
  ],
  "auth": false
}

Note: Wildcard domains require DNS challenge configuration in Caddy.

Update routes (hot)

{
  "routes": [
    {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"},
    {"domain": "pb.srazka.com", "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.srazka.com", "upstream": "127.0.0.1:18080"},
    {"domain": "api.srazka.com", "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 .