Live API contract — always reflects the latest committed API_REFERENCE.md. Raw: /docs/api-reference.md

ServerAvatar OSS — API Reference

Base URL: https://sv-oss.23-172-120-38.nip.io/api

Auth: Bearer token (Sanctum). Cookie-based session also supported for browser clients. Send Accept-Language for localised error messages.

Response envelope: named top-level key, never a generic data wrapper.

{"user": {"id": 1, "username": "admin", …}}
{"applications": [{"id": 1, "name": "shop", …}]}

Errors:

Status Shape When
401 {"message": "Unauthenticated."} Missing or invalid token
403 {"message": "…"} Permission denied
404 {"message": "Not Found."} Resource not found
422 {"message": "…", "errors": {"field": ["…"]}} Validation failure
500 {"message": "…", "reference": "…"} Server-op failure; reference locates the log entry

Timestamps: DD-MM-YYYY HH:mm:ss + _human sibling (e.g. created_at_human: "3 minutes ago").

Pagination: page-based. Response includes meta: {current_page, per_page, total, last_page}. Pass ?page=N&per_page=20.

Text matching and ordering: database-backed search parameters are case-insensitive on SQLite, MySQL/MariaDB and PostgreSQL. Human-facing names and labels are also ordered case-insensitively, whether ordering is selected through ?sort= or fixed by the endpoint. Structured filters remain exact because their values are canonical IDs, enums, booleans or dates. Git-provider repository search is provider-controlled.

Rate limits: a per-endpoint limit ("Throttle: 20/min" below) counts that endpoint only, per user — /backups/7 and /backups/8 are the same endpoint. (Until 2026-09-23 every such limit shared one counter per user, so unrelated actions used up e.g. "Run backup now".) Over the limit: 429.

Permissions: read operations need view ability; mutations need manage. Middleware notation: permission:<name> or permission:<name>,manage.


Auth

POST /auth/register

Create the first admin account. Registration closes after this call.

Request: name, username and a confirmed password are all required (422 otherwise).

{"name": "Admin", "username": "admin", "password": "…", "password_confirmation": "…"}

Response 201: the user and an API token for it.

{"user": {"id": 1, "username": "admin", "is_admin": true, "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"}, "token": "1|…"}

POST /auth/login

{"username": "admin", "password": "…"}

Response 200: Sets session cookie. Returns the user object.

{"user": {"id": 1, "username": "admin", "is_admin": true, "roles": [{"id": 1, "name": "Administrator", "slug": "administrator"}], "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"}}

POST /auth/logout

Auth-gated. Destroys the session/token.

Response 204: null


GET /auth/me

Auth-gated. Returns the current user + impersonated_by if an admin is currently viewing as this user.

{"user": {
  "id": 1, "username": "admin", "is_admin": true,
  "roles": [{"id": 1, "name": "Administrator", "slug": "administrator"}],
  "impersonated_by": null,
  "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"
}}

PUT /auth/profile

Auth-gated. Update own name and username.

Request: {"name": "New Name", "username": "newusername"} — both required.

Response 200: {"user": {...updated...}}


PUT /auth/password

Auth-gated. Change own password.

Request: {"current_password": "…", "password": "…", "password_confirmation": "…"}

422 on password when the new password is the same as the current one.

Response 200: {"token": "…"} — a fresh API token. Every other token of this user is revoked, so a bearer client must switch to this one (the session cookie stays valid).


POST /auth/stop-impersonating

Auth-gated. Exit impersonation mode (admin feature).

Response 204: empty. 422 when not impersonating. Read GET /auth/me for the admin's own user.


Admin — Roles

GET /admin/roles

Permission: access-admin (view)

Paged. ?search= case-insensitively matches name and description; ?sort=name|created_at (prefix - for descending, default name ascending; name ordering is case-insensitive); ?per_page=10|20|30|50|100, default 10. Responds meta{current_page, per_page, total, last_page}.

{"roles": [{
  "id": 1, "name": "Administrator", "slug": "administrator", "is_system": true, "description": null,
  "permissions": [
    {"level": "server", "name": "system_user", "title": "System User", "access": "manage",
     "permissions": {"view": true, "manage": true}}
  ],
  "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"
}]}

is_system: true — system roles cannot be renamed, deleted, or have their permissions changed (422).


POST /admin/roles

Permission: access-admin (manage)

Request:

{"name": "Developer", "description": "Can deploy",
 "permissions": [
   {"level": "server", "name": "application", "access": "manage"},
   {"level": "server", "name": "database", "access": "view"},
   {"level": "server", "name": "logs", "access": "none"}
 ]}

Each item needs level and name — the same name can exist at two levels. Send access (none | view | manage); the older {"view": bool, "manage": bool} pair is still accepted, and manage always implies view however it is sent.

Response 201: {"role": {...}}


PUT /admin/roles/{role}

Permission: access-admin (manage)

Request: same shape as POST /admin/roles. The permissions array replaces the role's grants — send the full set, not a delta.

Response 200: {"role": {...}}

422 if the role is is_system: true.


DELETE /admin/roles/{role}

Permission: access-admin (manage)

Response 204: null

422 if the role is is_system: true, or if it is the only role of any user (the message names them; every user keeps at least one role). Users who also hold another role simply lose this one.


Admin — Users

GET /admin/users

Permission: access-admin (view)

Paginated. ?search= case-insensitively matches name and username; ?filter[is_admin]=0|1; ?per_page=10|20|50|100.

{"users": [{"id": 1, "username": "admin", "is_admin": true, "roles": [...], "created_at": "…", "last_active_at": "…"}], "meta": {"current_page": 1, "per_page": 20, "total": 3, "last_page": 1}}

last_login_at, last_login_at_human, last_login_ip (added 2026-10-08, OLD-17) on every user: when and from where they last signed in. Null until their next sign-in — there is nothing to fill it from for older ones.

POST /admin/users

Permission: access-admin (manage)

Request:

{"name": "Dev", "username": "dev", "password": "…", "password_confirmation": "…", "is_admin": false, "role_ids": [2]}

Response 201: {"user": {...}}


PUT /admin/users/{user}

Permission: access-admin (manage)

Request: {"name": "Senior Dev", "username": "senior-dev", "is_admin": false, "role_ids": [2, 3]} — name, username and is_admin are required. Removing is_admin from the last admin is a 422.

Response 200: {"user": {...}}


DELETE /admin/users/{user}

Permission: access-admin (manage)

Response 204: null

Cannot delete yourself (422).


PUT /admin/users/{user}/reset-password

Permission: access-admin (manage)

Request: {"password": "…", "password_confirmation": "…"}

password_confirmation is required and must match. Minimum 10 characters, mixed case, at least one number.

Response 204: empty body.


PUT /admin/users/{user}/roles

Permission: access-admin (manage)

Request: {"role_ids": [1, 2]}

Response 200: {"user": {"id": 2, "roles": [...]}}


POST /admin/users/{user}/impersonate

Permission: access-admin (manage)

Become this user for the session (admin feature). Cannot impersonate yourself or another admin.

Response 201: {"user": {…}, "impersonated_by": {"id": 1, "username": "admin"}}


Admin — Permissions Catalog

GET /admin/permissions

Permission: access-admin (view)

Everything the role form needs: the flat catalog, the same rows pre-grouped, and the names of the three states a grant can hold.

{
  "permissions": [
    {"name": "dashboard", "title": "Dashboard", "level": "server", "sub_level": "server", "sub_level_title": "Server", "icon": null, "url": null},
    …
  ],
  "groups": [
    {"level": "server", "sub_level": "server", "sub_level_title": "Server", "permissions": [ … ]},
    {"level": "application", "sub_level": "application", "sub_level_title": "Application", "permissions": [ … ]}
  ],
  "access_levels": [
    {"key": "none",   "title": "No access",    "description": "Hidden from this user. The menu item does not appear at all."},
    {"key": "view",   "title": "Read only",    "description": "Can open the screen and see everything on it, but cannot change anything."},
    {"key": "manage", "title": "Read & write", "description": "Can open the screen and make changes — create, edit and delete."}
  ]
}

POST /admin/permissions/sync

Permission: access-admin (manage)

Re-runs the permission catalog seeder and re-syncs the protected Administrator role, so permissions shipped by a new panel version become available without a manual step. Idempotent — safe to press twice.

Response 200: the same body as GET /admin/permissions, plus "synced": 42 (the number of permissions in the catalog afterwards).

Also runs automatically on every deploy; this endpoint exists for the case where the panel was updated some other way.


Admin — Installation Self-check

GET /admin/doctor

Permission: access-admin (view) | Throttle: 10/min

Runs the same checks as the panel:doctor CLI command against the live server: sudo access, service state, the panel's own health endpoint. Read-only — no check changes anything.

Throttled because each call shells out and makes an outbound HTTP request. It is a diagnostic, not something to poll.

title, detail and fix are all in the viewer's language (detail since 2026-10-08, FS-C37 — it was English in every language). Names, paths, versions and command output inside detail are left as they are.

{"doctor": {
  "healthy": true, "passed": 11, "failed": 0, "warnings": 1,
  "checks": [
    {"key": "privilege", "title": "Privileged commands", "status": "pass", "detail": "…", "fix": null},
    {"key": "frontend_build", "title": "Frontend build", "status": "warn", "detail": "…", "fix": "Rebuild the frontend."}
  ]
}}

Twelve checks: privilege, binaries, services, web_server, driver_contention, database, queue, writable_paths, health_endpoint, php_isolation, account_locks, frontend_build.

status is pass, warn or fail. healthy counts failures only — a warning is worth showing and not worth blocking on, so don't derive health from warnings. title and fix arrive localized; fix is null when there is nothing to do. A check that throws is reported as a fail rather than aborting the run.

warn also means "this check could not be run". web_server reports it when the panel is not permitted to run the config test — sudo -n nginx -t exits non-zero identically for a sudo refusal and a broken config, and calling that second one invalid was a false alarm raised at precisely the moment someone is debugging something else. The missing grant is privilege's finding, and it fails there. When web_server does fail, detail carries the config test's own first line, which names the file and line number.


Admin — Activity Log (admin-wide)

GET /admin/activity-log/filters

Permission: access-admin (view)

Returns every type and action value the system can record, for building filter dropdowns — minus retired ones (config/activity.php retired: features that are gone, whose old rows still render but can never gain new ones).

{"types": ["user", "role", "system_user", "application", "database", …],
 "actions": {"all": ["created", "updated", "deleted", …], "user": ["registered", "logged_in", "password_changed", …]},
 "scopes": [{"value": "account", "label": "Account"}, {"value": "server", "label": "Server"}]}

scopes comes back with its labels already translated into the viewer's locale — the frontend must not carry a second copy of these names in eight languages. Same reason the sidebar section headers come from the API.


GET /admin/activity-log

Permission: access-admin (view)

Paginated (per_page defaults to 10, not 20). Filters: filter[user_id], filter[scope], filter[type], filter[action], search (free-text on type + action + what the event was about — site name, domain, PHP/Node version, IP — + actor name).

filter[scope] is the coarse one — pass account or server and the backend expands it to that scope's type list server-side. Do not build it client-side by sending several filter[type] values; there is no multi-type filter, and the map lives in config/activity.php.

{"activity_log": [{"id": 1, "type": "user", "action": "registered", "scope": "account", "description": "Registered", "user": {"id": 1, "username": "admin", "deleted": false}, "is_system": false, "created_at": "…", "created_at_human": "2 hours ago"}], "meta": {"current_page": 1, "per_page": 20, "total": 150, "last_page": 8}}

description is built at read time from __('activity.'.$type.'.'.$action, $properties) in the viewer's locale — not stored. The properties bag itself is not returned on any activity endpoint: it is the input to that sentence, not a field to read values out of.

filter[kind] (added 2026-10-08, FS-C15, all three logs) ∈ created | changed | removed | failed; anything else is 422. Each row carries kind too, and every …/filters answer carries kinds: [{value, label}] (labels translated). The kind is read from the action name (…_failed → failed, deleted/removed/uninstalled/detached/disconnected → removed, created/added/installed/connected/… → created, the rest → changed).

filter[security]=1 (added 2026-10-08, OLD-18, all three logs) — security events only: sign-ins and failed sign-ins, password and role changes, impersonation, firewall and fail2ban settings, SSH keys / sudo / SSH access of system users, phpMyAdmin and WordPress magic sign-ins, viewed credentials. The list is config/activity.php security. Each row carries is_security.

Failed sign-ins are recorded (added 2026-10-08, OLD-16): user.login_failed on the account the password was tried against (it shows in that user's own history, with the IP), and user.login_failed_unknown for a name that matches no account — the typed name is not stored, people type passwords into it. That row has no user and is_system: false.

scope is which half of the panel the row is about — account (users, roles, permissions, central consent) or server (everything operational) — so the frontend can badge or group without keeping its own copy of the type→scope map. It is null for a type not yet in the map.

A deleted user's rows keep their name (since 2026-10-08, FS-C22): user is {"id": null, "username": "leaver", "deleted": true} and is_system is false. Show it as "leaver (deleted)". Users deleted before this date have no name left, so their rows still read as system.

is_system: true means no person did this — a scheduled reboot, an automatic disk clean, a deploy from a git webhook. It is stated outright rather than left to be inferred from a null user, and the backend deliberately does not paper over it by writing some admin's id onto a system action.


Admin — Server Dashboard

GET /admin/dashboard

Permission: access-admin (view)

{"dashboard": {
  "users": {"total": 3, "admins": 1, "non_admins": 2},
  "roles": {"total": 4},
  "activity": {"today": 12, "total": 380}
}}

Counts only — the account half of the panel. users leaves out the panel's internal system accounts (is_system); activity.today is in the app timezone. Server figures (uptime, load) come from GET /server/facts and GET /server/metrics/live, not here.


Admin — Panel Updates

GET /admin/panel-update

Permission: access-admin (view) | Throttle: 30/min

Pass ?refresh=1 for the "check now" button — it bypasses the cached release lookup and calls the release host. Throttled, so don't poll it.

{"panel_update": {
  "installed": {
    "version": "1.0.0", "commit_hash": "a1b2c3d…", "commit_short": "a1b2c3d",
    "branch": "main", "source": "tag",
    "is_git_checkout": true, "has_local_changes": false
  },
  "available": {
    "version": "1.1.0", "published_at": "2026-08-10T09:00:00Z",
    "notes": "…", "url": "https://github.com/…/releases/tag/v1.1.0"
  },
  "update_available": true,
  "preflight": {
    "ready": true,
    "checks": [
      {"key": "git_checkout", "passed": true, "detail": null},
      {"key": "clean_working_tree", "passed": true, "detail": null},
      {"key": "free_disk", "passed": true, "detail": "8 GB"},
      {"key": "free_memory", "passed": true, "detail": "1 GB"},
      {"key": "writable_path", "passed": true, "detail": null}
    ]
  },
  "latest_run": null
}}

Bind the update button to both update_available and preflight.ready — the checks list is for explaining a preflight "no". update_available is true only when the published version is strictly newer than the installed version; POST returns a localized 422 error on version rather than allowing a reinstall or downgrade. is_git_checkout: false means an in-place update is impossible on this box (a packaged install without .git cannot be moved by git checkout), and preflight will say so.

installed.source is one of tag | tag-ahead | file | env | unknown. tag-ahead means HEAD is newer than its nearest reachable release tag; the reported version is that safe comparison baseline, so the latest release is not incorrectly offered as a downgrade.

latest_run is null until an update has been started; afterwards it is the same object POST and the status endpoint return. It is reconciled against the on-disk state file before answering, so a run whose process died along with the panel restart is still reported correctly.


POST /admin/panel-update

Permission: access-admin (manage) | Throttle: 3/min

Starts the update and returns immediately — the runner is detached and the panel is about to restart itself, so there is nothing to wait for.

Response 202: {"panel_update": {...}} (same shape as the status endpoint below)


GET /admin/panel-update/{panelUpdate}

Permission: access-admin (view) | Throttle: 120/min

Deliberately cheap — poll this every few seconds while the progress bar moves.

{"panel_update": {
  "id": 4, "status": "running", "status_title": "Running",
  "current_step": "migrate", "current_step_title": "Running migrations",
  "step_number": 6, "total_steps": 14,
  "from_version": "1.0.0", "to_version": "1.1.0",
  "from_commit": "a1b2c3d", "to_commit": "d4e5f6a",
  "reason": null, "reason_title": null,
  "rolled_back": false, "reference": null,
  "output": "Running database migrations…", "output_truncated": false,
  "started_at": "12-08-2026 10:15:00", "started_at_human": "2 minutes ago",
  "finished_at": null, "finished_at_human": null
}}

Draw the progress bar from step_number/total_steps — don't hardcode the step list. output is the bounded, redacted tail of the detached update log; output_truncated: true means older bytes were omitted. Terminal controls, authorization headers, cookies, common secret assignments/query parameters, and private keys are removed before this admin-only field is returned. On failure, reason is a classified key and reason_title is it localized; reference points at the server log entry. rolled_back says whether the previous release was restored.


Activity Log (own history)

GET /activity-log/filters

Auth-gated — no permission needed.

Same {types, actions, scopes} shape as /admin/activity-log/filters, so one component renders both. The contents are built from the caller's own rows, not from the catalog — a user who has never touched databases does not get a database option that is guaranteed to match nothing. A brand-new account therefore gets empty arrays; that is correct, not a failure.


GET /activity-log

Auth-gated — no permission needed. Own history only; the self-scope is applied first and no filter combination can widen it to another user's rows.

Paginated, per_page defaults to 10. Filters: filter[scope], filter[type], filter[action], search (type + action + what the event was about — site name, domain, version, IP; there is no actor to search, every row is yours).

{"activity_log": [{"id": 1, "type": "user", "action": "logged_in", "scope": "account", "description": "Logged in", "is_system": false, "created_at": "12-08-2026 09:00:00", "created_at_human": "2 hours ago"}], "meta": {"current_page": 1, "per_page": 10, "total": 42, "last_page": 5}}

The user key is absent entirely — not null. The relation is deliberately not loaded (it would be you on every row), and whenLoaded drops the key rather than emitting null. Do not read row.user.username here.


Branding

GET /branding

Unauthenticated.

{"branding": {"panel_name": "ServerAvatar", "logo_url": null}}

Permissions Check

GET /permissions

Auth-gated. Returns every permission the current user holds.

{"permissions": ["dashboard", "application", {"name": "app_backup", "level": "application"}, …]}

GET /permissions/check

Auth-gated. Check if the current user can perform an action.

Query: ?ability=application,manage

Response 200: {"allowed": true}


Server — Capabilities

GET /server/capabilities

Permission: application (view)

What this server is and what it can run — drives which site types are offered.

{"capabilities": {
  "stack": "lemp",
  "web_server": "nginx",
  "capabilities": {"php": true, "node": false},
  "source": "installer",
  "verified_at": "29-07-2026 10:00:00",
  "server_ip": "23.172.120.38",
  "temporary_domain_suffixes": ["nip.io"]
}}

stack is how the box was built (lemp|lamp|ols|mern|docker, or null for a server migrated in from another panel); the inner capabilities object is what it can run now. They diverge legitimately — installing Node on a LEMP box adds the capability without changing how the box was built — so filter on capabilities, never on stack.

source is installer (our install script wrote the row) or detected (the row was missing and the panel probed the box once). web_server is nginx|apache|openlitespeed; mern is never a value here, because MERN uses nginx.

server_ip is the public IP, or null. It is detected from the local route table and cached for an hour. It is deliberately null — not a private address — on a NAT'd instance (most of AWS, GCP, Azure, and anything with a floating IP), where the machine cannot see the address the world reaches it on. Callers must handle null and say "we could not determine this server's address" rather than offering a hostname that resolves nowhere. This is a different value from facts.ip on GET /server/facts — see that endpoint.

temporary_domain_suffixes is every suffix on offer for the throwaway <name>.<ip>.<suffix> hostname the create form builds. The same list decides what counts as a temporary domain, so the frontend cannot invent a hostname the backend then mistakes for the user's own.


Site Types (Application Catalog)

GET /site-types

Permission: application (view)

One entry per installable site type. Each carries its own field schema — the frontend writes one generic form renderer.

One-click Docker apps are data-driven recipes in backend/resources/recipes/<slug>/: recipe.json holds their metadata and compose.yml.tpl their compose template. Their site_type values remain the recipe slugs, so existing applications keep the same identity and rendered compose. Native one-click types and the generic docker type are unchanged.

Which types a server offers (fixed 2026-10-05). site_types is always a JSON array. What a server hosts follows how it was installed: LEMP, LAMP and OLS serve PHP, Node.js and static sites (the four one-click Node apps included); MERN serves Node.js and static; Docker serves containers only. Types the server will never host are left out of the list. A git site is offered wherever any of its rendering types can be served, and the chosen rendering_type must be one the server serves: php on a MERN server is a 422 on rendering_type, on create and on PUT /applications/{application}. The refusal for a type the server does not host says "containers only" on a Docker server and "not set up to host this kind of application" elsewhere.

{"site_types": [{
  "name": "wordpress",
  "title": "WordPress",
  "tagline": "Popular CMS with plugin ecosystem",
  "icon": "WordPressIcon",
  "category": "blog",
  "popular": true,
  "serving_profile": "php",
  "needs_database": true,
  "accepted_engines": ["mysql", "mariadb"],
  "available": true,
  "unavailable_reason": null,
  "unavailable_code": null,
  "installable_runtime": null,
  "has_installer": true,
  "fields": [
    {"name": "domain", "type": "domain", "label": "Domain", "required": true, "placeholder": "shop.example.com"},
    {"name": "php_version", "type": "select", "label": "PHP Version", "required": true, "options": ["8.4", "8.3", "8.2"]},
    {"name": "system_user_id", "type": "select", "label": "Owner", "required": true, "options": [{"value": 1, "label": "siteowner"}]},
    {"name": "site_user_password", "type": "password", "label": "Site Owner Password", "required": true},
    {"name": "web_root", "type": "text", "label": "Web Root", "required": false, "default": "/"}
  ]
}]}

database_engine (added 2026-09-10) appears in fields only when the server has two or more of the engines this type accepts — a select with options, a default, and required: false. Absent means there is nothing to choose and the panel decides; send database_engine on POST /applications to choose.

Today that is NodeBB alone, on a server with both MongoDB and PostgreSQL. The eight types listing mysql, mariadb never get the field: those two cannot coexist on one server, so exactly one is ever usable.

POST /applications answers 422 on database_engine for three distinct cases — an engine the application does not accept, one the server does not have, and (since 2026-09-11) one that is installed and too old. They are separate messages on purpose: pick again, install it first, upgrade what you have.

The version floor is per site type, not global: moodle and nextcloud need PostgreSQL 14+, craftcms 13+, joomla 12+, and every other type names no minimum at all — so the same PostgreSQL 13 refuses Moodle and accepts Joomla on one server. A type whose floor is not met is also dropped from the database_engine picker in fields, and reports available: false with unavailable_code: "database" when it is the only engine — the card and the create endpoint cannot disagree. An engine whose version cannot be read is never refused.

accepted_engines is which engines this type can be installed against — [] when it needs no database, so "no constraint" and "no database" are the same value rather than a null to special-case. Do not infer this list from the type's name. There are now four distinct lists, and NodeBB accepts ["mongodb", "postgresql"] (2026-09-10) — the first type in the catalog naming two unrelated engines, which is exactly the case any inference gets wrong. Order is meaningful: the first engine the server actually has is the one used, so a box with both MongoDB and PostgreSQL keeps making Mongo-backed forums.

Four types accept PostgreSQL as of 2026-09-11 — craftcms, joomla, moodle and nextcloud, each ["mysql", "mariadb", "postgresql"]. PostgreSQL is last for the reason above: an existing server keeps getting MySQL for a new site, and nothing moves database. On a server that has both, database_engine appears in fields as a picker for these four the same way it already did for NodeBB — no client change is needed to offer it.

wordpress, prestashop, mautic and akaunting stay ["mysql", "mariadb"]: upstream has no supported PostgreSQL path for any of them, and offering one would be an install that fails at the application's own setup screen.

available: false with unavailable_code: "database" now covers every database-backed type, not just NodeBB. Until 2026-09-09 the check returned early for anything accepting MySQL or MariaDB — nearly the whole catalog — so on a MongoDB-only server WordPress and the rest reported themselves available, took a filled-in form and failed at provisioning. unavailable_reason names the engines, so the card can say what to install.

Field type values: text, password, select, domain, email, textarea, toggle, repository.

default and placeholder are not interchangeable, and a field never carries both (a test enforces it). A default is a real value: pre-fill the input with it, and the request carries it. A placeholder is ghost text: show it in an empty box, and it is never submitted. Rendering a default as a placeholder produces a form that looks filled in and posts null.

As of 2026-09-03 every free-text field carries at least one of default, placeholder or help, all localized in the caller's locale, so no create form has an empty box with nothing to go on. help is a sentence to show under the input.

The rule the values follow is never show a value that differs from what an empty box actually produces, which is why the table prefixes are not uniform:

Type table_prefix Cleared box produces
wordpress default: wp_ wp_
joomla default: jml_ a random per-site prefix
moodle default: mdl_ mdl_
akaunting default: akt_ no prefix at all
prestashop default: ps_ ps_

Joomla and Akaunting say so in their help; do not paper over it with a placeholder.

options comes in two shapes, and both are already handled by rendering label and submitting value. A plain array of strings (["8.4", "8.3"]) means value and label are the same. Objects ({"value": …, "label": …}) mean they differ — always render label.

That distinction now matters for the locale and country pickers. They used to label every option with its own code, so choosing a site language meant picking between he_IL and hi_IN. They now carry real names, in the viewer's locale, built at read time:

"options": [
  {"value": "af", "label": "Afrikaans"},
  {"value": "sq", "label": "Albanian"},
  {"value": "ar", "label": "Arabic"}
]

value is unchanged and still the exact code the installer takes — nothing about the create request changes. Two consequences for the UI: the lists are sorted by label in the viewer's language (so Åland Islands sorts before Albania, which byte order gets wrong), and they are no longer grouped with the common defaults first — rely on the field's default for pre-selection rather than on position.

available: false → card is greyed. unavailable_reason is the sentence to show; unavailable_code is the value to branch on, and they must not be read the other way round — the sentence is translated and gets reworded.

unavailable_code Meaning Way out
runtime The server has no PHP / no Node Install it — installable_runtime names which
database The type takes an engine this server hasn't got (only NodeBB, which is MongoDB-only) The Databases screen
web_server This web server does not offer the type None — do not offer an action
php_version PHP is installed, but no version in the type's php_version_range (added 2026-09-26) The PHP screen, when the reason names a version to install; none when it says no version in range can be installed from the server's repository (e.g. a type whose range stops at 8.1 on OpenLiteSpeed / Ubuntu 26.04, where LiteSpeed publishes 8.2 – 8.5 only)

installable_runtime is set only for runtime, and is null for the others: nothing installable fixes them, so a card must not offer a button that cannot work.


Recipes (one-click apps)

GET /recipes

Permission: application (view), authenticated. Same API throttle as /site-types.

curl --fail-with-body -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' "$PANEL/api/recipes"
curl --fail-with-body -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' "$PANEL/api/recipes/ghost"

Returns {"recipes": [<catalog entry>, ...]}: exactly the entries from /site-types whose recipe is non-null, in the same order. recipes is always a JSON array, never a keyed object. The shipped Docker catalog contains nineteen recipes. On a non-Docker server it returns {"recipes": []}. Native one-click installers are not Docker recipes and are not included here.

GET /recipes/{slug}

Permission: application (view), authenticated.

Returns {"recipe": <catalog entry>}. The outer recipe is the complete site-type entry; its nested recipe contains public recipe metadata. Unknown, deprecated, non-recipe or stack-filtered slugs return 404. Slugs match [a-z][a-z0-9_]{1,39}. Both routes return 401 without authentication and 403 without application view permission.

Example Ghost response on a Docker server (the existing field labels/help are localized; the CPU count in help reflects that server):

<!-- rc08-ghost-response -->
{
  "recipe": {
    "name": "ghost",
    "title": "Ghost",
    "tagline": "Publishing and newsletters — runs with its own MySQL",
    "icon": "ghost",
    "category": "cms",
    "popular": true,
    "method": "one_click",
    "serving_profile": "docker",
    "needs_database": false,
    "accepted_engines": [],
    "available": true,
    "unavailable_reason": null,
    "unavailable_code": null,
    "installable_runtime": null,
    "has_installer": true,
    "node_version_range": null,
    "php_version_range": null,
    "fields": [
      {
        "name": "memory_limit",
        "label": "Memory limit",
        "type": "text",
        "required": false,
        "advanced": false,
        "placeholder": "512m",
        "help": "Optional. The most memory this app's own container may use, for example 512m or 2g. A bare number means bytes to Docker, not megabytes. Left empty it gets 512m. Any database this app brings has its own separate ceiling."
      },
      {
        "name": "cpu_limit",
        "label": "CPU limit",
        "type": "text",
        "required": false,
        "advanced": false,
        "placeholder": "No limit",
        "help": "Optional. How many CPU cores this app's own container may use — 1 for one full core, 0.5 for half. This server has 8, and Docker will not start a container asking for more. Left empty there is no CPU limit at all. Going past this does not kill anything; the container waits, so the symptom is slowness rather than an error. Any database this app brings is not limited by it."
      }
    ],
    "recipe": {
      "slug": "ghost",
      "version": 1,
      "description": null,
      "services": [
        {
          "name": "ghost",
          "role": "app"
        },
        {
          "name": "db",
          "role": "database"
        }
      ],
      "volumes": [
        {
          "role": "content",
          "path": "/var/lib/ghost/content"
        },
        {
          "role": "db",
          "path": "/var/lib/mysql"
        }
      ],
      "images": [
        {
          "role": "app",
          "ref": "ghost:5-alpine",
          "tested": ["ghost:5-alpine"]
        },
        {
          "role": "db",
          "ref": "mysql:8.0",
          "tested": ["mysql:8.0"]
        }
      ],
      "memory_floor": null,
      "first_run": {
        "kind": "setup_wizard",
        "credential_labels": []
      },
      "inputs": [],
      "docs_url": null,
      "icon_data_uri": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cmVjdCB4PSIwLjAiIHk9IjAuMCIgd2lkdGg9IjI0LjAiIGhlaWdodD0iMjQuMCIgcng9IjIuODgiIGZpbGw9IiNmZmZmZmYiIC8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yNCAxMkMyNCAxOC42Mjc0IDE4LjYyNzQgMjQgMTIgMjRDNS4zNzI1OCAyNCAwIDE4LjYyNzQgMCAxMkMwIDUuMzcyNTggNS4zNzI1OCAwIDEyIDBDMTguNjI3NCAwIDI0IDUuMzcyNTggMjQgMTJaTTExLjgzMjYgMi4zMzg3OUM2LjM3Nzg1IDIuOTUxODkgMy45NTkwMSA1LjIwNzk3IDMuNDExMjYgOS43NDY5OUMzLjM0ODk2IDEwLjI2MzIgMy4yMjY0MiAxMC43ODA1IDMuMTA0NDMgMTEuMjk1NEMyLjkzMjc3IDEyLjAyIDIuNzYyMjEgMTIuNzQgMi43NjIyMSAxMy40NDU4QzIuNzYyMjEgMTcuOTg4NSA2LjU4NTYgMjEuNTU2IDExLjEyODMgMjEuNTU2QzEyLjg5NTkgMjEuNTU2IDE0LjQ0MzMgMjAuODE0NCAxNS44NzU2IDIwLjA0OEMxOS4wNTM2IDE4LjM0NzggMjIuMDMyOCAxNi4yNTk3IDIyLjAzMjggMTIuNTQxMUMyMi4wMzI4IDkuOTE1MTIgMjAuMTA1MSA3LjU2OTMyIDE4LjQ2NiA1LjU3NDdDMTguMzgzNCA1LjQ3NDE2IDE4LjMwMTUgNS4zNzQ1MSAxOC4yMjA2IDUuMjc1NzdDMTcuMzg2NiA0LjI1NzQyIDE0LjQzMzMgMi4wNDY0MyAxMS44MzI2IDIuMzM4NzlaIiBmaWxsPSIjMTUxNzFBIiAvPjwvc3ZnPgo="
    }
  }
}

The recipe block on /site-types and /recipes

Every /site-types entry gains a final recipe key. It is null for docker and every native/non-recipe type. Recipe summaries have exactly these keys, in this order: slug, version, description, services, volumes, images, memory_floor, first_run, inputs, docs_url, icon_data_uri.

Deprecation: recipes declaring deprecated: true are omitted from both catalogs, and their detail endpoint is 404. The existing create validator refuses them with 422 on site_type, using application.unavailable.deprecated: “This one-click app has been retired. Existing sites keep running, but new ones can't be created.” The underlying availability reason code is deprecated, with no installable runtime; it takes precedence over the stack/runtime gates. This does not add an unavailable_code property to validation responses. Existing sites still resolve/render through the registry and retain their recipe origin, with deprecated: true there.

Creation is unchanged. A recipe posts its slug as site_type plus its existing fields. Direct Docker still posts site_type: "docker" with docker_mode: "simple" (image/manual/search) or "compose" (pasted compose). No new request fields or database columns are needed.

How to add a recipe

Add backend/resources/recipes/<slug>/recipe.json and compose.yml.tpl, optionally starter/ files declared by starter_files and a sanitized icon.svg. No PHP registration or installer config entry is needed. Follow the recipe schema, declare explicit tagged images, first-run behavior/curated credentials and safe placeholders; new recipes do not need legacy image override entries. Run cd backend && php artisan recipes:validate, add Pest coverage and new-recipe goldens, and run the full suite plus Pint. Existing tests/Fixtures/recipes-golden/** and GoldenComposeTest.php are immutable: add goldens for the new recipe only, never recapture or alter an existing application's fixtures.


Applications

GET /applications

Permission: application (view)

Paginated, ten per page. Was every application in one response, which is also why it was slow: the resource asks systemd for the state of every application that runs a process, so the page size bounds a number of subprocesses, not just a number of rows.

Query
page 1-based, standard Laravel paging
per_page default 10, max 100 — 422 above that
search case-insensitive free text over name and domain, max 255 chars
filter[status] pending · provisioning · active · failed · paused — active (Running) leaves paused sites out; paused is the sites with is_disabled: true (2026-10-05, junior re-test #5)
filter[site_type] any name from GET /site-types
sort created_at · name · domain · status · site_type · directory_size_bytes — prefix - for descending, default -created_at

Filters combine with each other and with search — all are AND, and all survive a sort.

Sort on the server, not in the table. The list is paged, so an in-table sort orders the current page and nothing else — which looks correct and is not. An unlisted column is a 422, including owner: it lives on a relation, so sorting by it would mean a join, and the list can already be searched by username.

Name sorting is case-insensitive. Capitalization does not split the list into uppercase and lowercase groups: alpha, apple, Banana, Zebra is the ascending order. Names that differ only by case remain stable across pages through the row-ID tie-breaker.

Sorting by directory_size_bytes puts never-measured sites at the small end — before a site of 0 bytes, not mixed in with it. A site nothing has walked yet is not an empty site, and the two must not be confused. Descending therefore reads "biggest first, unknown last". This is set explicitly rather than left to the database: SQLite and MySQL sort NULL first ascending, PostgreSQL sorts it last, and the panel supports all three.

Note the value is the last measured size — null for a site created in the last minute, until the per-minute sweep reaches it. The row already says so.

Both filters are validated against the real sets, so a wrong value is a 422, not an empty list. That matters more than it sounds: answering a typo with {"applications": []} reads to the user as "you have no applications". Send null or omit the key to clear a filter.

Filtering by system user or by server is deliberately not offered — ask rather than filtering client-side over a single page.

{
  "applications": [ … ],
  "meta": {"current_page": 1, "per_page": 10, "total": 14, "last_page": 2}
}

meta describes the filtered set, so page 2 of a one-result search is not an empty screen with a next button.

Every application in the list carries the same full shape as GET /applications/{application} below — this is one resource, used everywhere. The list is not a trimmed variant.

origin (list and detail) is derived from the existing site_type, registry, image and compose; there is no migration or new column. It appears immediately after site_type_title and always has {kind, recipe, image}:

{"kind":"recipe","recipe":{"slug":"ghost","title":"Ghost","version":1,"deprecated":false},"image":null}
{"kind":"image","recipe":null,"image":"neosmemo/memos:0.31.0"}
{"kind":"compose","recipe":null,"image":null}
{"kind":"other","recipe":null,"image":null}

Registry membership wins, including retired recipes and recipe sites with stored compose. Otherwise a docker site with null/empty/whitespace-only compose is image (its stored image reference is returned); nonempty compose is compose. Every other site is other. No secret values or curated credentials are added to the application resource.

{"applications": [{
  "id": 1, "name": "shop", "domain": "shop.example.com",
  "site_type": "wordpress", "site_type_title": "WordPress",
  "origin": {"kind": "other", "recipe": null, "image": null},
  "serving_profile": "php", "rendering_type": null,
  "status": "active", "status_title": "Active", "deployed": true,
  "is_disabled": false, "disabled_at": null,
  "system_user": {"id": 1, "username": "siteowner"},
  "php_version": "8.4", "node_version": null,
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}]}

POST /applications

Permission: application (manage)

Create + queue provisioning. Poll GET /applications/{id} until status leaves provisioning.

Request:

{
  "name": "shop",
  "domain": "shop.example.com",
  "domain_type": "custom",
  "site_type": "wordpress",
  "php_version": "8.4",
  "system_user_id": 1,
  "site_user_password": "…",
  "web_root": "/",
  "git_account_id": null,
  "repository": null,
  "branch": null,
  "package_manager": null,
  "build_command": null,
  "deploy_script": null
}

A site type that needs a database gets one during provisioning, named {slug}_{6 random} — shop_a1b2cd — where the slug is the application's own, the same one naming its directory on disk. Its database user has the identical name. The whole identifier is capped at 32 characters, which is MySQL's limit for an account, not the 64 a database gets; a long slug is truncated, the random tail never is, since that tail is what keeps two similar sites apart. Names are not derived from the domain (a hostname's dots become underscores and consumed most of the budget) and applications created before this change keep the names they were given — nothing is renamed.

domain is trimmed, lowercased, and must be globally unique across every application domain — not only other primary domains. A duplicate returns the normal localized 422 validation response on domain. Creation is atomic even if two requests race for the same hostname: no partial application row is retained, so the same request can be corrected and retried safely.

Response 201:

{"application": {"id": 1, "name": "shop", …, "status": "provisioning"}}

deploy_script matters most on a git site, and only there. Creating a git site queues an automatic first deploy as soon as provisioning finishes (DeploymentTrigger::Initial), and that deploy runs the script — so a script added afterwards on the Deployment screen has already missed the run that decides whether the site comes up. For a Laravel repository whose php artisan migrate lives here, that is the difference between a working site and a 500 until someone deploys again by hand.

deploy_script wins over build_command when both are given, exactly as on any later deploy. Windows line endings are normalised on save.

domain_type is custom or temp, and it is optional — but send it. It records whether the user brought their own name or took the panel's offered <name>.<ip>.nip.io hostname. Omitted, the domain is treated as the user's own. It no longer decides whether the site can have a Let's Encrypt certificate — that is now one rule for every hostname, "does the name resolve to this server" — but it is still worth sending: it is how the panel knows a wildcard-DNS name resolves by construction and needs no lookup, and it is surfaced as is_test on the domain. A wildcard-DNS suffix is detected server-side regardless of the label, so an omitted or wrong value is corrected rather than trusted.

php_version and node_version are checked against what is installed on the server, not only against their shape. A version that is not on the box is a 422 on that field naming the version, so the user can go and install it. This is new as of 2026-09-03 and it closes a hole that was invisible on nginx and Apache: there the FPM pool step failed loudly, but OpenLiteSpeed has no pool step, so a site on an absent lsphp was created, reported active, and answered 503 on every request forever. The same was true of a Node version, whose path goes straight into a systemd unit that is never validated.

Take the version list from GET /site-types (the php_version / node_version field's options) rather than hardcoding one. The php_version field's default is now an installed version too — it used to be the literal 8.4 on every server, which on a box without 8.4 pre-selected a value the API now rejects.

A site served by Node that is created without a node_version is pinned to one (2026-09-23). The server default when it fits the type's node_version_range, otherwise the newest installed version that does; it is recorded on the application and returned as node_version. Left blank, the installer found no Node on its PATH — a Node-RED on a fresh server failed with npm: No such file or directory. Pinned, the site also stays on its Node when the server default changes later. Sending a node_version is unchanged. Git sites that only build with Node are not pinned.

And, as of 2026-09-08, against the version the application itself runs on. A site type may publish a php_version_range ({"min": "7.2", "max": "8.1"}, either end nullable), alongside the node_version_range that has always been there. Sending a version outside it is a 422 naming the range, and the php_version field's default is the newest installed version within the range rather than the newest on the box.

Omitting php_version is checked too, as of 2026-09-11. An empty field is not "no version" — provisioning resolves it to the server default, which is the newest PHP on the box and therefore the version a ceiling exists to exclude. If that default falls outside the type's range the request is a 422 on php_version naming both the range and the default, so send a version in range or leave the field only where the default fits.

This closes the counterpart of the hole above: installed is not the same question as supported. A server whose newest PHP was 8.5 pre-selected 8.5 for PrestaShop, whose current release vendors the old monolithic symfony/symfony — the install died inside a Symfony cache warmer during kernel boot, after the archive had been downloaded, unpacked, chowned and handed a database. Ranges are published only where upstream states one: PrestaShop (7.2–8.5) and Statamic (8.3+) today.

PrestaShop's release follows the site's PHP (2026-09-26). The installer reads PrestaShop's distribution API (api.prestashop-project.org/prestashop, override SERVER_PRESTASHOP_RELEASES_API) and takes the newest stable release whose own PHP range holds the site's version: PHP 8.1 – 8.5 → PrestaShop 9.1 (Classic edition, which bundles PrestaShop's own modules — Checkout, Account, Marketplace), PHP 7.2 – 8.0 → 8.2. It used to follow channel.xml, which still names 8.2.1 as current and caps PHP at 8.1. SERVER_PRESTASHOP_URL still pins one package. Every other type sends null and accepts any installed version, which is the honest answer for a blank PHP site or a git deployment running the user's own code.

The PrestaShop shop name is used (2026-10-06, PR-01). settings.shop_name was passed as --shop_name, an option PrestaShop's installer does not have, so every shop was called "PrestaShop". It is passed as --name now. A name containing any of < > = { } | or a backtick is a 422 on shop_name (errors/application.shop_name_characters): PrestaShop rejects the first five, and its installer silently drops a value containing the last three.

Never send the raw field list — GET /site-types publishes the fields for each type, including this one, with localized labels and help text.

No other site type deploys after creation. The ten marketplace PHP types and four marketplace Node types install during provisioning; php and static sites start from a placeholder. Only git has app_deployment in its feature list at all.

system_user: {"username": "blogowner", "password": "…"} (added 2026-10-08, FS-B9), beside generate_system_user: true: the new account's name (same rules as POST /system-users, and free on the server too) and an optional password, in the same request as the application — no separate POST /system-users to undo if the application is refused. The account is created with that password when provisioning makes it. Without generate_system_user it is 422 on system_user.

GET /applications/{application}

Permission: application (view)

A site whose system user is missing (its system_user_id points at nothing — imported data, or a database without enforced foreign keys): this endpoint and DELETE still work, with system_user: null, document_root: null and path: null. Every other /applications/{application}/… route answers 409 with a message saying so, instead of a 500. Show that message; offer Delete. remove_files is ignored for such a site — without the user's home, the files' location is unknown.

Full application record. Poll this while status is provisioning or deploying.

certificate_pending is true while a certificate has been requested and is not serving yet (status pending or issuing, DS-14). A new site turns active before its Let's Encrypt job runs, and for those 10–60 s https://<domain> fails the TLS handshake ("unrecognized name"). url is still http://… then; show "HTTPS is being set up" rather than letting the user try https and see a broken site. Poll the application until it turns false.

Link to url, never build one from domain. url is http://… until the site has a servable certificate and https://… afterwards. Assembling https://${domain} in the client — which three screens used to do — produces a dead link for every site that has not been issued a certificate yet, which is every site for the first few minutes of its life.

fail2ban_enabled means "this site has a jail configured" — it is derived from the same jail this resource's sibling endpoint reports, so a dashboard card and the site's fail2ban screen can no longer disagree. It went the other way until 2026-08-22: the field came from a stored boolean whose only writer was unreachable, so it read false for every site on the server, including ones with a jail actively running. Do not read the fail2ban_enabled column directly — it is orphaned and stays only because dropping it needs a schema change.

It does not mean fail2ban is running. That is a server-wide fact; GET /services answers it. Live jail state — banned addresses, counters — is on GET /applications/{id}/fail2ban, which costs a fail2ban-client call and so is deliberately not in this response.

**`path` vs `document_root` — they are not the same directory, and picking the wrong one fails silently.**

- **`document_root`** is what the web server serves. Use it to *show* somebody where their site is.
- **`path`** is where the application's own command-line tools run. **This is the value to substitute for `{path}`** in a cron command preset or a deploy script.

They are equal for fifteen of the seventeen site types, because most applications unpack into the directory they are served from. They differ for **Craft** (`document_root` ends `/web`) and **Statamic** (`/public`), whose `craft` and `please` binaries sit one level above the served directory. `php {path}/craft queue/run` handed a `document_root` points at a file that does not exist, and cron reports nothing — so substitute `path`, always, and never derive one of these from the other.

```json
{"application": {
  "id": 1, "name": "shop", "domain": "shop.example.com",
  "url": "https://shop.example.com",
  "document_root": "/home/siteowner/shop/public_html",
  "path": "/home/siteowner/shop/public_html",
  "site_type": "wordpress", "site_type_title": "WordPress",
  "origin": {"kind": "other", "recipe": null, "image": null},
  "serving_profile": "php", "rendering_type": null,

  "status": "active", "status_title": "Active", "deployed": true,
  "is_disabled": false, "disabled_at": null,

  "basic_auth_enabled": false, "basic_auth_username": null,
  "basic_auth_supported": true,
  "basic_auth_unencrypted": false,

  "ai_bot_policy": "block_training", "ai_bot_policy_title": "Block AI training crawlers",
  "waf_supported": true,
  "waf_enabled": true, "waf_mode": "detect", "waf_mode_title": "Just watch, don't block",
  "waf_categories": ["query_string", "request_uri", "user_agent", "referrer", "cookie", "method"],
  "fail2ban_enabled": false,

  "is_staging": false, "production_application_id": null,
  "cloned_from_application_id": null,

  "system_user": {"id": 1, "username": "siteowner"},

  "php_version": "8.4", "node_version": null, "node_version_change": null, "app_port": null,
  "web_root": "/", "build_command": null, "start_command": null,

  "has_process": false,

  "git_account_id": null, "repository": null, "repository_url": null, "branch": null,

  "webhook": {
    "enabled": false, "provider": null, "url": null, "secret": null,
    "verification": null, "last_delivered_at": null, "last_delivered_at_human": null
  },

  "settings": {},
  "steps": [], "failed_step": null,
  "failed_reason": null, "failed_reason_title": null,
  "provisioning_started_at": null, "provisioning_started_at_human": null,
  "last_commit": null, "last_deployed_at": null, "last_deployed_at_human": null,
  "reference": null,

  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}}

failed_reason is usually null, and that is not an omission. For most failures failed_step says where it broke and reference points at the server-ops log entry holding the command's own output — together they say more than any category invented here would, and a wrong reason sends the user to fix something that was never broken.

reference always names an entry that exists — including at create_database, where it previously carried a freshly minted id that appeared in no log at all.

It is set only where the cause is genuinely identified. Render failed_reason_title when present — it is localized in the viewer's locale — and fall back to failed_step + reference when it is null.

failed_reason Meaning
out_of_memory The step was killed by the kernel's OOM killer — exit 137, nothing written at all, so the reference names an empty log.
serving_error The application started, but answers every request with a 5xx. Usually assets that did not build completely.
not_answering The application started but never answered a request at all.
composer_platform Composer refused to install the dependencies under the PHP version the site is set to — the version, or an extension, does not meet what the project requires.
script_php_missing The deploy script uses a {PHPxx} variable for a PHP version not installed on this server; nothing ran. Install it on the PHP screen or use {php}.
script_git_auth The deploy script ran a git command (usually git pull) that needed a login; the script has none, so it fails on a private repository. The panel already fetches the branch before the script runs — remove the line. Only on the script step (since 2026-10-01).
composer_dependencies_missing The project requires Composer packages and none were installed, so there is no vendor/autoload.php and every request to the site would fail. Raised at the new dependencies step, before the site is curled.
container_restarting A Docker site's container keeps restarting. See last_failure for its last log lines.
container_exited A Docker site's container stopped right after starting.
container_port_mismatch Nothing answered on the container port, and the image declares a different one (DS-03). last_failure.message names both.
container_not_answering The container is running but nothing answered on its port within the readiness timeout (90 s).
container_unhealthy The image's own HEALTHCHECK reports the container unhealthy (DS-14). last_failure.message carries the check's last output for users with app_log view.

The last two come from the verify_serving step, which is the final step of provisioning for any application that runs a process of its own. Being started is not the same as working: a forum whose assets never compiled runs, stays up, satisfies systemctl is-active, and answers 500 on every request. So after starting the process the panel requests a page from it on the loopback address and its own port — the same thing the reverse proxy in front of it does, so DNS, the certificate and the vhost are not involved.

Only 5xx fails. A 302 to a login page, a 401, or a 404 at / are all a working application answering correctly. Applications served by the web server rather than by their own process (every PHP site) have no port to probe and skip this step entirely.

When this step fails, reference points at the application's own log, not at the probe. The probe only ever recorded a status code, which says the site is broken without saying why; the journal captured at the moment of failure is where the reason is. If the journal could not be read or was empty, the reference falls back to the probe.

status is pending · provisioning · active · failed — there is no running.

Use provisioning_started_at for elapsed time, never created_at. It is restamped on every run including a retry, whereas created_at is the row's birthday — so after a retry, elapsed time built on created_at counts from the original attempt and keeps climbing. null on a site that has never been provisioned.

status and is_disabled are two different axes. A healthy site can be paused — the vhost is swapped to an "unavailable" page — without its provisioning status changing. The enable/disable button switches on is_disabled, never on status. deployed is simply status === "active", exposed so a "pending" badge cannot imply the site is reachable.

Fields that are absent, not null, unless the endpoint loads them. These use whenLoaded, so the key is missing from the JSON entirely — check with in / hasOwnProperty, not for null:

field present only on
bot_blocked, bot_allowed PUT /applications/{id}/bot-blocker
waf_exceptions, waf_custom_rules GET and PUT /applications/{id}/waf
has_staging never — see the note under Staging Area
process only when has_process is true

directory_size_bytes is always present, and null until measured. It used to be absent in that case, which made an unmeasured site indistinguishable from an endpoint that does not report size — render — on null rather than hiding the column.

It travels with directory_size_measured_at (and _human). Show them together: nothing recomputes this on a timer, so a size on its own reads as current when it may be days old. It is set by a deploy, by a file change (about a minute later — see POST /applications/{id}/directory-size), and by that endpoint.

has_process is null-safe to read everywhere and tells you whether to render process controls at all: PHP and static sites have nothing to run, so the answer is "render nothing", not "render a disabled button".

process (when present): {state, sub_state, since, memory, cpu_percent, pid, restarts, instances, online, supervisor}. cpu_percent and pid were added 2026-10-16 (v7's process detail):

webhook.secret is returned in full, deliberately — the user has to paste it into their repository settings and will come back for it. It is null unless the caller has app_deployment (manage) (2026-09-29): with the URL and the secret anyone can sign a push and start a deploy, so it follows POST /deploy, not the right to view the application. Hide the secret field (not the webhook card) when it is null and webhook.enabled is true. Same for target_webhook.secret on GET /clones/{clone}. webhook.url is assembled server-side so the frontend never builds the path or gets the host wrong. webhook.verification is signature or token; token means a plaintext shared value, which only GitLab has — offer the user the stronger signing token when you see it.

settings is always an object ({} when empty), never []. steps is a genuine list and stays [].

🔴 settings never contains a password (2026-09-24). The installer's passwords (admin_password, mailer_password) used to be saved here and returned to anyone who could view the site, for as long as the site existed. They are now held encrypted, only until the install succeeds, and are never returned. Sending them on PUT /applications/{id} (for example to fix a failed install before Retry setup) still works; they go where the installer reads them, not into settings.


GET /applications/{application}/sidebar

Permission: application (view)

Returns the sidebar nav items for this application, filtered by what this site type supports AND what the user can access.

{"items": [
  {"id": "app_dashboard", "name": "app_dashboard", "title": "Dashboard", "icon": "LayoutDashboardIcon", "sub_level": "info", "url": "/applications/1"},
  {"id": "app_domain", "name": "app_domain", "title": "Domains & SSL", "icon": "GlobeIcon", "sub_level": "info"},
  {"id": "app_deployment", "name": "app_deployment", "title": "Deployment", "icon": "GitBranchIcon", "sub_level": "info"},
  …
]}

POST /applications/{application}/directory-size

Permission: application (view) | Throttle: 10/min

Measure this site's directory now, and store the result.

Response 200: {"directory_size": {"size": 5242880, "size_human": "5 MB", "measured_at": "15-08-2026 16:40:00"}}

Its own call rather than something the listing does. du walks every inode, so the cost is the site's file count — anything with node_modules is a hundred thousand of them — and doing that per row would make the Applications screen as slow as the heaviest site on the box.

The size keeps itself current without this. Thirteen file operations (write, upload, copy, compress, extract, delete, the bulk pair, and the trash ones) queue a re-measure, unique per application and delayed ~60 seconds. A bulk delete of fifty files walks the site once, about a minute after the user stops — not fifty times while they work. Rename and chmod do not trigger it: they move bytes that are already counted.

So after a file operation, do not expect directory_size_bytes to change on the next fetch. It updates roughly a minute later. Either re-fetch then, or simply render directory_size_measured_at and let the number catch up on its own. This endpoint is the "measure it now" button for when the user will not wait.

A failed measure changes nothing — the previous figure and its date stand, because a stale number with an honest date beats a blank.


PUT /applications/{application}

Permission: application (manage)

Update web root / PHP version / git branch.

Request: {"php_version": "8.4", "web_root": "/public", "branch": "main"}

🔴 name is refused here (422), deliberately (2026-09-29). This endpoint used to accept it, and this page used to advertise it. The name becomes the site's slug, and the slug is the filename of six things: the vhost, the PHP-FPM pool and its socket, the site's directory, its logs, its fail2ban jail and its worker units. A rename moved one of them — the vhost — so the site came back pointing at a socket no pool listens on (502) beside a newly created empty site directory (404), with its real files still in the old one. Reproduced on a test server. Moving all six atomically under a live site is a lot of machinery for a label, so the label is fixed: the name is set at creation and a site that needs a different one is a new site.

Response 200: {"application": {...}}


POST /applications/{application}/provision

Permission: application (manage)

Re-run provisioning after a failure. Dispatches ProvisionApplication job.

Response 202: {"application": {"id": 1, "status": "provisioning", "failed_step": null, "reference": null}}


POST /applications/{application}/deploy

Permission: app_deployment (manage)

Trigger a git deploy (git-deploy apps only). 422 for one-click types, and when the site's git account was disconnected (git_account_missing).

Response 202: {"application": {"id": 1, "status": "deploying"}}


POST /applications/{application}/process/{action}

Permission: application (manage)

Start / stop / restart the site's own process (Node apps only). {action} = start | stop | restart.

422 if the app has no process (PHP sites).

Response 200: {"application": {...}}

500 {"message": "…", "reference": "…"} on failure.


POST /applications/{application}/disable

Permission: application (manage) | Throttle: 10/min

Swap vhost to an "unavailable" placeholder page. Files, database, and workers are untouched.

Response 200: {"application": {"id": 1, "status": "disabled"}}


POST /applications/{application}/enable

Permission: application (manage) | Throttle: 10/min

Reverse disable — restore the live vhost.

Response 200: {"application": {...}}


POST /applications/{application}/detect-type

Permission: application (manage) | Throttle: 10/min

Read the site's directory and report what is installed there. Records the verdict on the application; runs no installer and writes nothing to the site.

Response 200:

{"site_type_detection": {
  "detected": "wordpress", "detected_title": "WordPress",
  "confidence": 95, "matched": "wp-config.php",
  "checked_at": "2026-09-17T09:00:00+00:00", "suggested": "wordpress"
}}

suggested is the type to offer the user, or null when there is nothing to offer. It is null — even with a confident verdict — for a git site, when the verdict equals the current type, below confidence 60, when the site is already a specific application, and for any detected type that cannot be relabelled to. Test that one field; the rules are not duplicated client-side.

Button-triggered on purpose. A site's files arrive after it is created, so detecting when the screen opens would run against an empty directory and record "nothing found" at the one moment the answer is guaranteed wrong.

wp-config.php is looked for one level above the web root as well as inside it, because moving it out of the web root is standard WordPress hardening.

PUT /applications/{application}/site-type

Permission: application (manage) | Throttle: 10/min

Relabel a site: tell the panel what is actually installed in it.

Request: {"site_type": "wordpress"}

Response 200: {"application": {"id": 1, "site_type": "wordpress"}}

This changes what the panel offers, not what is on disk. Nothing is installed or downloaded and no installer runs. The site type decides which screens a site gets — WordPress adds Staging, Clone and Magic Login — and this exists because a user who installs WordPress by hand into a Custom PHP site has, as far as the panel is concerned, a Custom PHP site forever.

Allowed changes are not symmetric (422 with a reason otherwise):

From To
php, static wordpress, joomla ✅ only if the disk agrees
anything except git php, static ✅ always — no evidence needed
git anything ❌ git_cannot_change
anything git ❌ git_not_a_target
a specific app another specific app ❌ only_from_generic
php, static any other app type ❌ not_suggestable
any type itself ❌ unchanged

A site type outside the catalog is a 422 on the site_type field itself — a malformed request, not a refused transition. The five refusals above are whole sentences saying what is in the way and what to do instead, because "invalid selection" would be true of all five and useful for none.

The same site_type_detection object is on every application payload, so a screen can show a pending suggestion without calling Detect again. Its checked_at is null until Detect has run at least once.

Widening re-reads the disk at apply time rather than trusting the stored verdict, which closes the window where the application is deleted between Detect and Apply.

A git site cannot be relabelled in either direction. Its Deployments, Workers and .env screens exist because its type is git; removing them does not stop the supervisord workers running or the deploy webhook accepting pushes, so the change would orphan live processes rather than reconfigure them.

Narrowing is always permitted because it gives up features and claims none — the escape hatch for a wrong guess, or a site since replaced by something else.

On OpenLiteSpeed the site type is rendered into the vhost (.htaccess support for LiteSpeed Cache), so the config is republished, tested and reloaded. A disabled site's vhost is left alone — republishing it would put the site back online as a side effect of a relabel.

PUT /applications/{application}/node-version

Permission: application (manage) | Throttle: 10/min

Move a Node site to another installed Node version (2026-10-05, junior re-test #12). Before this there was no way to: node_version on PUT /applications/{application} was silently ignored, so a version any site used could never be removed. That field is now a 422 there, pointing here.

Request: {"node_version": "22.22.0"} — the full version, as listed by GET /node.

Response 202: {"application": {...}} with node_version_change: {"target": "22.22.0", "status": "switching", ...}. Poll GET /applications/{application} until node_version_change is null (it worked, node_version is the new one) or status is failed. The same version the site is already on is a 200 and does nothing.

What it does: rewrites the site's service for the new Node, restarts it, and asks the application for a page (the same check a new site gets). If it does not answer, the previous version's service is written back and restarted, so the site keeps running as before. No file of the application is touched: measured on the nginx test server, every compiled part of n8n, Uptime Kuma, Node-RED and NodeBB loads unchanged on Node 22, 24 and 26. A stopped or disabled site gets the new service without being started. A site with no process (a static or client-rendered git site) only records the version its next build uses.

node_version_change on the application resource: null, or {"target", "status": "switching"|"failed", "reason", "message", "reference"}. On failed, show message (translated); node_version is the version the site is on. reason is one of did_not_start (switched back, running as before), rollback_failed (switching back failed too: check the site), unit_write, install_pm2, worker. A failed switch can be tried again at once.

422 on node_version when it is not installed, outside what the site type runs on (node_version_range on GET /site-types), the site is not a Node site, it still runs under the previous panel's PM2 (convert it first), or a switch is already running.

Frontend: offer the installed versions inside the type's range on the site's Node.js fact; after the change, the old version can be removed from the Node.js screen (its "in use" refusal now says to switch the sites first).

PUT /applications/{application}/web-root

Permission: application (manage) | Throttle: 10/min

Change the served directory (rewrites vhost, tests + reloads).

Request: {"web_root": "/public"}

Response 200: {"application": {"id": 1, "web_root": "/public"}}

The folder must already exist on a live site (2026-10-05, junior re-test #8). A missing one is a 422 on web_root whose message names the full path (/home/owner/shop/public_html/etc), and nothing changes. It used to be created empty, and the site answered 403 at once. Sites not provisioned yet (and disabled ones) still just store the value. The same rule applies when web_root arrives on PUT /applications/{application}. A 500 with a reference means the check itself could not run, not that the folder is missing.

A folder reached through a symlink is refused (2026-10-06, WR-01). If public_html or any folder between it and the new web root is a symlink, the answer is a 422 on web_root naming that path, and nothing changes. A link the site user planted (public_html/pub -> /etc) used to be served, and root chowned its target to the user. The panel no longer runs mkdir/chown on this path at all.

web_root selects what the web server serves; it does not move the code. A git checkout always lands at {app_root}/public_html, and the web root chooses a directory inside it. So a Laravel repository is deployed with web_root: "/public":

/home/owner/shop/
├── public_html/        ← the checkout: artisan, app/, .env
│   └── public/         ← served
└── logs/

This used to move the checkout down with the web root, which left a repository whose front controller lives in public/ with no working configuration at all — empty web root and the served directory has no index; /public and the application's own public/ lands one level too deep. Both 403, and both published the source.

The application's .env follows the code: {app_root}/public_html/.env when a web root separates code from served directory, so the framework reads the same file the Environment screen edits. When there is no web root the code root is the served directory, and the file stays at {app_root}/.env instead — beside the code, but never inside what is served. A site in that state publishes its whole source anyway; the answer is a web root, not a different place to hide one file.


DELETE /applications/{application}

Permission: application (manage)

Delete the application record. Optionally delete its data.

Request body (all optional): {"remove_files": false, "remove_databases": false}

Both flags are accepted as query parameters too (?remove_files=true), and both accept true/false/1/0. Anything else is a 422 — until now remove_files was not validated at all, so a typo silently meant "keep".

Always removed, whatever remove_files says — these are the panel's own artefacts, and every one of them breaks something if it outlives the site:

Removed only with remove_files: true:

So remove_files: true means "destroy this site's data", not "tidy up the directory". Confirm it in the UI accordingly.

Removed only with remove_databases: true:

Needs database (manage) as well as application (manage). Without it the whole request is refused 403 before anything is deleted — the site is still there. Dropping a database is a separate destructive act on a separate resource, not a consequence of deleting a site.

Order is site first, databases after, never the reverse: a database dropped before a site delete that then failed is the data of a site still serving traffic.

Each database is removed independently — one that fails does not stop the rest, and it keeps its panel row, detached (application_id becomes null), so the same delete can be retried from the Databases screen.

Response 200: {"deleted": true}

With remove_databases: true:

{
  "deleted": true,
  "databases": {
    "deleted": ["shop_live"],
    "failed": [{ "name": "shop_logs", "engine": "mysql", "reference": "a1b2c3d4-…" }]
  },
  "message": "The site was deleted, but these databases are still on the server: shop_logs. …"
}

databases is absent entirely when the flag was not sent. message is present only when failed is non-empty, and is already translated. Still 200 in that case, deliberately: the site really is gone, and an error status would tell the panel nothing happened when most of it did. reference correlates to the raw failure in the server-ops log.

?remove_system_user=1 (added 2026-10-08, OLD-20) also removes the site's Linux account when it owns no other application; needs system_user (manage) as well, or the whole request is 403 before anything is deleted. The application is deleted first, and the answer says what became of the account: "system_user": {"username": "deploy", "outcome": "removed" | "still_used" | "has_processes" | "failed", "message": "…"}. Only removed deleted it; for the others the account is still there and the message says why.

GET /applications/port-check

Permission: application (view)

Check if a port is available before creating an app.

Query: ?port=3000&application_id=1 (application_id excludes that app's own port from the check)

Response 200:

{"port_check": {
  "port": 3000, "status": "free", "reason": null,
  "message": "Port 3000 is available."
}}

status: "warning" + reason: "known_service" when free but matches a known service name (e.g. 3000 → grafana).


Application — Domains & SSL

GET /applications/{application}/domains

Permission: app_domain (view)

{"domains": [{
  "id": 1, "domain": "shop.example.com",
  "type": "primary", "type_title": "Primary",
  "redirect_to": null, "redirect_status": null,
  "is_test": false,
  "dns_verified": true,
  "dns_verified_at": "26-07-2026 09:00:00", "dns_verified_at_human": "2 hours ago",
  "dns_resolved_ip": "203.0.113.10",
  "behind_proxy": false,
  "certifiable": true,
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}]}

type is one of primary, alias, redirect — there is no additional. redirect_to and redirect_status (301 / 302) are only meaningful on a redirect row and null elsewhere.

The DNS fields are what gate the SSL button. dns_verified is derived (dns_verified_at !== null) and means "this name currently resolves to this server". Do not offer Let's Encrypt while it is false: Let's Encrypt permits five authorisation failures per hostname per hour, so a guess is expensive.

behind_proxy is broken out on its own because it is the single most common support question this screen produces. Cloudflare's proxy answers on its own addresses, so dns_resolved_ip will not be this server and HTTP validation never arrives — "DNS looks fine but SSL fails". Show the cause; don't let the user hunt for it.

certifiable is whether this name can go on a certificate, and it means exactly one thing: the name resolves to this server (dns_verified_at is set). The suffix is not part of the question — a wildcard-DNS name resolves here by construction and is certifiable like any other.

is_test still tells you what kind of hostname it is, and the panel uses it to skip a DNS lookup that cannot fail. It is a fact about the name, not a verdict on it.


POST /applications/{application}/domains

Permission: app_domain (manage)

Request: {"domain": "www.example.com"}

Response 201: {"domain": {…}} — the full domain object above.

A name is unique across the whole server, not per application. When it is already taken the 422 message names the application holding it, so the user can go and detach it rather than guess.


PUT /applications/{application}/domains/{domain}

Permission: app_domain (manage)

Change what an attached name does. Accepts type (alias | redirect), redirect_to and redirect_status.

Request: {"redirect_to": "https://example.com"}

Response 200: {"domain": {…}} — the full domain object above.

Switching type to alias clears redirect_to, because an alias serves the site itself. Switching to redirect without a target — in the request or already stored — is a 422 on redirect_to.

redirect_to (here and on POST .../domains) must be http(s):// + host + optional path. A query string, fragment, whitespace or any of ; # $ & ? " ' \ { } < > `` `` is a 422 on redirect_to (errors/application.redirect_target_invalid): the target is written into the vhost as is, and those characters mean something there. Every web server appends the request path to the target, so a query string never worked anyway. A trailing / is removed when the target is saved (2026-10-06, DOM-01): the request path brings its own, and https://example.com/ used to send visitors to https://example.com//page. Targets saved before that are written without it the next time their vhost is.

The name itself cannot be changed here, deliberately. A rename would leave the old name in the certificate's lineage, and certbot renew re-validates every name in a lineage and fails the whole renewal when one of them cannot be validated — so it would silently stop the certificate covering the site's remaining, perfectly good names from ever renewing, and the first anyone hears of it is a browser warning up to ninety days later. Renaming stays a DELETE plus a POST, which is visibly two decisions. See stale_domains on the certificate object.

The primary domain is refused with 422: it names the vhost file and both log files, so changing it is what POST …/domains/{domain}/primary is for.


POST /applications/{application}/domains/{domain}/verify

Permission: app_domain (view)

Re-check DNS for this domain. Returns the full domain object with dns_verified, dns_verified_at, dns_resolved_ip and behind_proxy refreshed.


POST /applications/{application}/domains/{domain}/primary

Permission: app_domain (manage)

Promote this domain to primary and synchronize the application's own canonical URL before applying the vhost. The canonical URL uses HTTPS only when the current certificate is servable and covers the new hostname; otherwise it uses HTTP, avoiding an application-level redirect to a name whose certificate is invalid.

The transition is atomic from the caller's point of view. If URL synchronization or vhost application fails, the previous primary domain, application-domain mirror, canonical URL, and vhost are restored best-effort and the request fails; do not update the UI optimistically before the 200 response.

Which types reconcile a canonical URL. Only applications that keep their own copy of the site address have anything to synchronize — the rest are a deliberate no-op, and this is the same list for certificate issue, certificate removal and the primary-domain change above:

wordpress, moodle, mautic, prestashop, akaunting, craftcms, statamic, nextcloud, nodebb, n8n.

joomla, nodered, uptimekuma and phpmyadmin are not on it and are not missing it: none of them is given a URL at install and each detects its address per request. PrestaShop and Akaunting joined the list on 2026-09-03 — before that a shop issued a certificate served pages over HTTPS while its images and generated links still pointed at HTTP, which reads as a broken padlock and is often reported as a certificate fault.

Response 200: {"domains": [...updated list with type reordered...]}


DELETE /applications/{application}/domains/{domain}

Permission: app_domain (manage)

Response 204: null


Application — Certificate

GET /applications/{application}/certificate

Permission: app_domain (view)

null when no certificate is installed — normal state, not an error.

{"certificate": {
  "id": 1,
  "type": "letsencrypt", "type_title": "Let's Encrypt",
  "status": "active",
  "domains": ["shop.example.com", "www.example.com"],
  "missing_domains": [],
  "stale_domains": [],
  "force_https": true,
  "auto_renew": true, "renewable": true,
  "issued_at": "25-07-2026 14:31:00", "issued_at_human": "2 weeks ago",
  "expires_at": "01-09-2026 00:00:00", "expires_at_human": "in 3 weeks",
  "served_expires_at": "01-09-2026 00:00:00", "served_checked_at": "15-08-2026 03:00:00",
  "serving_stale": false,
  "days_remaining": 21,
  "expired": false, "expiring_soon": false,
  "message": "Active until 1 September 2026."
}}

There is no issuer field — use type / type_title.

domains is what the certificate covers; missing_domains is what the site answers to but the certificate does not. They diverge the moment someone adds a domain, and that divergence is exactly the failure this panel exists to catch: the browser reports it, the server logs nothing. A non-empty missing_domains should prompt a re-issue, not a warning buried in a tooltip.

stale_domains is the opposite direction, and it is the more urgent of the two. It lists names on the certificate that the site no longer has — usually because someone removed an alias. It matters because certbot re-validates every name in a certificate and fails the whole renewal if any one of them cannot be validated: a single removed hostname silently stops the certificate renewing for the domains that are still fine, and the first symptom is a browser warning up to ninety days later.

So the two read differently in the UI. missing_domains is "this name has no HTTPS" — visible immediately, fix when convenient. stale_domains is "this certificate has stopped renewing" — invisible until it is fatal, and the fix is a re-issue. Non-empty on a letsencrypt certificate deserves a warning, not a hint. Always empty for self_signed and custom, which nothing renews.

expires_at is the certificate on disk. served_expires_at is the one the web server is actually handing to visitors. They agree on a healthy site. When they do not, a renewal landed on disk and never reached the running process — so the countdown above looks healthy while every visitor gets a browser warning. It is the one certificate failure with no other symptom in the panel, because everything else reads the file.

serving_stale is the answer in one field: true means the site is serving something older than the file, false means they match, and null means nobody has managed to look — nothing listening on 443, or TLS refused. Null must not render as a tick; "we did not check" and "it is fine" are different statements. served_checked_at is stamped even when the read fails, so the two are distinguishable.

Read over loopback with SNI, once a day, so it costs nothing and works on a server behind NAT. A site behind Cloudflare reports this server's certificate rather than Cloudflare's, which is the one being asked about.

renewable is a property of the type — nothing can renew an uploaded or self-signed certificate — while auto_renew is the user's setting. Show a renewal date only when renewable is true.

expiring_soon is its own flag rather than something the frontend computes from days_remaining, so the threshold is one decision in one place (and can move when certificate lifetimes shrink).

Use days_remaining, not expires_at_human, for anything a decision hangs on. expires_at_human is diffForHumans(), which reports a single unit — so on a ninety-day Let's Encrypt certificate 45, 59 and 60 days out all render as "1 month from now", and 89 and 90 both as "2 months". days_remaining is a signed integer (negative once expired, null when there is no expiry date) and is the field to render from.

message is always present: a sentence in the viewer's locale. reason (a classified code) and reference appear only when status is failed — the keys are absent otherwise. Neither ever carries certbot's own output, which contains paths, order URLs and occasionally the account key location.

Response 200 with certificate: null: no cert installed yet.

The same response carries available_types — what this site can actually be given, and why not, where it cannot:

{"available_types": [
  {"type": "letsencrypt", "label": "Let's Encrypt", "available": false, "recommended": false, "renewable": true,
   "reason": "None of this site's domains point at this server yet. Add a DNS A record for one, wait for it to propagate, then try again."},
  {"type": "self_signed", "label": "Self-signed", "available": true, "recommended": true, "renewable": false,
   "reason": "Encrypts traffic immediately and works on any domain, including test and internal ones. Browsers will show a warning…"},
  {"type": "custom", "label": "Uploaded", "available": true, "recommended": false, "renewable": false, "reason": null}
]}

POST /applications/{application}/certificate

Permission: app_domain (manage)

Issue a new certificate (queued, 202) or upload an existing one (synchronous, 201).

Request:

{"type": "letsencrypt", "force": false}

OR

{"type": "custom", "certificate": "-----BEGIN CERTIFICATE-----…", "private_key": "-----BEGIN PRIVATE KEY-----…", "chain": "-----BEGIN CERTIFICATE-----…"}

The upload fields are certificate and private_key — not cert/key. Both are required when type is custom, must start with -----BEGIN, and are checked as a pair before anything is written: a mismatched certificate and key are accepted by the filesystem, fail the web server's config test, and take the site down over a copy-paste. chain is optional.

Where the files go (v7's layout, since 2026-10-08): an uploaded or self-signed pair is written to /etc/ssl/certs/{application slug}.crt and /etc/ssl/private/{application slug}.key (key 0600 root), named after the application rather than its domain. Those are the server's own directories, so a file already there that is not this application's certificate — the trust store's ca-certificates.crt, the distro's ssl-cert-snakeoil.key — is never overwritten: custom answers 422 on certificate, self_signed answers 422 on type, with certificate.file_taken naming the path. Certificates made before that date stay in /etc/ssl/sv-oss/ and are still removed from there.

A certificate v7 left (since 2026-10-13). v7 wrote uploaded certificates to the same two paths, and on Apache/OpenLiteSpeed kept the chain beside them in /etc/ssl/certs/{slug}_ca_bundle.crt. A pair at those paths counts as this application's own — so uploading or self-signing over it works instead of answering file_taken — only when both are plain files (no links), the key belongs to the certificate (compared by public key on the server; the private key is never read), and the certificate covers one of the site's domains. Anything else there is still refused. Server Sync adopts such a pair as a custom certificate (auto_renew: false, chain_path = v7's chain file when present), and also adopts a Let's Encrypt lineage named after the site (--cert-name {slug}, v7's and the panel's naming) when it covers one of the site's names. A custom certificate whose chain_path is v7's _ca_bundle.crt is served with SSLCertificateChainFile (Apache) / CACertFile (OpenLiteSpeed); nginx never had one (v7 appended the chain to the .crt). Removing the certificate, or replacing it with an upload or a self-signed one, also removes that chain file.

There is no domains field. A Let's Encrypt request covers the application's own domains — add or remove domains first, then issue. See certifiable and missing_domains on the certificate object for which of them made it in.

force skips the reachability dry run. Don't default it on: the dry run is what stops a doomed attempt from spending one of the five authorisation failures per hour Let's Encrypt allows. The one legitimate use is a server behind NAT whose public address does not answer to itself — the dry run fails there, but the real challenge arrives from outside and would succeed.

Response 202 (Let's Encrypt): {"certificate": {"status": "pending", "type": "letsencrypt"}} Response 201 (upload): {"certificate": {...}}


PUT /applications/{application}/certificate/force-https

Permission: app_domain (manage)

Force all HTTP traffic to HTTPS via a 301 redirect rule.

Request: {"force_https": true}

422 if no certificate is installed.

Response 200: {"certificate": {"force_https": true}}


DELETE /applications/{application}/certificate

Permission: app_domain (manage)

Remove the certificate and its redirect rule. The application-native canonical URL is switched back to HTTP before the non-TLS vhost is applied. Physical certificate cleanup happens only after HTTP is live.

If URL/vhost transition fails, the previous certificate status, canonical URL, and vhost are restored best-effort. If later physical cleanup fails, the API returns 500 with the normal server-operation reference and retains the certificate row as pending; retry this same DELETE to finish cleanup. A retry after success is also safe and returns 204 when no certificate remains.

Response 204: null


Application — Deployment

GET /applications/{application}/deployments

Permission: app_deployment (view)

Newest first.

Which commit is live: settings.code_on_disk (also on GET /applications/{application} as application.code_on_disk; not in the site list). Deploys are in place: the checkout replaces the files before the script, the dependency check and the verify run. So a deploy that fails after its checkout leaves the new commit live, while last_commit (written on success only) still names the old one.

"code_on_disk": {"commit": "3293703efd3e…", "state": "incomplete", "message": "The last deploy failed after the new code was put in place, so the site is running commit 3293703, which is not fully deployed. Fix the problem and deploy again."}

state: deployed (the last deploy that reached checkout succeeded), incomplete (it failed after the checkout; show message as a warning), deploying (in flight), or null (never deployed). A deploy that failed before the checkout (for example the fetch) changed nothing and is ignored. Show code_on_disk.commit as the running version, not last_commit.

{"deployments": [{
  "id": 12,
  "status": "succeeded", "status_title": "Succeeded", "in_flight": false,
  "trigger": "manual", "trigger_title": "Manual deploy",
  "user": {"id": 1, "username": "admin"},
  "branch": "main",
  "commit_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
  "commit_short": "a1b2c3d",
  "commit_message": "Fix payment redirect",
  "commit_author": "Priya Nair",
  "steps": ["init", "fetch", "checkout", "set_ownership", "seed_env", "script"],
  "failed_step": null,
  "failed_reason": null, "failed_reason_title": null,
  "reference": null,
  "duration": 90,
  "started_at": "28-07-2026 11:00:00",
  "finished_at": "28-07-2026 11:01:30",
  "created_at": "28-07-2026 11:00:00", "created_at_human": "3 days ago"
}], "settings": {
  "branch": "main", "repository": "https://github.com/user/shop",
  "deploy_script": "cd {path}\nnpm install\nnpm run build",
  "deploy_script_customised": true,
  "default_deploy_script": "cd {path}\nif [ -f composer.json ]; then\n    composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader\nfi\n",
  "auto_deploy": false, "webhook_enabled": false,
  "last_commit": "a1b2c3d", "last_deployed_at": "28-07-2026 11:01:30",
  "placeholders": ["{path}", "{branch}", "{domain}", "{php}", "{PHP84}", "{PHP83}"],
  "placeholder_values": {"{path}": "/home/user/shop/public_html", "{branch}": "main", "{domain}": "shop.example.com",
    "{php}": "/usr/bin/php8.4", "{PHP84}": "/usr/bin/php8.4", "{PHP83}": "/usr/bin/php8.3"}
}}

placeholder_values (since 2026-10-01) is what each placeholder becomes for this site, computed by the same code the deploy substitutes with — show it beside each name rather than working values out in the client. {path} is the code root the script runs in (not the web root), and {php} the site's real interpreter path.

{PHPxx} — v7's variables, since 2026-10-01. One per installed PHP version, named like v7: {PHP84} is PHP 8.4's interpreter (/usr/bin/php8.4 on nginx/Apache, /usr/local/lsws/lsphp84/bin/php on OpenLiteSpeed), so a script copied from v7 runs unchanged. placeholders lists the ones this server has. A script using a {PHPxx} whose version is not installed is refused on save (PUT …/deployment-settings and site creation, 422 on deploy_script, deployment.script_php_missing naming the variable and version) and, if the version was removed afterwards, the deploy stops before anything runs with failed_reason: script_php_missing. Prefer {php} (the site's own version) for new scripts.

status is queued · running · succeeded · failed. There is no completed and no pending. Rather than hardcoding which of those are terminal, poll on in_flight — it is the backend's own answer to "is this still going", so a new status added later cannot silently break the list's polling.

output is not in the list. It appears only on the single-deployment endpoint below; fifty deploys each carrying a full build log is a response nobody asked for.

commit_hash is the full 40 characters, commit_short the one people recognise — the old commit key does not exist. commit_author is the commit's own author, which is not the user who pressed deploy.

A failed deploy carries its commit too, as of 2026-09-09. The revision is recorded as soon as it is checked out, several steps before the ones that actually fail — ownership, the deploy script, the restarts, the verify — so a failure names the code it was running rather than showing "No commit message" for code that had definitely been pulled. A deploy that failed before checkout (bad credentials, unreachable remote, a branch that does not exist) has no revision to name and correctly reports commit_hash: null; that is the one case where the field is empty on a failure.

user is null for a webhook deploy — nobody pressed anything, and inventing an actor would be a lie. Render it as "System". On POST …/deployments and …/redeploy the key is absent entirely (the relation is not loaded there); it is present on this list and on the single-deployment view.

steps accumulates step names in the order they completed — init, fetch, checkout, set_ownership, seed_env, script — so the UI can show which stage a running deploy reached instead of a bare spinner. Don't hardcode the list; seed_env was added after the others and more may follow. It copies the repository's .env.example into place and generates a Laravel key, and it does nothing at all when the file already has contents or the repository ships no example. On a failure, failed_step names the one that broke and reference is the id to quote to support; the technical detail lives in the server-ops log under that id, never in the response.

A deployment carries failed_reason / failed_reason_title too, as of 2026-09-17 — the same codes and the same rule as the application's own fields above, rendered in the viewer's locale. Until now the deploy job threw its classification away, so every reason the panel could name was lost on the one path users hit most: redeploying an existing site.

The deploy script runs under the site's own PHP, also as of 2026-09-17. It did not before: composer install resolved against whatever /usr/bin/php pointed at, so a site set to 8.2 on a box defaulting to 8.4 got dependencies built for 8.4 — and composer's generated vendor/composer/platform_check.php, required by vendor/autoload.php, then threw a 500 on every request while every step of the deploy showed green. Scripts need no change; {php} is available for a script that wants to name the interpreter explicitly.

A new dependencies step runs after the script and before the verify: if the repository's composer.json requires real packages and vendor/autoload.php is not there, the deploy fails as composer_dependencies_missing rather than as an unexplained 500 from the verify. A manifest with only require-dev, or only platform entries (php, ext-*), is left alone.

With no deploy script and no build command written, the deploy now runs composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader as the site user (logged as the script step) when — and only when — that same check would have failed: composer.json requires real packages. Since 2026-10-01; before, nothing ran, so every new Composer project failed its first deploy. A site with its own script or build command runs only that. The PHP default_deploy_script now ends with the same install, guarded by if [ -f composer.json ].

No default script runs git pull (since 2026-10-01). The panel has fetched and checked out the branch with the connected account before the script runs; the script itself has no credential, so a pull there failed on every private repository (script_git_auth) and did nothing on a public one. Scripts users already saved are not changed.

deploy_script_customised is false for a saved script that is the default apart from line endings and trailing whitespace. steps lists each stage once (init used to appear twice: it is two commands).

duration is whole seconds, and null until the deploy has both started and finished.


GET /applications/{application}/deployments/latest

Permission: app_deployment (view) | Throttle: progress (polling bucket)

The newest deploy only, for polling while the Deployment screen is open, so a deploy started by a push (webhook) appears without a reload. {"latest": <one history row>}, the same shape as a row of deployments (no output), or {"latest": null} when the site has never deployed.

Recommended use: every ~5 s while the page is visible (2.5 s while latest.in_flight), stop when the tab is hidden. When latest.id is newer than the history's top row, or its status changed, put it at the top / reload the history (and settings, for code_on_disk).

POST /applications/{application}/deployments

Permission: app_deployment (manage)

Start a deploy.

Response 202: {"deployment": {"id": 13, "status": "queued", "in_flight": true, …}}

Response 422 when the site's git account was disconnected (git_account_missing: true): {"message": "This git account is no longer connected, …"} (translated). Nothing is queued and no history row is made. Relink with PUT /applications/{application}/git-account, then deploy again.


GET /applications/{application}/deployments/{deployment}

Permission: app_deployment (view)

The same deployment object as the list, plus output — the full build log, with credentials redacted. Each command appears as $ <step> followed by what it printed.


POST /applications/{application}/deployments/{deployment}/redeploy

Permission: app_deployment (manage)

Re-run the same deployment (re-fetches current branch tip, re-runs build script).

Response 202: {"deployment": {"id": 14, "status": "pending"}}

Response 422 when the site's git account was disconnected (git_account_missing: true): {"message": "This git account is no longer connected, …"} (translated). Nothing is queued and no history row is made. Relink with PUT /applications/{application}/git-account, then deploy again.


PUT /applications/{application}/deployment-settings

Permission: app_deployment (manage)

Update branch, deploy script, auto-deploy toggle.

Request:

{"branch": "develop", "deploy_script": "cd {path}\nnpm install\nnpm run build", "auto_deploy": true}

Response 200: {"settings": {...updated...}}


PUT /applications/{application}/git-account

Permission: app_deployment (manage) | Throttle: 30/min

Re-point an existing git application at a different account, repository or public URL. 404 on a non-git site, like every other app_deployment route.

The account used to be create-time only, which made a revoked token or a disconnected account permanent — the site kept its repository and branch and had no way back to a credential that could read them.

The usual call is just an account id:

{"git_account_id": 3}

That is the common case by a distance — a site that is deployed and correct and has simply lost its credential. Everything not sent is taken from the application as it already is, so the user never restates an owner/repo they did not change. The account is still verified against the existing repository; the short payload skips typing, not checking.

The fuller forms still work, for actually moving a site:

{"git_source": "account", "git_account_id": 3, "repository": "octo/other", "branch": "develop"}
{"git_source": "public_url", "repository_url": "https://github.com/octo/public.git"}

git_source is inferred when omitted: sending git_account_id means the account path, sending repository_url means the public one, and if neither is named the application's current mode decides. repository, repository_url and branch all default to what the application already holds.

Response 200: the full application resource.

Verified before it is stored. The candidate account is asked to list the repository's branches, and only a successful answer is persisted:

Status When
422 on repository that account cannot reach that repository — bad, revoked or wrongly-scoped token
422 on branch the branch does not exist in the repository

A rejected re-link leaves the application exactly as it was. Storing first would move the failure to the next deploy, where it reads as a deployment problem rather than a credential one.

Side effect: changing to an account with a different provider disables deploy-on-push (webhook_enabled → false, secret cleared). A webhook verifies signatures against its provider's scheme, so a GitHub→GitLab move would leave it silently rejecting every delivery. webhook_identifier is kept — it is the public part of the delivery URL.


git_account_missing on the application resource

true when a git site's account has been disconnected: the FK is nullOnDelete, so deleting a git account leaves its applications with a repository, a branch, and no credential. Nothing said so before — the site looked exactly like a public-repository one until the next deploy ran git remote add origin "" and failed.

Derived, never stored: an account-sourced site is the one with a repository and no repository_url, so a public-URL site is never flagged. Show a re-integrate prompt on this, pointing at the endpoint above.

While it is true, every deploy path refuses: POST …/deployments, …/redeploy and the older POST …/deploy answer 422 with a translated message, and a push delivery answers 202 {"deployed": false, "reason": "git_account_missing"} (a success, so the provider does not disable the hook). Disconnecting an account that applications still use is now refused (see DELETE /integrations/git/accounts/{account}), so this state only remains on sites stranded before that.


Application — Webhooks (deploy-on-push)

GET /webhook-providers

Permission: application (view)

What each provider (GitHub/GitLab/Bitbucket) needs from the user and which direction the secret travels.

{"webhook_providers": [
  {"name": "github", "title": "GitHub", "secret_source": "generate", "instructions": "…"},
  {"name": "gitlab", "title": "GitLab", "secret_source": "either", "instructions": "…"},
  {"name": "bitbucket", "title": "Bitbucket", "secret_source": "generate", "instructions": "…"}
]}

This is a list, not an object keyed by provider — key it yourself on name if you need lookup. title and instructions are already localized; render instructions as the setup help next to the URL, rather than shipping three hardcoded sets of steps in the frontend.

secret_source decides what the secret field does, and the three values are not interchangeable:

value provider what the UI must do
generate GitHub, Bitbucket Leave secret out. The panel mints one (64 hex chars) and returns it in full — show a copy button.
either GitLab Offer the choice, and make the signing token the default. GitLab alone mints a signing token itself, and the panel cannot generate one; leaving secret out does not fail, it silently falls back to GitLab's legacy plaintext secret. Read webhook.verification from the response to see which check ended up in force — signature (strong) or token (plaintext shared value).
paste — Defined in the contract but returned by no provider today. The provider would mint the secret and secret would be required. Handle it if you switch on this field exhaustively.

PUT /applications/{application}/webhook

Permission: app_deployment (manage)

Turn deploy-on-push on or off. This endpoint does not set the repository or branch — those live on the application itself; sending them here does nothing.

Request:

{"enabled": true, "provider": "github", "secret": "…", "rotate": false}
field rule
enabled required, boolean. Sending only {"provider": …} is a 422.
provider required when enabled is true; nullable otherwise. One of the name values from GET /webhook-providers. Required outright for a public repository, which has no connected account to infer it from.
secret optional, min:16, max:255. Omit it and the panel generates one — but only on the first configure. On a later call an existing stored secret is kept, not regenerated, so a plain on/off toggle never invalidates a webhook the user has already pasted into their provider.
rotate optional boolean. The only way to replace a stored secret without pasting one — mints a new value and invalidates the old, for a secret that leaked. Without it, enabled: true on an already-configured webhook is a no-op on the secret.

The min:16 on secret is enforced with its own message: a four-character "secret" is findable in a few thousand requests, so the field rejects it rather than accepting a value that only looks like protection.

Response 200: the full application object (the same shape as GET /applications/{id}), not a webhook-only payload. Read the nested webhook object from it:

{"application": {"…": "…", "webhook": {
  "enabled": true, "provider": "github",
  "url": "https://panel.example.com/api/webhooks/deploy/abc123",
  "secret": "…", "verification": "signature", "registered": true,
  "last_delivered_at": null, "last_delivered_at_human": null
}}}

There is no webhook_enabled or webhook_identifier key in the response — the identifier is already baked into webhook.url, which is assembled server-side so the frontend never builds the path or gets the host wrong. Show webhook.url as the callback URL the user pastes into the provider. last_delivered_at is how you tell "configured" from "actually working"; a webhook that has never fired is worth surfacing.

The panel now adds the webhook to the repository itself (GitHub, GitLab, Bitbucket), using the connected account's token: created on enabled: true, updated on a rotated secret (re-created if it was deleted in the provider), removed on enabled: false and when the site is deleted. A hook the user added by hand (webhook.registered: false) is never touched. The response carries how that went:

{"application": {"…": "…", "webhook": {"…": "…", "registered": true}},
 "webhook_registration": {"status": "registered", "reason": null, "message": null}}

Application — Issues (App Dashboard)

GET /applications/{application}/issues

Permission: app_dashboard (view)

All health signals for this application in one call.

{"issues": [
  {"type": "certificate", "severity": "critical", "message": "The SSL certificate for shop.example.com has expired.", "meta": {"…": "…"}},
  {"type": "worker", "severity": "warning", "message": "The application process is not running.", "meta": {"unit": "…"}}
], "healthy": false}

Each issue is {type, severity, message, meta} (corrected 2026-09-29 — this showed id/title/detail/data, which the API has not returned). type is one of certificate, dns, worker (the Node application's own process — queue workers are on the Workers screen), php_eol, disk, deploy_failed; message is already localised; meta varies by type.

healthy: false when there is at least one warning or critical. Info-level issues do not affect the flag.


Application — Environment (.env editor)

GET /applications/{application}/environment

Permission: app_environment (manage) — was view until 2026-09-29. The .env screen is shown only to who may edit it: a view-only grant no longer opens it, and neither the sidebar nor GET /permissions reports view: true for app_environment (or app_magic_login, whose routes were always manage-only) unless manage is granted too. GET …/environment/history is manage as well.

GET …/environment/history/{log}/diff (manage) returns {"diff": {"available": true, "changes": [{"key": "DB_PASSWORD", "before": null, "after": null, "status": "changed", "secret": true}, {"key": "APP_DEBUG", "before": "true", "after": "false", "status": "changed", "secret": false}]}}. status is added | changed | removed. A secret key (PASSWORD, SECRET, TOKEN, _KEY, … — the same rule that hides values in the variables view) never carries its values: show it as "changed (hidden)".

404, not 403, for site types that don't use a .env (e.g. WordPress — it uses wp-config.php). The screen does not exist there, which is a different statement from "you may not have it"; 403 is reserved for a caller who lacks the permission on a site that does have the screen.

{"environment": {
  "exists": true,
  "path": "/home/siteowner/shop/public_html/.env",
  "exposed": false,
  "framework": "laravel", "framework_title": "Laravel",
  "requires_restart": false,
  "requires_apply": true,
  "raw": "APP_NAME=Shop\nDB_HOST=127.0.0.1\n…",
  "variables": [
    {"key": "APP_NAME", "value": "Shop", "secret": false},
    {"key": "DB_PASSWORD", "value": null, "secret": true}
  ],
  "checks": [
    {"code": "app_debug_enabled", "severity": "danger", "key": "APP_DEBUG", "value": "true", "title": "…", "detail": "…"}
  ],
  "backups": [
    {"name": ".env.bak-20260728-141530", "created_at": "28-07-2026 14:15:30"}
  ]
}}

All of this comes from one read of the file. A raw endpoint plus a parsed endpoint plus a checks endpoint would read it three times and can return three different answers if someone saves in between.

exists: false is a normal state (nothing written yet), not an error — raw is then "" and checks is empty.

exists: true with an empty raw is also a real answer, and it means the file is there and empty. Every git checkout used to arrive that way: provisioning creates the file and no repository ships a .env. A deploy now seeds it from the repository's .env.example and generates the key for Laravel, so this should only be seen on a repository that has no example to copy.

500 with a reference when the panel could not find out. test -f exits non-zero both for a file that is absent and for a command that never ran (a sudoers grant older than the running build is the usual cause), and reporting the second as exists: false gives the user an empty editor for a file full of their settings. The two are told apart by stderr; quote the reference to find the exact command in the server-ops log.

path is where the file is, which is not always where a new one would be created. The rule is beside the code, but never inside a file-served directory: a Laravel checkout at public_html serving from public_html/public keeps its .env at public_html/.env, where the framework reads it and no URL reaches it. Where the code root is the served directory, a new file is created one level up instead — but an existing .env beside the code is opened and saved in place, because that is the one the application loads and hiding it would not unexpose it.

exposed: true says that file is reachable over the web, and adds a file_exposed check at error severity. The fix is to set a web root, not to move the file: in that state the whole source tree is served. A proxied application (Node) is never exposed — nothing under its document root is fetchable by path.

A secret's value is null, not a masked string. Don't render dots from the API; render them from secret: true.

raw is null unless the caller has app_environment (manage) (2026-09-29): it is the whole file, secrets included, and it went to view-only roles while variables nulled the same values. Since the same day the whole endpoint is manage-only, so this is a second line rather than the first.

requires_restart and requires_apply are the two ways a save can appear to do nothing, answered up front so the button can say what it will actually do:

framework is one of laravel, craft, statamic, nextjs, nuxt, node, unknown — detected from what is on disk, not from the site type.

checks are lint results on the file's contents, each with a code, a severity, the offending key/value, and a localised title/detail.

backups — timestamped automatic snapshots before each save, newest first, 20 kept (ApplicationEnvironment::KEEP_BACKUPS; this said five until 2026-10-08, ENV-2). Each is {name, created_at}: name is the on-disk filename (.env.bak-YYYYMMDD-HHMMSS) and is what the restore endpoint takes; created_at is the same instant formatted for display. Sorting by name sorts by time, so the list costs no stat per file.


POST /applications/{application}/environment/check

Permission: app_environment (manage) | Throttle: 60/min — added 2026-10-08 (ENV-1)

The save's own checks, with nothing written. Send the editor's text before saving so warnings (an export line a Node service will not read, debug on, a duplicate key) show while the user can still change it — they used to appear only after the file was saved and the service restarted on it.

Request: {"raw": "APP_ENV=production\n…"}

Response 200: {"checks": […same shape as GET's checks…], "refused": ["…"], "saveable": true} — refused lists what a PUT of this text would be refused for (a line that cannot be parsed, a locked key changed); empty means it saves.


PUT /applications/{application}/environment

Permission: app_environment (manage) | Throttle: 20/min

Replace the entire .env file. Optionally restart the app's process after save.

Request:

{"raw": "APP_NAME=Shop\nDB_HOST=127.0.0.1\nDB_PASSWORD=secret123\n", "restart": true}

Response 200:

{"environment": {"path": "…", "exists": true, "exposed": false, "raw": "…", "backups": [{"name": ".env.bak-20260728-150000", "created_at": "28-07-2026 15:00:00"}], "variables": [...]}, "applied": true, "restarted": true}

applied: true — config cache was cleared (Laravel apps). restarted: true — the app's process was restarted.

Syntax errors return 422 with errors.raw listing the problem.

Locked keys (2026-10-04, bug #17). The key an application encrypts its stored data with cannot be changed or removed once it has a value: N8N_ENCRYPTION_KEY on n8n, APP_KEY on Akaunting and Statamic. Such a save is a 422 on raw naming the key; every other line can still be edited. A file with no value for the key yet may be given one. Git sites have no locked keys — the code, and rotating its key, are the owner's.

Panel-managed keys (2026-10-05, junior re-test #7). n8n's N8N_PORT, N8N_USER_FOLDER and N8N_LISTEN_ADDRESS are refused the same way: the vhost proxies to the port the panel recorded, the data lives in the site folder, and n8n listens on loopback behind its vhost. Changing one answered 200 and broke the site at the next restart. The 422 message differs from the encryption-key one (environment_key_managed / _backup), so show errors.raw[0] (or errors.backup[0]) as it is.


POST /applications/{application}/environment/restore

Permission: app_environment (manage) | Throttle: 10/min

Restore a previous snapshot.

Request: {"backup": ".env.bak-20260728-141530", "restart": true}

backup is the name from the list, verbatim. It reaches a path, so anything not matching .env.bak-YYYYMMDD-HHMMSS exactly is refused rather than sanitised — a 422 on backup, as is a well-formed name that is no longer on disk (both were 500s until 2026-09-29). The current file is backed up first, so restoring the wrong snapshot is itself undoable.

A backup whose locked key (see PUT above) differs from the current file's is a 422 on backup: restoring it would undo a key change the same way typing one would.

Response 200: {"environment": {...}}


Application — File Manager

Every operation runs as the site's own Linux user (not root). All paths are relative to the site's root.

Refusals you can act on carry a field and a reason (updated 2026-10-08, APP-3/4/6). They are shaped like a validation error, plus a reason code, so the screen can put the message under the right field:

{"message": "The folder /missing does not exist. Create it first, or choose another folder.",
 "reason": "destination_missing",
 "errors": {"target": ["The folder /missing does not exist. Create it first, or choose another folder."]}}
reason Status When
destination_missing 422 The folder something is going into does not exist (copy, move, extract, upload, new folder). Before 2026-10-08 this was a 404 "item could not be found" (FI-B).
destination_not_directory 422 That "folder" is a file.
path_exists 422 Something is already at the target (also creating a folder that exists — this was a silent 200 until 2026-10-08, FI-9).
not_archive 422 Extract: the file is not .zip/.tar.gz/.tgz. Compress: the target's name does not end in one.
too_large 422 Too large to open in the editor (download it instead).
not_text 422 Not a text file.
not_image / svg_not_previewable 422 Preview: not an image, or an SVG (never shown, it can carry code).
setgid_file 422 A setgid mode (leading 2/3) on a file.
changed_on_disk 409 Save: the file changed since it was opened (see version).

A missing source (the file being copied/moved is gone) is still 404.

POST /applications/{application}/fix-permissions

Permission: app_file (manage) | Throttle: 5/min

Fix file/directory ownership and permissions for this site.

Response 200: {"fixed": true}


GET /applications/{application}/files

Permission: app_file (view)

Browse a directory.

Query: ?path=wp-content/plugins (relative to the site root; omitted or empty — ?path= — is the root itself; empty was a 422 until 2026-09-29. A leading / is refused with 422)

The root ("") is the site's code root — {home}/{slug}/public_html — not its document root. They are the same directory for a site with no web root. For Statamic (/public), Craft (/web) or any git site serving from a subdirectory they are not, and rooting here is what lets the file manager show the application itself: its .env, composer.json, vendor/ and config, rather than only the served folder. Every path in this section is relative to that root, and nothing above it is reachable — .panel/ (Basic Auth hash, PHP sessions, pre-push database dumps) is a sibling of public_html, which is precisely why the panel writes there.

Response 200:

{"path": "wp-content/plugins", "files": [
  {"name": "seo-pack", "type": "dir", "size": null, "size_human": null,
   "modified_at": "27-07-2026 10:00:00", "modified_at_human": "2 weeks ago",
   "mode": "drwxr-xr-x", "owner": "siteowner", "group": "siteowner",
   "link_target": null, "link_broken": null},
  {"name": "README.md", "type": "file", "size": 4096, "size_human": "4 KB",
   "modified_at": "25-07-2026 14:30:00", "modified_at_human": "3 weeks ago",
   "mode": "-rw-r--r--", "owner": "siteowner", "group": "siteowner",
   "link_target": null, "link_broken": null},
  {"name": "uploads", "type": "symlink", "size": 0, "size_human": "0 B",
   "modified_at": "25-07-2026 14:30:00", "modified_at_human": "3 weeks ago",
   "mode": null, "owner": null, "group": null,
   "link_target": "../shared/uploads", "link_broken": false}
]}

type is file, dir or symlink — note dir, not directory.

A folder's size is null (since 2026-10-08, APP-5): what the server reports there is the folder entry's own block, 4096 bytes whatever it holds. Use GET …/files/sizes for real folder sizes.

mode is the octal permission bits as a string ("644", "755") — the same three digits PUT …/files/permissions takes (corrected 2026-09-29: this said an ls -l string, which the API has not returned). owner and group are separate fields.

On a symlink, mode/owner/group are null — a link's own mode is always lrwxrwxrwx and its ownership says nothing about the target, so a constant is not shown. link_target is where it points, left exactly as written (a relative target stays relative), and link_broken is true when the target does not exist, is a loop, or could not be read. Both are null on non-symlinks.


GET /applications/{application}/files/search

Permission: app_file (view) | Throttle: 10/min

Recursive filename search.

Query: ?q=config&path= (empty = site root)

q is required (1–255 chars) — the parameter is q, not search. path is optional and defaults to the site root; it scopes the search to a subtree. Glob metacharacters in q are escaped, so the query matches literally rather than as a wildcard pattern.

Response 200:

{"path": "", "query": "config", "files": [
  {"path": "wp-config.php", "name": "wp-config.php", "type": "file",
   "size": 4096, "size_human": "4 KB",
   "modified_at": "25-07-2026 14:30:00", "modified_at_human": "3 weeks ago",
   "mode": "-rw-r--r--", "owner": "siteowner", "group": "siteowner",
   "link_target": null, "link_broken": null}
], "truncated": false}

Each entry is the same shape as a browse entry (see GET …/files above) with one extra field, path, holding the location relative to the site root — the browse listing has no path because everything in it sits in the directory you asked for.

truncated: true means the result was capped at 200 matches; narrow the query or the path.


GET /applications/{application}/files/size

Permission: app_file (view) | Throttle: 20/min

Folder size on disk.

Query: ?path=wp-content

Response 200: {"path": "wp-content", "size": 52428800, "size_human": "50 MB"}


GET /applications/{application}/files/sizes

Permission: app_file (view) | Throttle: 60/min

The size of every folder directly inside path, from one du — for the size column, so folders show a size without a click each (since 2026-10-01). Fetch it after the listing; the listing itself never waits for it.

Query: ?path=wp-content (empty = site root), optional refresh=1 to measure now.

Response 200:

{"path": "wp-content",
 "sizes": {"plugins": {"size": 9579803, "size_human": "9.1 MB"}, "uploads": {"size": 52428800, "size_human": "50 MB"}},
 "total": {"size": 62012699, "size_human": "59 MB"},
 "complete": true, "measured_at": "01-10-2026 11:50:00"}

GET /applications/{application}/files/content

Permission: app_file (manage) — manage, not view, since 2026-09-29: a file's contents (wp-config.php, .env) are exactly the secrets the rest of the API withholds from view-only roles. view is browse, search, sizes and the trash list; hide open/download/preview for it. | Throttle: 60/min

Read a file.

Query: ?path=wp-config.php

Response 200:

{"path": "wp-config.php", "content": "<?php\ndefine('DB_NAME', 'shop');\n…", "size": 4096, "version": "3f786850e387550fdab836ed7e6dc881de23001b", "backups": [{"name": "wp-config.php.bak-20260728-141530", "created_at": "28-07-2026 14:15:30"}]}

Binary files return 422 (reason: not_text); files over the editor limit 422 (reason: too_large).

version (added 2026-10-08, OLD-27) is a hash of the content. Send it back on save.


PUT /applications/{application}/files/content

Permission: app_file (manage) | Throttle: 20/min

Edit an existing file (404 if there is none — create files with POST …/files/upload). The previous version is kept as an automatic backup first.

Request: {"path": "wp-config.php", "content": "<?php\n…", "version": "3f78…"}

Response 200: {"saved": true, "version": "…"} — the new version, for the next save.


POST /applications/{application}/files/content/restore

Permission: app_file (manage) | Throttle: 10/min

Restore a file from an automatic backup.

Request: {"path": "wp-config.php", "backup": "wp-config.php.bak-20260728-141530"} — backup is a name from the backups list, verbatim. The current content is backed up before restoring, so a restore is itself undoable.

Response 200: {"restored": true, "content": "…", "version": "…"} — what the file now holds (added 2026-10-08, APP-5), so the editor can show it without asking again.


POST /applications/{application}/files/upload

Permission: app_file (manage) | Throttle: 10/min | Content-Type: multipart/form-data

Upload one file in a single request. Capped at 50 MB — the whole body is buffered through PHP memory. For anything larger use the resumable endpoints below; there is no size limit there.

Body: file (binary), path — the full destination file path, e.g. wp-content/plugin.zip, not the folder (corrected 2026-09-29). An existing file is never overwritten: 422, delete it first.

Response 200: {"uploaded": true}


GET /applications/{application}/files/uploads/space

Permission: app_file (view)

Free space before starting a large upload, so the UI can refuse early rather than after the user has waited.


POST /applications/{application}/files/uploads

Permission: app_file (manage) | Throttle: 30/min

Opens a resumable upload and returns its id. Validates up front that the destination directory exists and that the disk has room.

Request: {"path": "wp-content/big-backup.zip"} — an existing file there is refused up front (422).

Response 200: {"upload_id": "…32 hex…", "max_chunk": 66584576} — max_chunk is the largest chunk this server accepts, in bytes.


PUT /applications/{application}/files/uploads/{uploadId}

Permission: app_file (manage) | Throttle: 1200/min | Content-Type: raw body

Appends one chunk; responds {"received": <total bytes so far>}. The body is the raw bytes, not multipart — that halves the disk traffic of an upload on a box that is also serving customer sites.

Bounds: one chunk must fit client_max_body_size (64 MB); the reference client sends 8 MB. The throttle is per chunk, not per file, so a large upload is legitimately thousands of requests.

There is no file-size limit. The only bound is free disk: a chunk is refused if writing it would leave less than 5 GB or 10% of the disk free, whichever is larger.


GET /applications/{application}/files/uploads/{uploadId}

Permission: app_file (view) | Throttle: 60/min

{"received": <bytes>} — this is the resume offset. Restart from it after a dropped connection; there is no separate session to reconcile.


POST /applications/{application}/files/uploads/{uploadId}/finalize

Permission: app_file (manage) | Throttle: 30/min

Request: {"path": "wp-content/big-backup.zip"} — the same path the upload was opened with (required). Response: {"uploaded": true}.

Moves the assembled file into place. A same-filesystem rename, so it is atomic and instant — a half-finished upload is never visible at the target path.


DELETE /applications/{application}/files/uploads/{uploadId}

Permission: app_file (manage) | Throttle: 30/min

Abandons an upload and removes its part file. Response: {"aborted": true}.


GET /applications/{application}/files/download

Permission: app_file (manage) — manage, not view, since 2026-09-29: a file's contents (wp-config.php, .env) are exactly the secrets the rest of the API withholds from view-only roles. view is browse, search, sizes and the trash list; hide open/download/preview for it. | Throttle: 20/min

Download a file. Streamed, with no size limit (changed 11-08-2026 — it was previously capped at 5 MB).

The response is a stream, not a buffered body: read it as a blob, not as text. Content-Length carries the real size so a progress bar is possible, and Content-Disposition includes an RFC 6266 filename* alongside the plain filename.

Query: ?path=wp-content/seo-pack.zip


GET /applications/{application}/files/preview

Permission: app_file (manage) — manage, not view, since 2026-09-29: a file's contents (wp-config.php, .env) are exactly the secrets the rest of the API withholds from view-only roles. view is browse, search, sizes and the trash list; hide open/download/preview for it. | Throttle: 60/min

Stream an image with its real content type, so it can be rendered rather than saved. The only response in this API a browser is meant to interpret; download above deliberately labels everything application/octet-stream.

The type is determined from the file's own bytes, never its extension, and only these are served: PNG, JPEG, GIF, WebP, BMP, ICO, AVIF. Headers carry Content-Disposition: inline, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox and Cache-Control: private, no-store.

Query: ?path=wp-content/uploads/2026/logo.png

Refusals (all 422):


POST /applications/{application}/files/extract

Permission: app_file (manage) | Throttle: 5/min

Extract a .zip, .tar.gz or .tgz archive already in the site. Guarded against zip bombs: refused above 250 MB uncompressed or 10,000 entries, and any entry that is a symlink or escapes the destination.

Request: {"path": "plugin.zip", "target": "wp-content/plugins"} — target must already exist (422, reason: destination_missing, otherwise).

Response 202: {"job": {"id": 5, "operation": "extract", "target": "wp-content/plugins", "status": "queued", …}} — runs in the background; follow it on GET …/files/archive-jobs (status: queued → running → completed / failed, with message and reference on failure). Corrected 2026-09-29: this said 200 {"extracted": true}.


POST /applications/{application}/files/directories

Permission: app_file (manage) | Throttle: 20/min

Create one directory. The parent must already exist (422, reason: destination_missing). A folder that already exists is 422, reason: path_exists.

Request: {"path": "wp-content/uploads/2026"}

Response 200: {"created": true}


Bulk file operations

rename, copy, permissions, compress and DELETE /files each accept either a single path or a paths[] selection. The single form is unchanged and still supported.

The two are not the same operation:

single path paths[]
Missing file 404 200 with a failed entry
Result {"deleted": true} per-path succeeded[] / failed[]

Bulk response:

{"deleted": false,
 "succeeded": ["cache/a.txt", "cache/b.txt"],
 "failed": [{"path": "cache/gone.txt", "reason": "not_found"}]}

reason is not_found, exists (something is already at the destination), setgid_file (permissions only) or failed. A target_directory that does not exist refuses the whole request (422, reason: destination_missing). A batch can partly succeed — show the failures rather than treating the call as failed.

Limits: at most 250 paths per request (422 above that — an argument vector has a kernel limit, and crossing it would fail with some paths already handled). Every entry is validated individually, so one bad path rejects the whole request with 422.


PUT /applications/{application}/files/rename

Permission: app_file (manage) | Throttle: 20/min

Rename or move one path; move a selection into a directory.

Single: {"path": "wp-content/old-name", "target": "wp-content/new-name"} Bulk: {"paths": ["cache/a.txt", "cache/b.txt"], "target_directory": "keep"}

A destination that is already occupied is refused, never overwritten.

Response 200: {"renamed": true, "succeeded": […], "failed": […]}


POST /applications/{application}/files/copy

Permission: app_file (manage) | Throttle: 10/min

Single: {"path": "wp-config.php", "target": "wp-config.php.bak"} Bulk: {"paths": ["cache/a.txt", "cache/sub"], "target_directory": "keep"}

Response 200: {"copied": true, "succeeded": […], "failed": […]}


POST /applications/{application}/files/compress

Permission: app_file (manage) | Throttle: 10/min

The target's extension chooses the format: .zip, .tar.gz or .tgz. Anything else is 422 — the extension is what selects the command, so an unrecognised one has nothing to run rather than a default to guess at. Same set POST …/files/extract accepts, from the same function, so the panel can always open what it writes.

Single: {"path": "wp-content", "target": "wp-content-backup.tar.gz"} Bulk: {"paths": ["cache/a.txt", "cache/b.txt"], "target": "cache/bundle.zip"}

Prefer .tar.gz for site content. ZIP does not carry Unix permissions, so a folder zipped and later extracted comes back with whatever modes the extractor decided — a wp-config.php at 0600 does not stay 0600. tar.gz preserves mode, ownership and symlinks, and compresses better across many small files. Offer .zip for portability, and default to .tar.gz for "keep a copy before I change this".

Bulk sources must all sit in the same folder (422 otherwise): the command runs from that folder so the archive holds bare names rather than the server's directory layout, and there is no folder to run from once the sources are spread across the tree. Disable the button when a selection spans folders.

Response 202: {"job": {"id": 1, "operation": "compress", "target": "wp-content-backup.tar.gz", "status": "queued", …}} — runs in the background, like extract; the finished job carries size_bytes. Corrected 2026-09-29: this said 200 {"compressed": true}.


PUT /applications/{application}/files/permissions

Permission: app_file (manage) | Throttle: 20/min

Change the mode. Three octal digits (644), or four with a leading 0, 1 (sticky) or 2/3 (setgid) — 1777, 2775 (since 2026-10-08, OLD-26). Setuid (4–7) is refused with 422; setgid is accepted on folders only (422 reason: setgid_file on a file, a setgid_file failure in a bulk call).

Single: {"path": "wp-config.php", "mode": "644"} Bulk: {"paths": ["cache/a.txt", "cache/b.txt"], "mode": "600"}

Response 200: {"chmoded": true, "succeeded": […], "failed": […]}


DELETE /applications/{application}/files

Permission: app_file (manage) | Throttle: 10/min

Single: {"path": "wp-content/old-plugin", "confirm": true} Bulk: {"paths": [...], "confirm": true, "count": <paths.length>}

confirm is required either way. For a selection, count must equal the number of paths or the request is refused with 422 — a stale selection is the realistic bulk-delete accident, and confirm cannot catch it because it is true either way. Send paths.length; do not hardcode it.

Deleting moves to the trash by default. Add "permanent": true to destroy instead. Omitting it means recoverable, which is the safer reading of an ambiguous request — so existing clients gain a safety net without changing.

Offer both in the UI. Permanent has to stay reachable: someone deleting 40 GB to free disk space and seeing nothing freed would rightly call that a bug.

A trashed selection keeps one batch id, so twelve things deleted together are restored together rather than one at a time.

Response 200: {"deleted": true, "succeeded": […], "failed": […]}


GET /applications/{application}/files/trash

Permission: app_file (view) | Throttle: 60/min

What is recoverable, newest first. An empty list is a normal answer.

{"trash": [
  {"batch": "20260813-104500", "path": "wp-content/old-plugin", "deleted_at": "13-08-2026 10:45:00", "size": 48234496, "size_human": "46 MB"}
], "total_size": 48234496, "total_size_human": "46 MB", "retention_days": 7}

path is where it came from, which is also where it goes back to — plugin.php alone would not say which one it was. Only the top of a deleted tree is listed: a deleted directory is one thing the user deleted, not four hundred. Items deleted from inside a folder are listed by their own path (qa/note.txt), not the folder's — until 2026-09-29 they showed as the parent folder and "restore all" failed with exists; batches deleted before that fix still list the old way until they expire.

size is the reclaimable on-disk footprint of that entry (directories included), not its inode size. size and size_human are null if that entry cannot be read; total_size and total_size_human are also null when any entry is unreadable, rather than claiming an incomplete total. retention_days is the operator-configurable automatic-retention window — use this value, never a frontend constant.

Everything lives above the document root, so nothing here is reachable over HTTP — a deleted wp-config.php still holds live credentials.


POST /applications/{application}/files/trash/restore

Permission: app_file (manage) | Throttle: 30/min

Single-path request: {"batch": "20260813-104500", "path": "wp-content/old-plugin"}

Restore-all request: {"batch": "20260813-104500"}

With path, restores only that path. Without it, restores every top-level entry in the batch independently. An occupied destination never blocks the other entries in its batch.

batch must be YYYYMMDD-HHMMSS. It is half a filesystem path, so anything else is refused rather than sanitised.

Response 200:

{
  "restored": false,
  "succeeded": ["wp-content/old-plugin"],
  "failed": [{"path": "wp-config.php.bak", "reason": "exists"}],
  "trash": [],
  "total_size": 0,
  "total_size_human": "0 B",
  "retention_days": 7
}

restored is true only when every requested item was restored. For a single-path request, an occupied destination remains a 422; batch restore instead reports it in failed and continues. reason is exists, not_found, or failed. trash, totals, and retention_days use the same shape as GET .../files/trash.


DELETE /applications/{application}/files/trash

Permission: app_file (manage) | Throttle: 10/min

Request: {"confirm": true} — everything, or {"batch": "…", "confirm": true} for one batch.

This is the only unrecoverable action the file manager has, deliberately: it is how the disk space comes back. Trash is swept automatically after SERVER_TRASH_RETENTION_DAYS (default 7).

Response 200: the updated {"trash": [...]}.


Application — PHP Settings

GET /applications/{application}/php

Permission: app_php (view)

403 for non-PHP site types.

The editable values live under settings, not at the top level. There is no opcache_* field on this endpoint.

{"php": {
  "application_id": 1,
  "php_version": "8.4",
  "available_versions": ["8.1", "8.2", "8.3", "8.4"],

  "isolated": true,
  "isolated_at": "25-07-2026 14:30:00",
  "isolation_supported": true,
  "runs_as": "siteowner",
  "managed": true,

  "settings": {
    "memory_limit": "256M",
    "upload_max_filesize": "64M",
    "post_max_size": "128M",
    "max_execution_time": 120,
    "max_input_time": 60,
    "max_input_vars": 3000,
    "session_gc_maxlifetime": 1440,
    "pm_type": "ondemand",
    "pm_max_children": 6,
    "pm_max_requests": 500,
    "open_basedir_enabled": true,
    "open_basedir_paths": "/var/www/shop/extra",
    "disable_functions": "exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec",
    "allow_url_fopen": true,
    "php_timezone": "UTC",
    "auto_prepend_file": null,
    "additional_directives": null
  },

  "overridden": {
    "memory_limit": true, "upload_max_filesize": false, "post_max_size": false,
    "max_execution_time": true, "max_input_time": false, "max_input_vars": true,
    "session_gc_maxlifetime": false, "pm_type": false, "pm_max_children": false,
    "pm_max_requests": false, "open_basedir_enabled": true, "open_basedir_paths": true,
    "disable_functions": true, "allow_url_fopen": false, "php_timezone": false,
    "auto_prepend_file": false, "additional_directives": false
  },

  "presets": [
    {"key": "low", "title": "…", "description": "…", "pm_type": "ondemand", "pm_max_children": 2},
    {"key": "balanced", "title": "…", "description": "…", "pm_type": "ondemand", "pm_max_children": 6},
    {"key": "high", "title": "…", "description": "…", "pm_type": "dynamic", "pm_max_children": 12}
  ],

  "memory": {
    "total": 8360124416, "committed": 2147483648, "available": 6212640768,
    "over_committed": false, "sites": 4, "this_site": 1610612736
  },

  "open_basedir_effective": "/var/www/shop:/var/www/shop/.sessions:/tmp:/var/www/shop/extra",
  "open_basedir_live": "/var/www/shop:/var/www/shop/.sessions:/tmp",
  "open_basedir_recommended": "/var/www/shop:/var/www/shop/.sessions:/tmp",

  "suggested_disable_functions": "exec,passthru,…",
  "disable_functions_presets": [
    {"key": "safe",   "title": "Recommended", "description": "…", "functions": "exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec"},
    {"key": "strict", "title": "Strict",      "description": "…", "functions": "getmyuid,passthru,shell_exec,dl,exec,system,…"}
  ]
}}

settings is always the effective value — the user's override where one exists, the panel default otherwise. overridden is the parallel map saying which is which, so you can render a per-field "Reset to default" control instead of guessing. Same keys, same order, one boolean each.

isolated gates the entire screen. Every value here is only enforceable once the site has a pool of its own; on a shared pool memory_limit is shared too. isolated_at is when that happened; isolation_supported is false where the web server has no FPM at all (OpenLiteSpeed runs LSAPI), so hide the isolate action rather than offering one that cannot work. runs_as is the Linux user the site's PHP executes as — its own system user once isolated, otherwise the server-wide web user (www-data).

The shared www-data pool is no longer a choice: asking for it answers 405. It survives only as a state for sites migrated from an older panel and for OpenLiteSpeed.

managed: false means the pool file on disk no longer matches what the panel would write — someone edited it by hand. Warn before the user presses save, not after their edits are gone.

managed: null means the panel could not check (the pool file could not be probed or read). Say "could not verify the pool file" — do not treat it as true (a save may overwrite hand edits) or as false (there may be none).

open_basedir has three answers on purpose, and they are all different questions. additional_directives accepts PHP settings only, one per line — name = value or the FPM form php_[admin_]value|flag[name] = value; ;/# comments and blank lines are allowed. Any other line is a 422 naming it (php_settings.errors.directive_invalid). Also a 422 (since 2026-10-01): a setting the panel writes itself (directive_managed — memory_limit, upload_max_filesize, post_max_size, max_execution_time, max_input_time, max_input_vars, allow_url_fopen, session.save_path, session.gc_maxlifetime, error_log, log_errors, date.timezone, auto_prepend_file, open_basedir, disable_functions; any case; use its own field), and extension/zend_extension (directive_extension; use the PHP Extensions screen). In a PHP-FPM pool each line is written as php_admin_value[...]/php_admin_flag[...] (an FPM-form line keeps its own directive); on OpenLiteSpeed it goes into the site's php.ini as name = value.

Pool settings (since 2026-10-13, v7 parity). On PHP-FPM (nginx/Apache) these pool lines are also accepted and written as typed: env[NAME] = value (NAME [A-Za-z_][A-Za-z0-9_]*), request_terminate_timeout / request_slowlog_timeout (seconds or s|m|h|d, at most a day), slowlog = <path> (must be inside the site's own logs/ — directive_slowlog_path; never written otherwise), catch_workers_output / decorate_workers_output (yes|no), rlimit_files (number), rlimit_core (number or unlimited). On OpenLiteSpeed they are a 422 directive_fpm_only (no pools). Refused by name and never written, in any form: user, group, listen*, chroot, chdir, prefix, include, clear_env, access.*, security.* (e.g. security.limit_extensions, which would run other file types as PHP), process.*, ping.*, apparmor → directive_pool_forbidden; pm / pm.* → directive_pm (use the PM fields). ⚠️ Lines saved before 2026-10-01 are still written as they were, and for those which value wins differs by stack (measured 2026-09-30): PHP-FPM applies the first of a repeated key, so on nginx/Apache a panel-managed key there has no effect; on OpenLiteSpeed the php.ini is read top to bottom and it does override. Re-saving the screen with such a line in it is a 422 — remove it or move the value to its field.

field question it answers
open_basedir_effective what the panel would write from the stored settings
open_basedir_live what the pool file on disk actually says right now — null when the site has no pool, or its pool sets nothing. Resolved as FPM does: the first php_admin_value line, else the first php_value (since 2026-10-01; it read the last line before). Differs from effective after a hand edit, or an open_basedir saved in additional_directives before 2026-10-01 on OpenLiteSpeed
open_basedir_recommended what switching it on with no additions would give — the value to offer when it is off, so the screen proposes a path list instead of asking the user to invent one

Paths the user adds are appended to a base of the application root plus that site's own session directory plus /tmp. A server-wide session directory (/var/lib/php/sessions, which a migrated commercial panel leaves behind) is deliberately never included: it would let every site read every other site's sessions.

pm_type is ondemand / dynamic / static, and presets exists so nobody has to reason about worker counts from first principles — three named starting points, each carrying the pm_type and pm_max_children it implies. memory is the budget in bytes, recalculated against /proc on every request (a VPS can be resized underneath a cached figure); this_site is what the current settings commit, so the screen can show the cost of a change before it is saved and flag over_committed.

suggested_disable_functions is the safe list repeated as a flat string, kept so older clients keep working. Prefer disable_functions_presets — a third preset should not need a frontend change. Titles and descriptions in both presets arrays are already localised by Accept-Language.

suggested_disable_functions is the safe list repeated as a flat string, kept so older clients keep working. Prefer the array — a third preset should not need a frontend change.


PUT /applications/{application}/php

Permission: app_php (manage) | Throttle: 10/min

Update PHP version and/or pool settings.

Request:

{"php_version": "8.4", "memory_limit": "512M", "max_execution_time": 300, "upload_max_filesize": "128M"}

Response 200: {"php": {...updated...}}

php_version is checked against the application's supported range (added 2026-09-11), not only against what is installed. Until then the range was enforced when a site was created and never again, so a site correctly created on a version its application supports could be moved here to one it does not and simply break. A version outside the range is 422 on php_version naming the range. The range comes from the site type; types that declare none are unaffected.

post_max_size also sets the web server's request-body limit, as of 2026-09-08 — client_max_body_size on nginx, LimitRequestBody on Apache, maxReqBodySize on OpenLiteSpeed — and changing it rewrites the vhost as well as the pool.

Until then no vhost set one at all, so every nginx site ran on nginx's built-in 1 MB default while the panel wrote upload_max_filesize = 64M into its pool. A WordPress media upload answered 413 Request Entity Too Large at one megabyte, and raising the PHP values did nothing, because a 413 is refused by the web server before PHP is reached. Sites created or reconfigured before that date carry the limit from their next vhost write.

Send null to clear an override and fall back to the server default. Every field that can be overridden accepts it — including pm_type, pm_max_children, pm_max_requests and allow_url_fopen, which until 2026-08-13 could be set but never cleared, so "reset to default" was only ever half a feature.

One exception, and it is not an oversight: open_basedir_enabled is a plain boolean with a column default of false. It has no override to clear, so "reset" for that one is false, not null.

Omitting a field leaves it as it is; sending null is what clears it. The two are different requests.

GET .../php reports open_basedir three ways, and they are allowed to disagree:

field meaning
open_basedir_effective what the panel would write from the saved settings. null when off.
open_basedir_live what the pool file on disk actually sets. null when the site has no pool, or its pool sets nothing.
open_basedir_recommended what switching it on with no additions would give — show this as the suggested value when it is off.

On a migrated server, the first time the panel takes ownership of a site's pool (POST .../php/isolate or php artisan php:isolate-all) it adopts whatever open_basedir was already there: the old panel's paths are kept as open_basedir_paths and the setting is switched on. A server-wide session directory (/var/lib/php/sessions and friends) is deliberately not carried over — importing it would let that site read every other site's sessions, and the site's own session directory is in the base paths already. The command prints what it kept and what it dropped, per site.

live differs from effective when someone hand-edited the pool file, or (OpenLiteSpeed only) set their own open_basedir through additional_directives before that was refused (2026-10-01) — on PHP-FPM the panel's own line comes first and FPM keeps the first. When they differ, show live — that is what PHP is enforcing — and managed will also be false.

open_basedir_paths holds additional directories, one per line (: and , also accepted). The app root, the site's own session directory and /tmp are always included and cannot be removed — without them the site cannot read its own code or keep anyone logged in. Read open_basedir_effective from GET .../php to show the exact value the pool file will contain.

422 on open_basedir_paths for a relative path, a path containing .., or / — the last because it would leave the setting switched on while enforcing nothing.

422 on settings when the site has isolated: false and isolation_supported: true. Every limit on this form is enforced by the pool file, so without a pool they would be stored and never applied. Offer the isolate action instead of a save.

php_version is exempt — it is carried by the vhost and can be changed either way.

On OpenLiteSpeed (isolation_supported: false) a save is accepted and applied, and this is new as of 2026-09-03. There are no pools on that stack, so the settings are written as the site's own .ini file and LSPHP is pointed at it by the vhost — the 422 above must not be expected there. Until this changed, every value on this form saved with a 200 on an OpenLiteSpeed server and reached nothing: the guard asked "does this stack have pools", which is false, so it never fired on the one stack where nothing else applied the settings either.

Two other fields changed meaning on that stack at the same time, both because they were inferred from isolated_at — a column only the pool feature ever writes:


POST /applications/{application}/php/isolate

Permission: app_php (manage) | Throttle: 5/min

Give this site its own PHP-FPM pool running as its own user.

This is a repair, not a mode. Every site the panel provisions already gets its own pool at creation. The only sites that need this are ones the panel did not create: adopted from another panel, made before pool isolation shipped, or left behind by a failed pool step. Surface it only when isolated: false and isolation_supported: true.

Response 200: {"php": {"isolated": true, "isolated_at": "29-07-2026 10:00:00", …}}

422 if already isolated or if the web server is OpenLiteSpeed (OLS runs LSPHP and has no pools — isolation_supported is false there and no isolation UI should show at all).

Server-side, php artisan php:isolate-all converts every remaining shared site in one pass.


DELETE /applications/{application}/php/isolate — removed

Returns 405. There is no supported way back onto the shared pool: it means running as the web server's own account again, which lets one compromised site read every other site's .env. Remove any "un-isolate" / "back to shared pool" control.


PHP-FPM tuning fields (since 2026-10-13). pm_start_servers, pm_min_spare_servers, pm_max_spare_servers (dynamic), pm_process_idle_timeout (seconds, ondemand), pm_max_spawn_rate (dynamic, PHP ≥ 8.1) — all nullable. Null = derived as before (start = children/2, min = children/4, max = 3·children/4, idle 30s, no spawn rate), so existing sites do not change. The three spare-server numbers are all-or-none and must satisfy min ≤ start ≤ max ≤ pm_max_children (php-fpm's own rule), else 422 on pm_start_servers (pm_spare_inconsistent). If pm_max_children is later lowered below them, the pool falls back to deriving rather than write a pool that will not start. Returned in settings by GET.

POST /applications/{application}/php/v7-extra-conf

v7 importer helper. Permission app_php manage, throttle:30,1. Body {"extra_conf": "<v7 text>"}. Saves nothing. v7 appended extra_conf to the pool as typed, after its own fields, so a line there won. This returns the same effect as v8 settings:

{
  "settings": {"memory_limit": "512M", "php_timezone": "Asia/Kolkata", "pm_type": "dynamic", "pm_max_children": 12,
               "pm_start_servers": 3, "pm_process_idle_timeout": 15,
               "additional_directives": "php_flag[display_errors] = off\nenv[APP_ENV] = production\nrequest_terminate_timeout = 300"},
  "refused": [{"line": "listen = /tmp/x.sock", "reason": "directive_pool_forbidden", "name": "listen", "message": "…"}],
  "valid": true,
  "errors": {}
}

Application — Basic Auth (Password Protection)

GET /applications/{application}/root-lock

Permission: application (view)

Whether the site folder ({home}/{slug}) is locked against its own user: {"root_lock": {"status": "locked|unlocked|unknown", "path": "/home/brown/brownsite"}}. unknown means "could not check" (for example a filesystem with no immutable flag), not "unlocked". Every site the panel creates is locked when it is set up; unlocked is normally a site that server sync adopted, whose folder belongs to the site user.

POST /applications/{application}/root-lock

Permission: application (manage) | Throttle: 10/min | no body

The Lock button. Hands the folder to root (chown -h, owner only; the mode and every file inside are left as they are) and locks it, then returns the same shape as GET with status: "locked". Already root's? It is just locked. Logged as application.root_locked.

Show a warning before calling it: after locking, the site user can no longer add, delete or rename entries directly in the site folder; everything inside public_html works as before.

Refused with 422 {"message": "...", "code": "root_lock_request_refused"} and nothing changed when: it is not a real folder (a symlink, or it changed while being checked), another account owns it, its group or everyone can write to it, or root ownership would lock the site user out (the user needs read+open through the folder's group). The message is translated and says what to fix.

PUT /applications/{application}/security

Permission: app_security (manage) | Throttle: 10/min

Enable/disable HTTP Basic Auth for this site.

Request:

{"enabled": true, "username": "shopuser", "password": "secretpass"}

To disable: {"enabled": false}

Response 200: {"application": {"id": 1, "basic_auth_enabled": true}}

Not every application can be put behind it. basic_auth_supported: false on the application resource means this site's own interface signs in with the Authorization header — Node-RED's editor sends Authorization: Bearer <token> on every admin call. HTTP carries that header once per request, so Basic Auth and the application's own token cannot both travel: whichever one is sent, the other side answers 401, and the application becomes unreachable rather than merely double-protected.

Enabling it on such a site is a 422 on enabled, not a silent success. Disable the control when basic_auth_supported is false; those types mandate their own credentials at install time, so nothing is left unprotected.

Password protection forces HTTPS when the site has a certificate (2026-10-05, junior re-test #9). Plain http is redirected to https before the password prompt, on nginx, Apache and OpenLiteSpeed, whatever certificate.force_https says, so the password never travels in clear. force_https keeps showing the user's own setting; turning it off while password protection is on changes nothing until protection is off too. A site without a certificate keeps working over http, and basic_auth_unencrypted: true says the password is sent unencrypted: show a warning such as "This site has no SSL certificate, so the password is sent unencrypted. Add a certificate to protect it."


Application — Staging Area

WordPress only. Clone a site to a staging domain, work on it, push changes back.

GET /applications/{application}/staging

Permission: app_staging (view)

{"staging": {
  "id": 5, "name": "shop-staging", "domain": "staging.example.com",
  "status": "active", "php_version": "8.4",
  "is_staging": true, "production_application_id": 1,
  "system_user": {"id": 1, "username": "siteowner"},
  "created_at": "28-07-2026 09:00:00", "created_at_human": "3 days ago"
}}

A staging site is just another application row — the full application object, with is_staging: true and production_application_id pointing back. staging: null when none exists.

⚠️ The production site's own has_staging flag is documented on the application resource but no endpoint currently loads the relation that produces it, so the key never appears in practice. Decide "create staging vs view staging" from this endpoint returning null or an object, not from has_staging.

staging: null — no staging site exists yet.


POST /applications/{application}/staging

Permission: app_staging (manage) | Throttle: 5/min

Create a staging clone.

Request: {"domain": "staging.example.com"}

Response 201: {"staging": {"id": 5, "status": "provisioning", …}}

Response 422 (added 2026-10-03, bugs #88/#89; was a generic 500) under errors.application:


POST /applications/{application}/staging/push

Permission: app_staging (manage) | Throttle: 5/min

Push staging changes back to production.

Request: {"mode": "files|full"}

Before either mode changes production, the panel creates a temporary private file snapshot. If the push fails, it restores the files (and the database for full) before bringing production back online. A successful push or successful recovery removes the temporary file snapshot; the pre-push SQL dump is retained.

Response 200: {"application": {...updated production record...}, "backup": null}

"backup": true (added 2026-10-08, FS-B10) — take a backup of the live site through its own backup setup (Backups → target) before the push, and push only if it verified. Needs app_backup (manage) too, and a backup target on the application; otherwise 422 on errors.backup (not permitted / "Backups are not set up for this application yet." / a backup already running). The push request waits for the backup — on a large site that is the long part. On success backup is the backup row (same shape as GET /backups). If the backup did not verify, nothing is pushed and the answer is:

{"message":"The backup before the push did not finish, so nothing was pushed. Open the backup to see why.","reason":"backup_failed","backup_id":42,"backup_reason":"upload_failed"}

with status 409.

Response 422 (added 2026-10-03, bug #88; was a generic 500) — no staging copy exists: errors.application = "This site has no staging copy to push."

Recovery failure 500:

{"message":"The staging push failed and production could not be restored. The site remains disabled. Quote the reference to support.","code":"staging_rollback_failed","reference":"…"}

When recovery itself fails, production deliberately remains disabled and the private recovery files are preserved. The reference correlates with the server-operations log.


POST /applications/{application}/staging/pull

Permission: app_staging (manage) | Throttle: 5/min — added 2026-10-15 (v7's sync-to-staging)

Update staging from live — the reverse of a push. Call it on the live application. Staging's files and/or database are replaced with live's; live is only read, never disabled.

Request: {"mode": "files|database|full"} — same meanings as a push, pointed at staging.

What stays staging's own, every mode: wp-config.php (its database, URL, DISABLE_WP_CRON), the mail trap and noindex mu-plugins (both written again after the copy), and search-engine visibility (blog_public = 0, forced and then checked). Media copies additively, as in a push (a file deleted on live stays on staging). URLs in the copied database are rewritten live → staging.

Safety, as for a push but on staging: staging's files are snapshotted and (for database/full) its database saved first. If anything fails — including staging coming out connected to live's database or URL, or visible to search engines — staging is put back from those copies. Staging's saved database copies are listed and restorable through the safety-copies endpoints below called on the staging application.

Refused (422, errors.mode) while a push or pull of the pair, or a safety-copy restore on staging, is running (errors/application.staging_push_running), or a backup restore of either site is running. 422 on errors.application when there is no staging copy.

Response 200: {"staging": {...the staging application...}}. Activity: application.staging_pulled_files|database|full.

Frontend: warn before running — "This replaces the staging site's files/database with the live site's. Changes made only on staging will be lost (a copy of staging is saved first)."


GET /applications/{application}/staging/safety-copies

Permission: app_staging (view) — added 2026-10-08 (ST-B3)

The copies of the live database a database/full push saves before replacing it (kept on the server, above the web root, newest 3 kept). Newest first:

{"safety_copies": [{"name": "pre-push-20261008-101500-abc123.sql", "size_bytes": 2048, "created_at": "2026-10-08T10:15:00+00:00"}], "kept": 3}

Empty list when the site never pushed its database.

POST /applications/{application}/staging/safety-copies/{name}/restore

Permission: app_staging (manage) | Throttle: 5/min — added 2026-10-08 (ST-B3)

Puts that copy back into the live database. The database as it is now is saved first as a new copy, so this can be undone from the same list (the oldest beyond 3 is removed). Refused with 422 on errors.name when the name is not one of the listed copies, the application has no database, a push or a backup restore is running.

Response 200: {"restored": "pre-push-….sql", "safety_copies": [...]}. Activity: application.staging_safety_copy_restored.

Application — AI Bot Blocker

GET /ai-bot-policies

Permission: app_bot_blocker (view)

The four current v8 policy choices (allow_all, block_training, block_agents, block_all) and the compatibility choice legacy_v7, with exactly which bots each blocks (resolved server-side).

legacy_v7 preserves the broad v7 literal user-agent list and the case-insensitive whole-agent YandexBot exemption. On legacy_v7 an allowed name exempts every user-agent that contains it, the same way, because the v7 list has short tokens that sit inside longer names (GPT in GPTBot); on the other policies an allowed name is only removed from the list. Migration may map v7 enabled to legacy_v7 and disabled to allow_all; this backend does not implement that importer. New sites still default to allow_all, and existing v8 policies are unchanged.

{"ai_bot_policies": {
  "allow_all": {"title": "Allow all", "description": "…", "blocked_bots": [], "blocked_count": 0},
  "block_training": {"title": "Block AI training crawlers", "description": "…", "blocked_bots": ["ClaudeBot", "ChatGPT-User", …], "blocked_count": 14},
  "block_all": {"title": "Block all AI crawlers", "description": "…", "blocked_bots": ["ClaudeBot", "GPTBot", "ChatGPT-User", …], "blocked_count": 17}
}}

PUT /applications/{application}/bot-blocker

Permission: app_bot_blocker (manage) | Throttle: 10/min

Set the policy and custom allow/block rules for specific user agents.

Request:

{"policy": "block_training", "blocked": ["SomeBot/1.0"], "allowed": ["ClaudeBot/2.0"]}

Response 200:

{"application": {
  "id": 1, "ai_bot_policy": "block_training",
  "bot_rules": [{"rule": "ClaudeBot/2.0", "action": "allow"}, {"rule": "SomeBot/1.0", "action": "block"}]
}}

GET /applications/{application}/bot-traffic

Permission: app_log (view) | Throttle: 30/min

Evidence for the policy decision: which bots hit this site recently, and whether the current settings block them.

Query: ?days=7 (default 7, max 30)

Response 200:

{"status": "ok", "days": 7, "scanned_lines": 4821, "since": "30-09-2026 10:00:00",
 "bots": [
   {"bot": "GPTBot", "hits": 142, "category": "training", "blocked": true,
    "blocked_hits": 120, "last_status": 403,
    "last_seen": "07-10-2026 09:12:44", "last_seen_human": "3 hours ago"}
 ],
 "totals": {"bots": 1, "hits": 142, "blocked_hits": 120}}

category: training | search | agent (v8's own lists) · custom (the site's own block/allow rule) · legacy (since 2026-10-13: a name from v7's list on a legacy_v7 site that the owner did not add themselves).

blocked is what the current settings would do to that bot. blocked_hits (per bot and in totals) is what the server really refused — the 403 lines in the access log — and last_status is the status its latest hit got (2026-10-07, AB-B: the count used to come from the current settings, so 200s and hits from before a block existed read as blocked).

Gated on app_log (not app_bot_blocker) because it reads the site's access log — prevents widening the bot-blocker permission into log access.


Application — 8G Firewall

The Perishable Press 8G ruleset (v1.5), applied as web-server config — no daemon and no package. Six independently switchable categories, two modes, and a per-app exceptions/custom-rules list. Permission app_firewall; the screen is titled 8G Firewall.

GET /waf-options

Permission: app_firewall (view)

The six categories and two modes, plus whether this server can enforce them at all.

{
  "waf_supported": true,
  "web_server": "nginx",
  "waf_categories": [
    {"value": "query_string", "title": "Bad search terms", "description": "Blocks requests whose search terms carry SQL, script or file-path tricks…"},
    {"value": "request_uri", "title": "Bad web addresses", "description": "…"},
    {"value": "user_agent", "title": "Bad visitors", "description": "…"},
    {"value": "referrer", "title": "Bad links", "description": "…"},
    {"value": "cookie", "title": "Bad cookies", "description": "…"},
    {"value": "method", "title": "Bad request types", "description": "…"}
  ],
  "waf_modes": [
    {"value": "detect", "title": "Just watch, don't block"},
    {"value": "enforce", "title": "Actually block"}
  ]
}

The six values are the complete set — there is no sql_injection, xss, spam or bad_js category. title and description are localized; render them rather than mapping the values yourself.

waf_supported is false on OpenLiteSpeed, where the rules are not yet implemented. It is server-wide, because one server runs one web server.

You should not normally need it: on such a server app_firewall is dropped from the application's feature list, so it never appears in GET /permissions?level=application&application_id=… and the sidebar simply has no 8G Firewall item — the same hide-rather-than-grey treatment as a Deployment screen on a WordPress site. The endpoints below answer 404 there too, via the same list. waf_supported is for the case where you want to explain the absence rather than just omit it.


GET /applications/{application}/waf

Permission: app_firewall (view)

Returns the application resource. The firewall fields on it:

{"application": {
  "id": 1,
  "waf_supported": true,
  "waf_enabled": true,
  "waf_mode": "detect", "waf_mode_title": "Just watch, don't block",
  "waf_categories": ["query_string", "request_uri", "user_agent", "referrer", "cookie", "method"],
  "waf_exceptions": ["/wp-admin/admin-ajax.php"],
  "waf_custom_rules": ["/xmlrpc.php"]
}}

waf_categories is a flat array of enabled values, never an object of booleans. A stored null is resolved here to all six, so the frontend never has to know that null and the full list mean the same thing.

waf_exceptions and waf_custom_rules are arrays of plain strings (max 50 each, 1–255 chars), not objects — an exception is matched against the request URI, query string and user agent. Both use whenLoaded, so they are absent rather than empty when the relation was not loaded.


PUT /applications/{application}/waf

Permission: app_firewall (manage) | Throttle: 10/min

Request:

{
  "enabled": true,
  "mode": "detect",
  "categories": ["query_string", "request_uri", "user_agent", "referrer", "cookie", "method"],
  "exceptions": ["/wp-admin/admin-ajax.php"],
  "custom_rules": ["/xmlrpc.php"]
}

enabled and mode are required. categories, exceptions and custom_rules are optional, and omitting one leaves the stored value alone — it does not reset it. That matters: absent once meant "all six on", which silently re-enabled the category a user had switched off to fix a false positive. Send an empty array to clear.

Response 200: {"application": {...}}

Response 404 — the web server cannot enforce the ruleset (currently OpenLiteSpeed), so app_firewall is not among the application's features and the route does not exist for it. The screen should already be hidden; this is the backstop.

Response 422 — enabled — the same refusal from the service layer. Not reachable through this route while the 404 above applies, and kept because the manager is also called from the console and from sync.

Mode matters more than it looks. detect blocks nothing and logs what would have been blocked to a separate file, exposed as the waf_detect log key (see Application — Logs). It is listed only while the mode is detect, because only that mode writes it. The intended flow is detect → read the log → add exceptions → enforce; a UI that pushes straight to enforce skips the step that makes this safe to turn on.

Applying is atomic: the config is written, tested and reloaded, and rolled back on failure — including the rules, which are not persisted until the config test passes.


Application — Per-site Fail2ban

Per-application fail2ban watches one site's own access log, at {app_root}/logs/access.log — the application root, beside public_html and never inside it. Every log a site produces lives there whichever web server the box runs, so the jail's logpath does not depend on the stack. The directory is owned by root with the site's user as its group: the site's owner can read its logs, and cannot replace a file a root process is appending to. The jail and filter are raw INI files written verbatim to /etc/fail2ban/{jail,filter}.d/<app-slug>.conf and reloaded into the daemon. Any feature fail2ban's INI supports (custom regex, multiple logpaths, additional actions) is reachable from the form, not just the structured maxretry/findtime/bantime of the previous implementation.

On GET, applications with the old structured columns still on disk are migrated to the new INI form on the first read, after which the structured columns are dropped on the next migration. New applications start with fail2ban: null and the templates below.

GET /applications/{application}/fail2ban

Permission: app_fail2ban (view)

Response when the application has never been configured — fail2ban is null, the two templates are the defaults the form can pre-fill with:

{"fail2ban": null, "jail_template": "...default jail INI...", "filter_template": "...default filter INI..."}

Response when configured — fail2ban carries the saved values, and the templates echo them so the form renders the user's last submission unchanged until they edit it. jail_template and filter_template are always present alongside fail2ban so the form can pre-fill with the saved values (not defaults) without any client-side logic:

{"fail2ban": {
  "jail_name": "panel-site-shop",
  "jail_content": "[{name}]\nenabled  = true\nport     = http,https\nfilter   = {filter}\nlogpath  = /home/siteowner/shop/logs/access.log\nmaxretry = 3\nbantime  = 3600\nfindtime = 600\n",
  "filter_content": "[Definition]\nfailregex = ^<HOST> .* \"(POST|PUT|DELETE) .*wp-login.php\n           ^<HOST> .* \"(POST|PUT|DELETE) .*xmlrpc.php\n           ^<HOST> .* \"(POST|PUT|DELETE) .*wp-admin.*\nignoreregex =\n"
}, "jail_template": "[panel-site-shop]\nenabled  = true\nport     = http,https\n...", "filter_template": "[Definition]\nfailregex = ^<HOST> .* \"(POST|PUT|DELETE) .*wp-login.php\n..."}

The jail file template:

[{name}]
enabled  = true
port     = http,https
filter   = {filter}
logpath  = {app_root}/logs/access.log
maxretry = 3
bantime  = 3600
findtime = 600

The filter file template:

[Definition]
failregex = ^<HOST> .* "(POST|PUT|DELETE) .*wp-login.php
           ^<HOST> .* "(POST|PUT|DELETE) .*xmlrpc.php
           ^<HOST> .* "(POST|PUT|DELETE) .*wp-admin.*
ignoreregex =

{slug}, {name}, {filter}, {logpath} are replaced with the resolved values when the file is written. Other permitted content is preserved. Jail settings are allowlisted: arbitrary actions, command hooks, foreign sections and foreign filters are rejected rather than executed.

New jail names remain prefixed panel-site-. Existing owned v7 jails retain their actual bare name. {name} and {filter} resolve to the stored, validated identity for saves, tests and removal; resync does not rename or rewrite v7 jails. Bare-name operations require root-owned regular files matching the recorded configuration, safe parent directories, negative package ownership and no competing jail section. Unknown, foreign, package or system collisions fail closed with 422; do not retry by adopting an SSH jail. No second prefixed jail is created.

filter_scope / filter_note (added 2026-10-08, FB-wp): the default filter catches failed WordPress logins and XML-RPC only. On WordPress filter_scope is wordpress and filter_note is null; on every other site type filter_scope is wordpress_only and filter_note is a translated sentence saying the jail bans nobody until a rule for that application's own login page is added — show it above the form. There is no per-type default yet on purpose: a rule has to tell a failed login from a successful one by what the access log records, and that has not been measured for each type — a guessed rule bans the site's own users, as the old WordPress one did.

GET /applications/{application}/fail2ban/bans

Permission: app_fail2ban (view) — added 2026-10-08 (FS-C45)

What this application's own jail has banned. {"jail": "panel-site-shop", "banned": ["203.0.113.9"]}; jail is null and banned empty when fail2ban is not running a jail for it (protection off, or fail2ban stopped). Other jails — SSH, other sites — never appear here.

POST /applications/{application}/fail2ban/bans

Permission: app_fail2ban (manage) | Throttle: 20/min

Request: {"ip": "198.51.100.20"} → 200 {"ban": {"ip": "…", "jail": "panel-site-shop"}}. Prefixed v8 jails use site-only bans (FB-K). Owned v7 jails retain the server-default firewall action, which can affect other sites or services: do not label these as site-only. List/ban/unban address the same ownership-checked actual jail. 422 for the server's own address or the caller's own; 409 reason: jail_not_enabled when the jail is not running.

DELETE /applications/{application}/fail2ban/bans/{ip}

Permission: app_fail2ban (manage) | Throttle: 20/min

Release an address from this application's jail → 200 {"unbanned": {"ip": "…", "jail": "…"}}. 404 when this jail has not banned it (an address banned by another jail is not released here), 409 reason: jail_not_enabled.


POST /applications/{application}/fail2ban

Permission: app_fail2ban (manage) | Throttle: 10/min

Validate, dry-run against fail2ban-client -t, save, write to disk, reload. The dry-run is the gate: a config that does not parse against fail2ban-client never reaches the live daemon.

Request:

{"jail_config_content": "[{name}]\nenabled  = true\nfilter   = {filter}\n...\n", "filter_config_content": "[Definition]\nfailregex = ^<HOST>\n...\n"}

Both fields are required, must be strings, and are capped at 65,535 characters.

The jail may only be the site's own. Every [section] in jail_config_content must be [{name}] or the site's jail name ([panel-site-shop]), and every filter = must be {filter} or that same name. [sshd], [DEFAULT] (which changes every jail on the server) or filter = sshd are valid fail2ban config, so -t accepts them, and are refused here with 422 and a validation error on jail_config_content naming the replacement.

Response 200 — test passed, configuration applied:

{"testOk": true, "message": "Fail2ban configured successfully!"}

Response 422 — test failed, nothing was saved or written to disk (was 500 until 2026-09-23; read testOk, as before):

{"testOk": false, "message": "Fail2ban configuration test failed.", "output": "ERROR: ..."}

DELETE /applications/{application}/fail2ban

Permission: app_fail2ban (manage) | Throttle: 10/min

Remove only this application's actual, ownership-checked jail and filter, reload, then clear the record. On a failed reload the previous files are restored and the record remains unchanged. No parallel-name cleanup or package/system file removal.

Response 200 — disabled:

{"message": "Fail2ban disabled successfully!"}

Response 422 — already disabled (no saved content; was 500 until 2026-09-23):

{"message": "Fail2ban is already disabled for this application."}

Application — Logs

GET /applications/{application}/logs

Permission: app_log (view)

Which log sources this application has, whether each exists yet, and how big it is.

{"logs": [
  {"key": "access", "label": "Access log", "kind": "file", "exists": true, "size": 1048576, "modified": "16-10-2026 12:00:00"},
  {"key": "error", "label": "Error log", "kind": "file", "exists": false, "size": null, "modified": null}
]}

Each source is {key, label, kind, exists, size, modified} (size and modified added 2026-10-16, same shape as the server Logs screen). size is bytes; modified is the last write, d-m-Y H:i:s in the app timezone (UTC). Both are null when there is no single file to measure (kind journal or container), when the file does not exist yet, or when it could not be read — never a made-up 0. Always access and error; a Node application adds its own process output (stdout and stderr, separately); a site with the WAF on adds waf_detect. Queue workers' logs are not here — they are on the server Logs screen under each worker's log_identifier (see Workers).


GET /applications/{application}/logs/{key}

Permission: app_log (view) | Throttle: 120/min

Read a log source — the last N lines of it, or N lines from a given line on.

Query: ?lines=200&grep=error (lines: default 200, max 5000; grep: case-insensitive literal filter)

v7's advanced logs (2026-10-17), all optional and combinable with grep:

Response 200:

{"log": {
  "key": "access", "label": "Access Log", "kind": "file", "exists": true,
  "lines": ["192.168.1.1 - - [29/Jul/2026:11:00:00 +0000] \"GET / HTTP/1.1\" 200 1234"],
  "first_line": null,
  "truncated": true,
  "search_window_capped": false
}}

There is no cursor and no ?after=. This reference described cursor-based tailing that was never implemented — poll the endpoint and diff client-side if you need a live tail.


GET /applications/{application}/logs/{key}/download

Permission: app_log (view) | Throttle: 6/min — added 2026-10-08 (LOG-dl)

The whole log as a file (Content-Disposition: attachment; filename="{slug}-{key}.log"), streamed — the screen shows only the last lines. 404 when the log does not exist yet or the site has no such source; 422 reason: not_downloadable for a container's output, which Docker keeps rather than a file. Recorded in the activity log.


DELETE /applications/{application}/logs/{key}

Permission: app_log (manage)

Empty one of this site's logs. manage, not view: reading a log and destroying it are not the same trust, and a viewer gets 403.

Response 200: {"log": {"key": "access", "label": "Access Log", "lines": [], "truncated": false}} — the source still exists, it is simply empty, so a client can render it rather than being told the key is gone.

Truncated, never deleted. An open log file that is unlinked keeps its disk space until the writer restarts and breaks its own handle — rm would free nothing and leave nginx or the Node unit appending to a file no screen can read. truncate -s 0 reclaims the space with the writer still attached, and keeps the inode's owner and mode, which matters because these files belong to the site's system user and the web server will not recreate them with the right ownership.

404 for a key this application does not have. The key is resolved through the same catalogue every read uses, so a request can never aim this at a path of its own choosing — which is what makes a DELETE safe to offer here at all.

Every clear is written to the activity feed as application.log_cleared with the source named. Destroying a record without recording that it was destroyed is the one version of this feature a support conversation cannot recover from.

There is deliberately no equivalent for the server-wide logs (/logs/{key}). That catalogue includes auth.log, ufw.log and fail2ban.log — the record of what happened to the machine, and the first thing an intruder would want erased. A one-click wipe of those is an anti-forensics button rather than a maintenance one. A site's own access log is the site's own noise, which is a different thing.


Application — Workers (Queue / Background Processes)

GET /applications/{application}/workers

Permission: app_worker (view)

Workers are supervisord programs — one [program:{program}] block per worker in /etc/supervisor/conf.d (legacy workers retain [program:sv-worker-{slug}]), with supervisor owning the copies via numprocs. (They were systemd units until 2026-09-07; this section described that and was wrong for a day.) The panel's field names are its own, not supervisor's — processes is numprocs, auto_start is autostart, auto_restart is autorestart, stop_wait_seconds is stopwaitsecs. Send the panel's names:

{"workers": [{
  "id": 1,
  "application_id": 1,
  "name": "queue",
  "command": "php8.4 artisan queue:work --sleep=3 --tries=3 --max-time=3600",
  "kind": "queue", "kind_title": "Queue worker",
  "directory": null,
  "processes": 4,
  "stop_wait_seconds": 10,
  "auto_restart": true,
  "restart_on_deploy": true,
  "enabled": true,
  "user": null,
  "effective_user": "shop",
  "log_file": null,
  "log_level": null,
  "extra_config": null,
  "auto_start": true,
  "running": 3,
  "state": "degraded", "state_title": "Degraded",
  "log_identifier": "sv-worker-shop-email-queue",
  "created_at": "28-07-2026 11:00:00", "created_at_human": "3 days ago"
}], "presets": [
  {"key": "queue", "kind": "queue", "title": "Queue worker", "description": "…", "command": "php8.4 artisan queue:work --sleep=3 --tries=3 --max-time=3600"},
  {"key": "horizon", "kind": "horizon", "title": "Horizon", "description": "…", "command": "php8.4 artisan horizon"},
  {"key": "custom", "kind": "custom", "title": "Custom command", "description": "…", "command": ""}
], "checks": [
  {"code": "cache_driver_array", "severity": "warning", "title": "…", "detail": "…"}
]}

running and state are read from supervisord on every request and never stored. state is running (all processes up), degraded (some up), or stopped (none) — "3 of 4 running" is a real condition that a single green dot would hide, so show running against processes.

enabled: false means the program block is kept but not started — disabling is not deleting.

enabled and auto_start are different questions and the UI should not merge them. enabled is the panel's: does this worker run at all. auto_start is supervisor's autostart: when supervisord itself starts, does it bring this program up. A worker can be enabled with auto_start: false — it exists and you start it by hand.

user is the account the processes run as. Null means the panel chose, and effective_user says what that resolves to (the site's own system user) — show effective_user as the hint, not as the field's value, or saving the form writes a choice nobody made. A non-null user is normally an adopted block naming another account, which the panel preserves rather than rewriting.

log_file is stdout_logfile, with redirect_stderr on so one file holds both streams. Null means {application root}/logs/supervisor-{program}.log for v7-layout workers, or the unchanged {application root}/logs/sv-worker-{slug}.log for legacy workers. log_level is supervisord's own: critical | error | warn | info | debug | trace | blather.

extra_config is appended to the program block verbatim, so a directive there wins over everything the panel wrote — the same shape as additional_directives on PHP settings. It may not contain [: a section header would define a second program the panel does not know about and would never stop. Max 2000 characters; both are 422s.

kind is queue, horizon or custom, and it decides how a restart happens: queue:restart for a queue worker, horizon:terminate for Horizon (both run as the worker's own account), a supervisor restart otherwise — and also whenever the graceful command fails. It is not cosmetic. A queue worker and Horizon on the same application both consume the same queue and would run every job twice — neither tool can detect the other, so the request layer rejects the combination.

restart_on_deploy makes the worker pick up new code after a deploy; without it a deploy leaves the site on new code and the queue on old code, with nothing anywhere connecting the two.

log_identifier is the opaque key of this worker's source on the Logs screen. Use it directly for /logs?source={log_identifier}; do not construct it.

Newly saved workers use v7's /etc/supervisor/conf.d/{program}.conf and [program:{program}]. The program is a globally unique, file-safe slug of the worker's name (max 64 characters including collision suffixes). Existing disk filenames and program headers, including foreign/shared configs, are reserved. A label rename does not move a worker that already has a stored program.

The nullable internal workers.program column is not client input. Null continues to identify the legacy sv-worker-{slug} group and its existing log. Adding the column does not stop or rewrite workers. The first explicit save moves an owned legacy definition to v7 naming: stop/remove/reload the old group before starting the new one, restoring the legacy definition on a recoverable failure. Missing, unreadable or replaced legacy definitions are refused rather than deleted or duplicated. No bulk backfill runs on deploy. Controls and Sync use the stored program (legacy fallback when null). Log paths follow it too, while log-source keys retain the globally unique sv-worker-{slug} namespace. A worker named mysql must never select the server database log, and existing links remain valid across the move.

If a create fails and stopping/removing its new program cannot be verified, the worker row is retained for recovery rather than losing ownership of a possibly running process. An identical create retry is refused by name uniqueness; inspect/edit/control that retained worker instead. Verified cleanup still removes a failed-create row. Supervisor updates are scoped to the owned program, not unrelated pending config changes.

presets are starting points for the detected framework — key/kind/command, already localised. Craft returns queue + custom; Statamic returns queue + horizon + custom; Node applications without a recognised queue framework (including n8n and Node-RED) return only custom. Detection follows the application's actual project root rather than assuming its served directory contains the CLI, so Craft's craft, Statamic's please/artisan, and brownfield Laravel layouts with artisan beside public/ are found correctly. When directory is null, the worker uses that same detected project root as its working and writable directory.

checks is a list (empty for non-Laravel sites), each entry a code + severity + localised title/detail; cache_driver_array is the one worth surfacing loudly, because on the array cache driver queue:restart silently does nothing.


POST /applications/{application}/workers

Permission: app_worker (manage) | Throttle: 20/min

Add a worker.

Request:

{
  "name": "queue",
  "command": "php8.4 artisan queue:work --sleep=3 --tries=3",
  "kind": "queue",
  "directory": null,
  "processes": 2,
  "stop_wait_seconds": 10,
  "auto_restart": true,
  "restart_on_deploy": true,
  "enabled": true,
  "user": null,
  "log_file": null,
  "log_level": null,
  "extra_config": null,
  "auto_start": true
}

name and command are required; everything else is optional. name is unique per application. command rejects shell metacharacters (; | & \ $ < > ( )and newlines) — it is executed directly, not through a shell.directoryis relative to the application root, defaults to it, and may not contain... processesis 1–16,stop_wait_seconds1–600.user is a Linux account name (^[a-z_][a-z0-9_-]*$, max 32). log_filemust be **absolute** and may not contain... log_levelis one of supervisord's seven levels.extra_configis max 2000 characters and may not contain[`.

⚠️ stop_wait_seconds must be at least the worker's own --max-time, or supervisor SIGKILLs it partway through a job and that job runs twice.

Response 201: {"worker": {...}}


PUT /applications/{application}/workers/{worker}

Permission: app_worker (manage) | Throttle: 20/min

Update worker settings. Changes rewrite the program block, then supervisorctl reread followed by update, and restart the worker.

Request: the same fields as create, with name and command required (it is the create form sent again — the panel's edit dialog always sends the whole form). Corrected 2026-09-29: this said all fields were optional, and a partial body like {"processes": 4} is a 422.

Response 200: {"worker": {...updated...}}


DELETE /applications/{application}/workers/{worker}

Permission: app_worker (manage) | Throttle: 20/min

Remove the worker (stops the processes, removes the program block, deletes the record).

Response 204: null


POST /applications/{application}/workers/{worker}/{action}

Permission: app_worker (manage) | Throttle: 30/min

Control a running worker. {action} = start | stop | restart.

Response 200: {"worker": {"id": 1, "running": 4, "state": "running", "state_title": "Running", …}} — the full worker object, with running/state re-read from supervisord after the action.


Application — Site Clone

GET /clones

Permission: app_clone (view)

Every clone across every application, newest first. For resuming a clone from a different browser session than the one that started it.

{"clones": [{"id": 1, "source_application_id": 5, "source_application_name": "shop", "target_application_id": 8, "name": "shop-backup", "domain": "backup.example.com", "status": "completed", "status_title": "Completed", "current_step": null, "reason": null, "started_at": "29-07-2026 10:00:00", "finished_at": "29-07-2026 10:03:00"}], "meta": {"current_page": 1, "per_page": 20, "total": 3, "last_page": 1}}

POST /applications/{application}/clone

Permission: app_clone (manage) | Throttle: 5/min

Duplicate an application to a new domain. Runs async on the queue.

Not available on every site type. Cloning copies the served files; a database-backed application needs a recipe on top of that (dump, load, rewrite the stored URL), and only WordPress has one. So app_clone is absent from the feature list of Akaunting, CraftCMS, Joomla, Mautic, Moodle, Nextcloud, NodeBB, PrestaShop — and from phpMyAdmin, which holds no content of its own. For those types this endpoint returns 404, and app_clone is omitted from GET /permissions?application_id=…, so the sidebar item does not render. Types that need no database (static, plain PHP, git, Statamic, n8n, Node-RED, Uptime Kuma) clone generically and are unaffected.

A git site's databases are copied too (2026-10-06, CLN-01). Every database linked to the source (databases.application_id) is dumped into a new database and user of its own, linked to the clone. The clone's .env is then written from the source's, with values that are exactly the source's database name, user or password swapped for the copy's (DB_DATABASE, PGDATABASE, and so on). A connection URL (DATABASE_URL=mysql://user:pass@host/db) gets the same swap, and a URL whose host is the source's domain (APP_URL) gets the clone's domain. Every other line is left as it is. Before this, the clone ran on the source's live database. If the clone fails, a database made for it is dropped again.

Request:

{"name": "shop-backup", "domain": "backup.example.com"}

One clone of a site at a time (2026-10-07, CL-B1). While a clone of the same source is pending or running the request answers 409 (clone.errors.already_running) — it used to be accepted and its job silently dropped, leaving the row pending forever. A pending clone that never started within an hour is marked failed with reason abandoned first. The domain of a clone still being made counts as taken (422 on domain).

A failed clone carries a reason code and a reference, never the exception text (CL-B2): reason is one of setup_failed, copy_failed, failed, crashed, abandoned, with reason_title translated and reference to quote. A copy left half-made by a job killed at its time limit is removed (CL-B4). WordPress copies (and staging) keep the source's own $table_prefix (CL-B5).

Only name and domain. The clone always lands under the source site's own system user — a system_user_id or site_user_password sent here is not accepted and has no effect (it used to be documented, and was silently dropped).

Response 202:

{"clone": {
  "id": 1, "name": "shop-backup", "domain": "backup.example.com",
  "status": "pending", "status_title": "Queued",
  "source_application": {"id": 1, "name": "shop"},
  "current_step": null, "step_number": null, "total_steps": 7,
  "started_at": "29-07-2026 10:00:00"
}}

GET /clones/{clone}

Permission: app_clone (view) | Throttle: 120/min

Poll while running.

{"clone": {
  "id": 1, "status": "running", "status_title": "Cloning…",
  "current_step": "Copying files", "step_number": 4, "total_steps": 7,
  "started_at": "29-07-2026 10:00:00", "finished_at": null
}}

On completion: status: "completed", "finished_at: "29-07-2026 10:03:00". On failure: status: "failed", "status_title: "Clone failed", "reason": "…".


Application — One-click update

Updates a site's own software in place, as v7 did, for Moodle, Mautic, Statamic, Craft CMS, Uptime Kuma, phpMyAdmin, n8n and Node-RED (the last two since 2026-10-15). Updates stay inside the installed line — the major version (Moodle: the branch, e.g. 5.0). A newer line is reported in newer_major and never applied. The panel does not take a backup: the screen offers one first (backups screen) and shows last_backup_at. Every command runs as the site's user on the site's own PHP/Node.

App What the update does
Moodle maintenance on → newest package of the installed branch over the code (config.php kept, moodledata is outside) → admin/cli/upgrade.php --non-interactive → maintenance off (also on failure)
Mautic full release package of the target over the code (config/local.php kept) → doctrine:migrations:migrate → mautic:cache:clear. (v7's mautic:update:* commands no longer exist in Mautic 7.)
Statamic composer update statamic/cms --with-all-dependencies (keeps the project's constraint) → please stache:refresh
Craft CMS craft update craft:{target} --interactive=0 --backup=0 --migrate=1
Uptime Kuma git fetch + git checkout --force {tag} → npm run setup → restart the site's systemd unit (data/, .env kept)
phpMyAdmin the versioned release package over the code (config.inc.php kept)
n8n, Node-RED refuse if the target's engines.node excludes the site's Node (node_unsupported) → node_modules moved aside → npm install --omit=dev {package}@{target} in the site → restart; n8n then waits for /rest/settings (it migrates its database on start). Install or start failing puts the previous node_modules back (app_not_ready for a start that never came up). .env (n8n's encryption key), settings.js and flows are never written. Target = npm's own tag for the line — latest, or Node-RED's vN-maintenance — never a plain-numbered pre-release such as n8n's next.

Success is decided by the version the files carry afterwards; an update that ran but left the old version fails with version_unchanged.

GET /applications/{application}/update

Permission application (view). Also the poll while an update runs (throttle:progress).

{
  "update": {
    "supported": true,
    "installed_version": "5.0.9",
    "latest_version": "5.0.11",
    "newest_version": "5.3.0",
    "update_available": true,
    "newer_major": "5.3.0",
    "blocked_reason": null,
    "last_backup_at": "2026-10-13 09:10:02"
  },
  "history": [ { "id": 3, "from_version": "5.0.8", "to_version": "5.0.9", "status": "completed", "status_title": "Updated",
                 "reason": null, "reason_title": null, "step": null, "reference": null, "user": "admin",
                 "started_at": "…", "finished_at": "…", "created_at": "…" } ]
}

blocked_reason: null (can update) · unsupported · not_active (not active, or disabled) · version_unknown (installed version unreadable) · lookup_failed (release lookup failed — not cached, retried next time) · up_to_date · running. Release lookups are cached for 30 minutes per type and installed version.

POST /applications/{application}/update

Permission application manage, throttle:10,1. No body: the target is latest_version. 202 {"update": {…status: "pending"…}} — runs on the queue; poll GET until history[0].status is completed or failed. 409 an update of this site is already running. 422 errors.update[0] = the readable blocked_reason. A pending update whose job never started is released (abandoned) after 60 minutes. Failure reason: step_failed (with step + reference into the server-ops log), version_unchanged, release_not_found, download_unreachable, out_of_memory, composer_platform, no_build_tools, node_unsupported, app_not_ready, worker (job died/timed out), abandoned, failed. Activity: application.update_started, application.updated, application.update_failed.

Backups

GET /backup-targets

Permission: backup (view)

Every application and its backup configuration — the overview screen.

Driven from applications, not from backup targets — a list built from targets could only ever return the sites that are already protected, and the question this screen exists to answer is which ones are not.

Paged. ?search= case-insensitively matches application name and domain; ?filter[protected]=0 is the one this screen exists for — the sites with no backup configured at all; ?sort=name|domain|created_at, default name ascending with case-insensitive name ordering; ?per_page=10|20|30|50|100, default 10.

meta.total / meta.protected / meta.unprotected count every application on the server, not the current page and not the current filter — the header reads "N of M sites protected", and that sentence must not change when you turn the page. meta.matched is the separate number: how many rows the current search and filter found. Two different questions, two fields.

{"backup_targets": [
  {
    "application_id": 1, "application_name": "shop", "application_domain": "shop.example.com",
    "backup_target": {"id": 1, "type": "full", "frequency": "daily", "…": "…"},
    "last_backup": {"id": 15, "status": "verified", "created_at": "28-07-2026 02:00:00", "…": "…"}
  },
  {
    "application_id": 2, "application_name": "blog", "application_domain": "blog.example.com",
    "backup_target": null,
    "last_backup": null
  }
], "meta": {"total": 7, "protected": 4, "unprotected": 3, "configured": 5}}

protected means backed up on a schedule (since 2026-10-08, OLD-9): a target that is switched off, or manual, does not count, and filter[protected] uses the same rule. configured is how many sites have backup settings at all.

There is no configured flag — backup_target === null is the unprotected state. backup_target and last_backup are the full objects documented below.

last_backup is the newest run however it ended, not the last successful one. A failed run replaces a good one here; read its status before showing a reassuring date.


GET /applications/{application}/backup-target

Permission: app_backup (view)

Backup settings for one application.

{"backup_target": {
  "id": 1,
  "application_id": 1,
  "storage_destination_id": 1,
  "storage_destination_name": "S3 Backup",
  "type": "full", "type_title": "Files and database",
  "retention_count": 7,
  "frequency": "daily", "frequency_title": "Daily",
  "schedule_time": "02:00",
  "enabled": true,
  "file_excludes": ["storage/framework/cache", "node_modules"],
  "database_excludes": ["sessions", "cache"],
  "last_run_at": "28-07-2026 02:00:00", "last_run_at_human": "3 days ago",
  "last_backup_at": "28-07-2026 02:03:41", "last_backup_at_human": "3 days ago",
  "next_run_at": "29-07-2026 02:00:00", "next_run_at_human": "in 20 hours",
  "timezone": "UTC",
  "is_due": false,
  "created_at": "25-07-2026 14:30:00",
  "updated_at": "28-07-2026 02:00:05"
}}

Show last_backup_at as "Last backup" (added 2026-10-08, BK-A): when the newest verified backup that still exists finished, null once every backup is deleted. last_run_at is when the scheduler last ran the target; it stays after the backups are deleted, because the schedule needs it.

There is no schedule or retention field — they are frequency and retention_count. There is no nested storage_destination object either: only storage_destination_id plus a flat storage_destination_name (and that name is present only when the endpoint loads the relation — it does here and on save).

frequency is manual · hourly · every_3_hours · every_6_hours · every_12_hours · daily · weekly · monthly; schedule_time is HH:MM. type is filesystem · database · full. Take the lists from GET /backup-targets/options rather than hardcoding them.

What schedule_time means depends on the frequency (added 2026-09-24):

retention_count counts backups, not days, so on hourly a retention of 7 is seven hours of history. Say so next to the field. A run still in progress when the next slot comes makes that slot be skipped rather than start a second backup, and a slot missed while the server was down runs once when it is back.

GET /backup-targets/options

Permission: app_backup (view)

Everything the backup settings form offers, already translated into the request's language. Built from the same lists the save validation uses, so every value offered is accepted and the reverse.

{
  "frequencies": [
    {"value": "manual", "label": "Manual only", "time": null, "hint": "Runs only when you start it."},
    {"value": "hourly", "label": "Every hour", "time": "minute", "hint": "Runs every hour, at the chosen minute."},
    {"value": "every_3_hours", "label": "Every 3 hours", "time": "time", "hint": "Runs at the chosen time and every 3 hours around the clock."},
    "…",
    {"value": "monthly", "label": "Monthly", "time": "time", "hint": "Runs on the 1st of each month at the chosen time."}
  ],
  "default_frequency": "daily",
  "types": [{"value": "filesystem", "label": "Files"}, {"value": "database", "label": "Database"}, {"value": "full", "label": "Files and database"}],
  "retention": {"min": 1, "max": 365},
  "timezone": "UTC"
}

time says which picker to render: minute (a minute only), time (hour and minute), or null (none: manual). frequencies is in display order.

🔴 schedule_time and next_run_at are in timezone, which is the panel's clock — not the server's. (This line previously said "the server's timezone" and was wrong.) The scheduler resolves the slot against the application timezone, so on a box set to anything else, 02:00 is not 02:00 to the person who typed it — a user on Asia/Kolkata gets it at 07:30. Always render timezone beside the time; a bare 02:00 reads as local time to everyone, and nothing else in the response reveals otherwise.

⚠️ Its value is deliberately different from a cron job's. CronjobResource.timezone names the server's timezone, because Linux cron genuinely runs on the OS clock. Backups do not. Same field name and same IANA format across both, different value on purpose — do not "fix" one to match the other. (Even on a UTC box the two strings differ: the OS reports Etc/UTC, the app reports UTC.)

Read is_due, not next_run_at, to decide whether a run is imminent. A brand-new target is due immediately — it will run on the next scheduler tick — while next_run_at names the next scheduled slot, which can be tomorrow. The two disagreeing is correct, not a bug.

backup_target: null — not configured.


PUT /applications/{application}/backup-target

Permission: app_backup (manage)

Configure or update backup settings.

Request:

{
  "storage_destination_id": 1,
  "type": "full",
  "frequency": "daily",
  "schedule_time": "02:00",
  "retention_count": 7,
  "enabled": true,
  "file_excludes": ["node_modules"],
  "database_excludes": ["sessions"]
}

storage_destination_id, type, frequency, retention_count and enabled are all required — this is a full save, not a patch. retention_count is 1–365. schedule_time must be H:i. The two exclude arrays are optional, max 100 entries each.

Lowering retention_count deletes backups immediately, archives included, down to the new number. It used to take effect only at the end of the next run, so the setting saved and nothing happened. Raising it deletes nothing, so saving this screen can never remove more than the user just asked to keep. Verified backups only, and never a safety copy.

Response 200: {"backup_target": {...}}


DELETE /applications/{application}/backup-target

Permission: backup (manage) | Throttle: 12/min

Stop backing this application up.

Refuses while backups exist, naming how many, unless the caller confirms they go too:

{"delete_backups": true}

Without it: 422 — "This application still has 7 backup(s). Confirm that they should be deleted too, or delete them first." The schedule is cheap to retype; the archives are somebody's only copy, so they are never removed as a side effect.

With it, every backup is deleted through the same path as the single delete below — archive first, then the row — so an archive that cannot be removed stops the whole operation rather than stranding the rest.

422 while a backup is running. Response 204.

Note the permission: backup (manage), not app_backup. Setting a schedule is a per-site decision; this can take every archive with it.


POST /applications/{application}/backups

Permission: app_backup (manage) | Throttle: 6/min

Run a backup immediately.

Response 202: the backup target, not the backup — {"backup_target": {"id": 1, "frequency": "daily", …}}

The Backup row does not exist until a queue worker picks the job up, so there is no id to return here. Find the run with GET /backups?filter[application_id]={id} (newest first) and poll it from there. is_due comes back true immediately after dispatch and clears once last_run_at is recorded.

422 if not configured or if a backup is already in progress.


GET /backups

Permission: backup (view)

Every backup across every application — paginated, filterable.

Query: ?filter[application_id]=1&filter[status]=verified&filter[type]=full&filter[from]=2026-07-01&filter[to]=2026-07-31&page=1&per_page=20

storage_destination_name (added 2026-09-09) is where this archive went, which is not always where the target points now — the destination is editable, so an older backup can sit somewhere the current setting no longer names. Present on this list and on GET /backups/{backup}; absent, not null, on any endpoint that does not load the relation, so an absent key means "not asked for" rather than "no destination".

Corrected the same day: it first resolved through the backup's target, so it reported the destination configured now against every historical row — the opposite of what the field is for. Each backup now records its own destination when it is created, and download, restore, delete and retention all resolve through that. Backups written before this are backfilled from their target, which is right unless that target had already been repointed; for those the recorded destination is the best available answer and the archive may genuinely be unreachable.

{"backups": [{
  "id": 15,
  "uid": "9c1f0b1e-6d3a-4c2b-9a77-1f2e3d4c5b6a",
  "application_id": 1,
  "application_name": "shop", "application_domain": "shop.example.com",
  "storage_destination_name": "S3 Backup",
  "type": "full", "type_title": "Files and database",
  "is_safety": false,
  "status": "verified", "status_title": "Complete",
  "size_bytes": 52428800,
  "reason": null, "reason_title": null,
  "reference": "9f8e7d6c-5b4a-4321-9876-0fedcba98765",
  "started_at": "28-07-2026 02:00:00",
  "finished_at": "28-07-2026 02:04:00",
  "verified_at": "28-07-2026 02:04:10",
  "created_at": "28-07-2026 02:00:00", "created_at_human": "Yesterday"
}], "meta": {"current_page": 1, "per_page": 20, "total": 45, "last_page": 3, "counts": {"total": 45, "pending": 0, "running": 1, "verifying": 0, "completed": 40, "failed": 4}}}

status is pending · running · verifying · verified · failed. There is no completed — a finished, checked backup is verified, and filter[status]=completed is rejected as an invalid enum value. (The meta.counts key is spelled completed, and counts the verified rows. That inconsistency is in the response; it is not a typo here.)

verified_at is when the archive was checked after upload, which is the only timestamp that means the backup is actually restorable. finished_at only means the process stopped.

uid (added 2026-09-11) is the name this archive has in the destination — show it wherever someone may go looking in the bucket. id cannot serve: it is an autoincrement meaningful only inside one panel's database, so it identifies nothing to anyone reading the storage directly. Read-only.

The application is flattened as application_name / application_domain — there is no nested application object, and no size_human: format size_bytes yourself.

Note the label: status: "verified" renders as "Complete", not "Verified" — status_title is written for the person reading the screen. Render it; do not build your own map from the raw value.

reason is a classified failure code with reason_title its localised sentence; both null unless status is failed. reference is a UUID assigned when the run starts — never null, on any status — and the id to quote to support; a failure is in Admin → Error log under it. There is no log_key since 2026-10-08 (OLD-10): it pointed at a server log no screen can open. error_class / error_class_title (FS-C33) say why the storage refused, for a failure in upload_artifact, verify_artifact or prune_old_backups — the destination test's categories (invalid_credentials, bucket_not_found, unreachable…); null otherwise. is_stale / clearable (BK-B) say whether POST /backups/{id}/clear would accept the row.

is_safety: true marks the automatic pre-restore snapshot rather than a backup anyone scheduled — worth distinguishing in the list so it does not read as a stray extra run. The newest two per site are kept; each restore removes older ones, archive included (until 2026-09-24 only the row was removed, so every restore from the third on left a full-site archive in the bucket that the panel could no longer see). An archive that cannot be removed keeps its row and is retried by the next restore.


GET /applications/{application}/backups

Permission: app_backup (view) — added 2026-10-08 (BK-K)

One application's backups, for the application's own Backups tab. Same response, filters (filter[status|type|from|to], per_page) and meta.counts as GET /backups, always limited to this application. GET /backups needs the server-wide backup permission, which shows every site; a role with only app_backup gets 403 there and should use this.


GET /backups/{backup}

Permission: backup (view)

Poll one backup.

Same object as the list above — {"backup": {…}}. Poll until status is verified or failed.


GET /backups/{backup}/download

Permission: backup (manage) | Throttle: 6/min

Get a time-limited presigned download URL for the archive.

{"download": {
  "url": "https://s3.amazonaws.com/bucket/abc123?X-Amz-Signature=…",
  "expires_at": "29-07-2026 10:05:00",
  "filename": "shop-example-com-2026-07-28-020000-full.tar.gz",
  "size_bytes": 52428800
}}

url expires 5 minutes after issuance. filename is human-readable (domain-YYYY-MM-DD-HHMMSS-type.tar.gz).


POST /backups/{backup}/retry

Permission: app_backup (manage) | Throttle: 6/min

Re-run a failed backup with the same configuration.

Response 202: {"backup_target": {"id": 1, …}}

422 if the backup did not fail or if a backup is already in progress.


POST /backups/{backup}/clear

Permission: app_backup (manage) | Throttle: 6/min

Close out a backup stuck in flight after its worker died — a row left at running or pending that nothing is working on any more.

This matters because a stranded row blocks the site. Every path that starts a backup refuses while one is in flight, so until the row is closed that application cannot be backed up at all.

Not a cancel. A run that could still be executing is refused with 422, and the message says how long until it clears itself. There is no way from the panel to stop a job already running on a worker, and marking it failed would leave the row saying one thing while the process kept going — and free the guard for a second backup to start alongside the first, both writing to the same archive key.

Response 200: {"backup": {"id": 9, "status": "failed", "reason": "abandoned", …}}

422 if the backup is not in flight, or if it is too recent to be certain it is dead.

You usually will not need this. Stranded runs are closed out automatically the next time anything starts a backup for that target, and by the scheduler tick. This endpoint exists so nobody has to wait for that.

Two reason values relate to this:

value meaning
crashed the worker died and the panel noticed — OOM, a restart mid-backup
abandoned the run never reported back at all and was closed out later

DELETE /backups/{backup}

Permission: backup (manage) | Throttle: 12/min

Delete one backup — the archive on the storage destination as well as the panel's record of it.

The order is archive first, then the row, and it matters: deleting the row first would leave an object in the bucket that nothing in the panel knows about — unfindable, undeletable, and billed every month until somebody goes looking.

Response 204.

Same permission tier as restore and download rather than the per-site app_backup: whoever can configure a schedule is not automatically trusted to destroy what it produced.


Backups of a database on its own

Added 2026-10-16 (v7's backup type "database" with only a database). A database with no site gets its own schedule; a site's database is backed up with the site's target (type database/full) and its schedule here is refused. Permission: database — view for reading, manage for everything else (decision 2026-10-16), not the server-wide Backups permission.

The run is the site pipeline: dump that one database (all engines), pack it, upload under databases/{name}-{id}/ on the destination, verify, prune to retention_count. Nothing of any site is read.

GET /databases/{database}/backup-target

{"backup_target": {…same shape as a site's, with "database_id"…} | null, "backed_up_with": null | {"application_id": 3, "application_name": "Shop"}} — backed_up_with set means the database belongs to a site: show "Backed up with site Shop" instead of the form.

PUT /databases/{database}/backup-target

Throttle: 20/min. {"storage_destination_id": 1, "retention_count": 7, "frequency": "daily", "schedule_time": "02:00", "enabled": true} — the frequencies and retention limits of GET /backup-targets/options. The type is always database; there are no excludes. 422 on database when the database belongs to a site ("…is backed up with it. Set up backups on the site instead.").

DELETE /databases/{database}/backup-target

Same rules as a site's: refused while a run is in flight; refused while backups exist unless ?delete_backups=1 (each deleted archive-first).

GET /databases/{database}/backups

Its backups — the shape and filters (status, type, from, to, per_page) of GET /backups.

POST /databases/{database}/backups

Run now. Throttle: 6/min. 202 {"backup_target": {…}}. 422 when not configured, already running, or a restore/import of the database is running.

POST /databases/{database}/backups/{backup}/restore

Throttle: 2/min. {"confirm": "<the database name, typed>"}. Restores its own backup, or an archived one (below) of the same engine, into this database. 202 {"restore": {…}}; poll GET /restores/{id}.

A restore first takes a safety backup of the database as it is now (through this database's own schedule — so the database needs one), then drops, recreates, gives its users back and loads the dump. No site file and no process is touched.

422 — the backup is not verified; it belongs to another database and is not archived; engine differs ("This backup is from a mysql database and cannot be restored into a postgresql database."); the database has no backup schedule ("Set up backups for this database first…"); wrong confirm; a restore, import or export of this database is running. Imports and exports refuse while a restore runs, too.

GET /databases/{database}/backups/{backup}/download · DELETE /databases/{database}/backups/{backup}

As GET /backups/{backup}/download and DELETE /backups/{backup}, for this database's own backups only (404 otherwise).

Archived backups

Deleting a database archives its backups instead of deleting them (decision 2026-10-16): the schedule goes, the backups stay — database_id becomes null, archived: true, and database_name/database_engine still say what they were. They stay listed in GET /backups (?filter[archived]=1, ?filter[engine]=mysql), downloadable and deletable there, and restorable into another database of the same engine with the route above called on that database. Nothing deletes them automatically. Deleting a database is refused (422) while a backup or restore of it is running.

Deleting a site archives its backups too (follow-up, 2026-10-16): until then the rows went with the site and the archives stayed in the bucket with nothing pointing at them. Now they stay listed (archived: true, site_name = the site's domain), downloadable and deletable; they cannot be restored (422, "This site was deleted… Download the backup…"). DELETE /applications/{application} is refused (422 on application) while a backup or restore of the site is running. The activity entry application.deleted carries backups_archived.

GET /backups rows gained database_id, database_name, database_engine, archived, site_name (null/false where they do not apply), and the filters filter[database_id], filter[archived], filter[engine]. POST /backups/{backup}/restore refuses a database backup (422, "Restore a database backup from the database's own page.").


Backup — Restores

GET /restores

Permission: backup (view)

Restore history — what was restored, when, and by whom. Paginated.

Query: ?filter[application_id]=1&filter[status]=succeeded&page=1

{"restores": [{
  "id": 3, "backup_id": 14, "application_id": 1,
  "application_name": "shop", "application_domain": "shop.example.com",
  "type": "full", "type_title": "Files and database",
  "status": "succeeded", "status_title": "Restored",
  "current_step": null, "current_step_title": null,
  "step_number": null, "total_steps": 7,
  "reason": null, "reason_title": null,
  "safety_backup_id": 16,
  "rollback_path": "/home/siteowner/.rollback-3",
  "reference": "3c2b1a09-8f7e-4d6c-5b4a-392817465fed",
  "created_at": "28-07-2026 09:59:58", "created_at_human": "3 days ago",
  "started_at": "28-07-2026 10:00:00", "started_at_human": "3 days ago",
  "finished_at": "28-07-2026 10:05:00", "finished_at_human": "3 days ago"
}], "meta": {"current_page": 1, "per_page": 20, "total": 3, "last_page": 1}}

The application is flattened as application_name / application_domain — no nested object.

safety_backup_id — the pre-restore snapshot created automatically. rollback_path — the previous site directory still on disk. Only the newest successful restore of a site keeps one (2026-09-24): each successful file restore removes the copies earlier restores left, and their rollback_path becomes null. Every restore made a full copy of the site and none was ever removed, so five restores of a 117 MB site left 585 MB. A failed restore's copy is never removed.

current_step is the machine key, current_step_title its localised sentence, and step_number / total_steps turn it into a progress bar. step_number is null when nothing has started yet; total_steps is always populated, so a bar can be rendered before the first step reports.

The seven keys, in order: download_artifact · verify_download · safety_backup · extract_archive · restore_database · swap_files · restart_process. Branch on those, never on current_step_title — the title is translated and changes with the viewer's locale.

status is pending · running · succeeded · failed (labels: Queued · Restoring · Restored · Restore failed). reference is a UUID assigned at creation — never null.


GET /restores/{restore}

Permission: backup (view) | Throttle: 120/min

Poll a running restore.

{"restore": {
  "id": 3, "status": "running", "status_title": "Restoring",
  "reason": null, "reason_title": null,
  "current_step": "swap_files", "current_step_title": "Putting the files in place",
  "step_number": 6, "total_steps": 7,
  "started_at": "28-07-2026 10:00:00", "finished_at": null
}}

On success: status: "succeeded", safety_backup_id, rollback_path. On failure: status: "failed", reason (step name), reason_title (localised explanation of whether anything changed).


POST /backups/{backup}/restore

Permission: backup (manage) | Throttle: 2/min

Restore an application from this backup. Destructive — overwrites live data.

Request:

{"type": "full", "confirm": "shop.example.com"}

confirm is required and must match the application's domain exactly (trimmed, case-sensitive). Everything else in this feature guards against the system going wrong; this guards against the person going wrong, which is the more common failure — so it is typed, not clicked.

type is optional and is one of filesystem · database · full — the same vocabulary as everywhere else in this feature. There is no files; sending it is a 422. Omit it and the restore uses whatever the backup itself holds, which is the right default.

Five separate 422s, all worth distinguishing in the UI:

Condition Error key on
Backup is not status: "verified" — we cannot prove it arrived intact backup
The backup's application no longer exists backup
A restore for this application is already pending or running backup
confirm does not match the domain confirm
Asked for more than the archive holds (full from a database-only backup) type

That last one is a subset check, not a formality: restoring full from a database-only backup would swap an empty directory over a working site.

Response 202: the RestoreResource — {"restore": {"id": 3, "status": "pending", "type": "full", "total_steps": 7, …}}. There is no confirm field in the response; it is an input only.

Poll GET /restores/{id} until status is succeeded or failed.


Databases

GET /databases/engines

Permission: database (view)

Capability list for every engine the panel knows. Four as of 2026-09-10 — PostgreSQL joined MySQL, MariaDB and MongoDB.

PostgreSQL is installable (added 2026-09-10) from Ubuntu's own archive — no third-party repository, unlike MongoDB. POST /databases/engines/postgresql installs it, starts its cluster and provisions the panel's own role.

system_schemas added 2026-09-11 — the engine's own databases, which the panel never creates, drops or alters. Read it from here rather than hardcoding a list: it is per driver, so MySQL and MariaDB answer identically, and a new engine arrives with its own set.

{"engines": [{
  "engine": "mysql", "driver": "mysql", "running": false, "version": null,
  "system_schemas": ["information_schema", "mysql", "performance_schema", "sys"],
  "installed": false, "installable": true,
  "install_status": null, "install_reason": null, "install_message": null,
  "install_progress": null
}, {
  "engine": "mariadb", "driver": "mysql", "running": true, "version": "10.11.4",
  "installed": true, "installable": true,
  "install_status": null, "install_reason": null, "install_message": null,
  "install_progress": null
}, {
  "engine": "mongodb", "driver": "mongodb", "running": false, "version": null,
  "system_schemas": ["admin", "config", "local"],
  "installed": false, "installable": true,
  "install_status": null, "install_reason": null, "install_message": null,
  "install_progress": null
}, {
  "engine": "postgresql", "driver": "pgsql", "running": true, "version": "16.4",
  "system_schemas": ["postgres", "template0", "template1"],
  "installed": true, "installable": true,
  "install_status": null, "install_reason": null, "install_message": null,
  "install_progress": null
}]}

Two things differ for postgresql, and both change what the API accepts.

charsets are PostgreSQL's, not MySQL's. UTF8 / LATIN1 / SQL_ASCII, with LC_COLLATE values (C, C.UTF-8, en_US.UTF-8) in place of MySQL collations. Read them from this endpoint per engine; utf8mb4 is not a value PostgreSQL has ever heard of, and a create using one is refused.

supports_remote_users (added 2026-09-10) is on every engine row and is true for every engine as of 2026-09-12 — PostgreSQL was the last false and is no longer one. Branch on this field, never on the engine name. It stays because the next engine added is the one that will need it, and adding the field then would be a contract change.

Remote database users are refused with a 422 on create_user.connection_preference / connection_preference for any engine whose supports_remote_users is false. No shipped engine is currently in that state.

PostgreSQL remote users need a cluster restart the first time

A MySQL account carries its own host, so CREATE USER 'a'@'10.0.0.5' is the grant. A PostgreSQL role is cluster-wide and has no host, so the same request is three facts that must agree: a pg_hba.conf record, listen_addresses bound off the loopback, and an open port. The panel now owns all three.

Two of them are free. The third is not: listen_addresses can only be set at server start, and PostgreSQL clusters default to localhost. So the first time remote access is asked for on a cluster still bound to loopback, the API answers:

{ "message": "Allowing remote connections needs PostgreSQL restarted, …", "code": "restart_required" }

with 409 — not 422. Nothing about the request is wrong; the server is in a state that has to change first. Nothing is written and nothing is restarted at this point: no role, no pg_hba.conf rule, no ALTER SYSTEM.

To go ahead, re-send the same request with restart_cluster: true (or create_user.restart_cluster on POST /databases). That second request is the consent. Show a confirm dialog first — every application connected to that cluster loses its connections for the moment the restart takes.

A cluster that already listens remotely — because the panel widened it earlier, or an operator configured it themselves — skips all of this and never returns 409.

MySQL and MariaDB answer the same 409 (added 2026-09-26). Ubuntu ships both with bind-address = 127.0.0.1, so a remote user used to be created and could never connect. The first remote user on an engine bound to loopback now gets the same 409 / code: restart_required (the message names the engine), and restart_cluster: true makes the panel write bind-address = 0.0.0.0 to a drop-in (/etc/mysql/mariadb.conf.d/99-panel-remote.cnf or /etc/mysql/mysql.conf.d/zz-panel-remote.cnf) and restart the engine. Same dialog, same consent, one code path on the client.

MySQL's drop-in is zz-panel-remote.cnf since 2026-10-06 (MY-01). It was 99-…, which MySQL reads before Ubuntu's mysqld.cnf, so bind-address = 127.0.0.1 won. The user was created and success reported, but 3306 stayed closed and every later remote user asked for another restart. A leftover 99-panel-remote.cnf is removed the next time the panel opens MySQL. A firewall rule that ufw refuses is no longer left on the Firewall screen.

connection_string of a remote or anywhere user now points at this server's public address (it used to print the user's own allowed host, i.e. the client's machine). Local users still get 127.0.0.1.

What the panel writes. One host <database> <role> <cidr> scram-sha-256 line per remote user, inside a marked block appended to pg_hba.conf. Everything outside the markers is left byte for byte; the block is re-rendered on every change, so rules never accumulate. anywhere writes two lines, 0.0.0.0/0 and ::0/0 — one would leave IPv6 clients unable to connect with no setting to blame. Dropping or renaming a user removes or moves its lines.

Safety. The new file is validated with pg_hba_file_rules — which reports on the file as it is on disk, before the server has loaded it — and is only reloaded if every line parses. If any line does not, the previous file is put back and nothing is signalled.

install_status is only ever installing | failed | null — never installed. A finished install removes its row.

install_reason = os_unsupported is the one failure a retry cannot fix. It means the engine's vendor has not published a build for this server's Ubuntu release — MongoDB on Ubuntu 26.04 (resolute) is the live case: it ships the tools and not the server, so the repository adds, the index fetches, and the package is simply not there. Nothing on the server is broken and nothing in the panel can be configured to change it. install_message names the release (read live from /etc/os-release) and says so. Don't offer a retry button for this reason — every other failure here is worth one, this one is not until the vendor releases.

install_message is worded per runtime. Until 2026-09-11 the whole panel shared one set of install-failure sentences, written for PHP, so a failed MongoDB install said "check the PHP repository is configured and reachable". It no longer does, and neither does a failed Node install.

While an install is queued, running, or failed, install_progress carries the detailed lifecycle:

{
  "status": "installing",
  "queued": false,
  "started_at": "27-08-2026 04:00:00",
  "started_at_human": "a few seconds ago",
  "reason": null,
  "message": null,
  "reference": null,
  "current_step": "starting_service",
  "current_step_title": "Starting the database service",
  "output": "Setting up mariadb-server ...",
  "retryable": false
}

current_step is one of queued, checking_conflicts, preparing_repository, updating_package_index, waiting_for_package_manager, preparing, downloading, unpacking, configuring, starting_service, verifying_cluster, verifying_connection, or creating_panel_account. MongoDB uses the repository steps; MySQL and MariaDB use the conflict check; PostgreSQL uses verifying_cluster — a step of its own because neither of its systemd units can report a cluster that failed to start (postgresql.service is ExecStart=/bin/true, and postgresql@.service ignores its own exit code), so the panel asks pg_isready instead. It is a different failure from verifying_connection, which is about credentials, and telling someone with a dead cluster to check their password would send them the wrong way. waiting_for_package_manager means Ubuntu is holding the package lock — usually a freshly booted server running its own unattended upgrades. The install is not stuck: the panel waits up to ten minutes and continues on its own. A step that never appeared before will now show on a busy server instead of a failure. Package phases are parsed from APT's real output rather than advanced on a timer. output is an 8 KB tail of APT output and contains no command arguments or credentials. current_step_title and failure message are localized for the viewer. On failure, the last real step remains in place and retryable becomes true.

queued (added 2026-10-08, FS-B1) is true while the install is still waiting for the queue worker — which runs installs and application setups one at a time, so an install can wait minutes behind them. status already reads installing; show "Waiting in the queue" rather than a progress bar while queued is true. Every install and removal row carries it (PHP versions and extensions, Node, database engines, fail2ban, GET /setup components as queued).

The database component returned by GET /setup exposes the same object as progress. Once installation succeeds, the transient row is deleted, both progress objects become null, and installed/running are derived from the server itself.


POST /databases/engines/{engine}

Permission: database (manage)

Install a database engine. All three are installable now — MongoDB was the last one that was not, because it is not in Ubuntu's archive and needed its own apt repository.

409 reason: install_in_progress when that engine is already queued or installing (since 2026-10-08, FS-C14). A second click used to answer 202 for an install that was silently dropped.

Do not hardcode which engines are installable: read installable from GET /databases/engines. It is driven by config, so an engine can be operable (the panel manages databases on one that already exists) before it is installable, and a false there means the button must not be offered.

installable: false now also covers "not on this OS", and when that is the reason the row carries an unavailable object:

{
  "engine": "mongodb",
  "installable": false,
  "unavailable": {
    "code": "os_unsupported",
    "reason": "MongoDB has not published packages for Ubuntu 26.04 yet. …"
  }
}

unavailable is null for every engine that can be installed. The shape is deliberately the same one a blocked site-type card carries ({code, reason}), so both render the same way: reason is already translated for the viewer, code is stable to branch on.

Render this as a disabled card with reason as its subtext, and no retry. POST /databases/engines/{engine} refuses the same case with 422 and the same sentence in message — it will not queue a job that cannot succeed, so the client never sees a progress bar for it.

The current case is MongoDB on Ubuntu 26.04 (resolute): MongoDB publishes per Ubuntu codename and ships only mongodb-database-tools there — no server, and no mongosh either. The panel supports 26.04; MongoDB has not built for it.

Response 202: {"queued": true} — poll GET /databases/engines.

Already installed → 200 with the engine list.


GET /databases/connections

Permission: database (view)

{"connections": [{
  "engine": "mariadb", "driver": "mysql",
  "connection_type": "socket", "socket": "/var/run/mysqld/mysqld.sock",
  "username": "panel_abc123xyz",
  "has_password": true, "has_root_password": false, "options": {}
}]}

Passwords are never returned. has_root_password (MySQL/MariaDB only, null on other engines): whether the panel knows a root password — set with PUT …/root-password below, or saved as root_password on a server that already had one. false is normal on a fresh install: root then signs in only as the server's own root account.


PUT /databases/connections/{engine}

Permission: database (manage)

Update the admin connection config.

Request (TCP): {"connection_type": "tcp", "host": "127.0.0.1", "port": 3306, "username": "panel_abc123xyz", "password": "…"}

Request (Socket): {"connection_type": "socket", "socket": "/var/run/mysqld/mysqld.sock", "username": "panel_abc123xyz", "password": "…"}

Response 200: {"connections": [...], "mariadb": {"reachable": true}}

Optional root_password (MySQL/MariaDB): root's current password, for a server that already has one (moved from v7). The panel signs in with it first and stores it only if that works — 422 errors.root_password otherwise, and on other engines. Stored encrypted; the panel uses it to reach root where socket login is gone. To change root's password use the endpoint below.


PUT /databases/connections/{engine}/root-password

Permission: database (manage) · Throttle: 5/min · Engines: mysql, mariadb (others 404)

Set, change or reset the MySQL/MariaDB root password. The old password is not asked for: the panel reaches root as the server's own root account, or with the stored root password.

Request: {"password": "…", "password_confirmation": "…"} — 12–128 characters, no line breaks or control characters. Quotes and spaces are fine (v7 refused them).

Response 200: {"mariadb": { …connection…, "has_root_password": true }}

What happens on the server:

Errors: 422 errors.engine — engine not installed, root unreachable (no socket login and no working stored password: save root's current password first), or a change already running · 422 errors.password — validation, the server's password policy (MySQL validate_password), or the new password could not be confirmed (previous login restored) · 500 {message, reference} — the server refused the change, or restoring the previous login failed.


POST /databases/connections/{engine}/test

Permission: database (manage)

Test reachability of the admin connection.

Response 200: {"reachable": true}


GET /databases

Permission: database (view)

Paged. ?search= case-insensitively matches the database name; ?filter[engine]=mariadb (validated against the configured engines — an unknown one is a 422, not an empty list); ?filter[application_id]=1 for the databases attached to one application; ?filter[attached]=0|1 for the unattached ones (what an attach picker lists) or the attached ones; ?sort=created_at|name|engine|users_count, default -created_at, with case-insensitive name ordering; ?per_page=10|20|30|50|100, default 10. Responds meta{current_page, per_page, total, last_page}.

{"databases": [{
  "id": 1, "name": "shop_db", "engine": "mariadb", "driver": "mysql",
  "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci",
  "application_id": 1,
  "size_bytes": 2097152, "size_human": "2 MB",
  "users_count": 1,
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}]}

size_bytes is stored and refreshed every 10 minutes by a scheduled command.

sort also accepts size_bytes (-size_bytes for largest first; added 2026-10-08, OLD-13).

POST /databases

Permission: database (manage)

Request:

{"name": "shop_db", "engine": "mariadb", "charset": "utf8mb4", "collation": "utf8mb4_unicode_ci", "application_id": 1,
 "create_user": {"username": "shop_user", "password": "…", "connection_preference": "localhost", "host": null}}

Response 201: {"database": {…}}

create_user is optional — send it to create the database and its first user in one call instead of a second round trip. Omit it entirely for a bare database; if present, create_user.username is required.

Field limits differ and the difference is not arbitrary: name allows up to 63 characters, create_user.username only 32, which is MySQL 8's hard limit on a user name. Both are [A-Za-z0-9_] only — identifiers cannot be parameterised in DDL, so the regex is the injection guard, and anything outside it is rejected rather than escaped. password may be omitted and one is generated.

connection_preference is localhost | remote | anywhere. host is required when it is remote and must be a real IPv4 address or range (203.0.113.5, 203.0.113.0/24); 0.0.0.0, a /0 and 127.x are refused there — use anywhere or localhost.

collation must belong to the chosen charset — a valid-but-mismatched pair fails validation on collation, it is not silently corrected. Reserved system schema names are refused.


GET /databases/{database}

Permission: database (view)

Size is re-measured on this single-record view (exact figure worth one query).

{"database": {"id": 1, "name": "shop_db", …, "users": [{…full user object…}]}}

The list and the single record carry different keys, and neither carries both. GET /databases returns users_count and no users; this endpoint returns users and no users_count — each is omitted, not null, when the query did not ask for it. Do not write db.users?.length ?? db.users_count; branch on which endpoint you called.

Each entry in users is the full DatabaseUserResource documented under GET /databases/{database}/users — password and connection string included, not the {id, username} stub. POST /databases/adopt and the update endpoints return neither key.


PUT /databases/{database}/application

Permission: database (manage)

Attach this database to an application, move it to another, or detach it.

Request: {"application_id": 1} — or {"application_id": null} to detach.

Response 200: {"database": {…}}

application_id must be present. An absent key is a 422, not a detach: forgetting a field and asking to unlink are different requests and this endpoint does not conflate them.

🔴 This changes bookkeeping, not connectivity. No wp-config.php, .env or other connection string is rewritten — an application keeps talking to whatever its own code says it talks to. What the link decides is what the panel treats as part of the application: a backup dumps exactly the attached databases, and staging, cloning and restoring each find "the application's database" this way. An unattached database is absent from every backup of its site. Say this in the UI; a user who attaches a database expecting their site to start using it has been misled.

Two refusals, both 422 on application_id:

Deleting an application detaches its databases rather than dropping them (nullOnDelete). Attaching is therefore never destructive and always reversible.


DELETE /databases/{database}

Permission: database (manage)

Drops the database and cascades its users. No orphans.

Response 204:


GET /databases/untracked

Permission: database (view)

Server databases not yet under panel management (brownfield discovery).

Query: ?engine=mariadb (required; missing or unknown → 422 on engine)

Response 200: {"untracked": ["legacy_app_db", "old_cms"]}


POST /databases/adopt

Permission: database (manage)

Bring existing server databases under panel management.

Request: {"engine": "mariadb", "names": ["legacy_app_db"]}

Response 201: {"databases": [{"id": 5, "name": "legacy_app_db", …}]}

Database users are not adopted here — POST /server/sync finds them once the database is tracked. An adopted user has no password: the engine stores a hash and a hash is not a password. Such a user comes back with password_known: false and connection_string: null rather than a string that looks right and fails at connect time. Setting a new password is a deliberate act, since it breaks every application still using the old one.

Server Sync

Reads a migrated server into the panel. preview (the default) changes nothing; apply writes the rows. Nine resource types, dependency-ordered: system_user → ssh_key → application → php_settings → worker → database_user → certificate → cronjob → firewall_rule.

firewall_rule is the one that has to be asked for: it is skipped unless include_firewall: true, because adopting a rule set is the one step here that can lock you out of the box.

System users and sudo (2026-09-28). An adopted user's sudo is what the server says — membership of the sudo group (or the older admin group) — where it used to be recorded as false for everyone, so an account with full sudo was listed as having none. The found item's evidence.sudo shows it in a preview. An apply also corrects sudo on users the panel already tracks when the group changed outside the panel; the server is never touched. Grants written directly into /etc/sudoers.d are not read. If the groups cannot be read at all, nothing is corrected.

What an adopted site is called (2026-09-24). A site is adopted only if its document root is {owner's home}/{folder}/public_html[/sub], and it is recorded with slug = that folder and web_root = the part after public_html. The slug used to be made from the domain, so the panel looked for the site in a folder that did not exist and its files, PHP settings, workers and backups all pointed at nothing. New skip reasons: folder_taken (another site in the panel already uses that folder name; slugs are unique) and folder_name_unusable (the folder name has characters the panel cannot put in a file name). A root not in that shape is outside_panel_layout, as before.

Database users. MySQL/MariaDB users are now found (they never were: the account list was mis-read). PostgreSQL offers only non-superuser roles, each only for databases it owns or was granted by name; superusers (postgres, the panel's own panel_* account) are never offered, and PUBLIC's default CONNECT no longer makes every role look like a user of every database.

Cron jobs already adopted are not offered again. A job adopted from a user crontab records source_path: "crontab:<user>" and the exact line; its first edit or delete removes that one line from the crontab (the rest is left as it is), so it no longer runs twice.

POST /server/sync

Permission: sync (manage) | Throttle: 10/min

{"mode": "preview|apply", "only": [], "include_firewall": false, "include_ignored": false} → 202 {"sync": {"id": 1, "status": "pending"}}

An omitted mode is preview. Refused with 422 while another run is live.

A preview lists what the apply would adopt, including what belongs to users it found. The sites, SSH keys and cron jobs of an account the panel does not have yet show as found, not skipped, because applying the run adopts the account first. The workers, certificates and PHP settings of a site the preview found cannot be read until the site exists, so the preview adds one line per type instead: action: "skipped", resource_key = the type, reason: "after_sites_adopted". The apply reads them as usual.

GET /server/sync/{run}?since=<item id>

Permission: sync (view)

The run plus items after the cursor, so a screen can poll ~1s and append — this is the line-by-line feed.

{"sync": {
  "id": 1,
  "mode": "preview",
  "status": "running",
  "finished": false,
  "options": {"only": [], "include_firewall": false, "include_ignored": false},
  "totals": {"found": 12, "adopted": 0, "skipped": 3, "failed": 0},
  "started_at": "12-08-2026 09:00:00",
  "finished_at": null,
  "items": [{
    "id": 87,
    "resource_type": "application",
    "resource_key": "shop.example.com",
    "action": "found",
    "confidence": "high",
    "evidence": "wp-config.php",
    "reason": null,
    "model_id": null
  }]
}}

Poll on finished rather than comparing status against a list of terminal values.

totals is the running tally per action, so the header does not have to be reduced from a paged item list — and stays correct when the caller is only fetching items after a cursor.

model_id is the id of the row that was created, and is null on a preview run and on any item that was not adopted. It is what turns a finished line into a link to the thing it created.

confidence and evidence exist because a site's type is inferred (wp-config.php, artisan, package.json). A site mislabelled WordPress will later have wp commands run at it, so show the evidence rather than presenting the guess as fact.

items is absent unless the endpoint loads it.

GET /server/sync/latest · GET/POST /server/sync/ignores · DELETE /server/sync/ignores/{ignore}

Permission: sync (view / manage)

Dismissed items stop appearing in later runs entirely. POST {"resource_type", "resource_key", "note"}; re-posting the same pair is the same decision, not an error. Pass include_ignored: true on a run to see them again.


POST /server/sync/handover · GET /server/sync/handover

Permission: sync (manage for POST, throttle 5/min; view for GET)

The server-side half of taking over from the old panel: stop and disable its agent, make PM2 start at boot for every account with an adopted pm2 application, and give PM2's logs a rotation policy. Nothing here restarts an application. Adopted applications carry supervisor_mode: "pm2", and Sync reports such processes with the resource type pm2_process.

POST runs the steps and reports each one (2026-10-07, FS-C27 — it used to answer the same "done" whatever happened):

{"ok": true,
 "agent": {"unit": "sureshcloud.service", "stopped": true, "disabled": true, "reference": null},
 "users": [{"username": "appuser", "boot_unit": "written", "log_rotation": "ok"}]}

agent is null when no old agent was found. boot_unit is healthy (already in place), written or failed; log_rotation is ok or failed. 500 with the same body when any step failed (ok: false) — the steps that worked are not undone.

GET answers whether a handover is needed, without changing anything (FS-C26):

{"needed": true,
 "agent": {"unit": "sureshcloud.service", "running": true},
 "users": [{"username": "appuser", "boot_unit_healthy": false, "log_rotation": false}]}

GET /databases/{database}/tables

Permission: database (view)

Structure only — no data browsing.

{"tables": [
  {"name": "wp_posts", "rows": 1420, "size_bytes": 524288},
  {"name": "wp_users", "rows": 8, "size_bytes": 8192}
]}

503 with reason: engine_unreachable when the engine is stopped or not answering (since 2026-10-08, FS-C12). It used to answer {"tables": []}, which read as an empty database.


Removed 2026-09-08: POST /databases/{database}/optimize and POST /databases/{database}/repair. Both now return 404. REPAIR TABLE is a MyISAM/ARCHIVE/CSV operation — on InnoDB, MariaDB answers "The storage engine for the table doesn't support repair" as a note and exits 0, so the endpoint reported success without doing anything on any table this panel manages. OPTIMIZE TABLE on InnoDB is a full table rebuild, and it ran synchronously against a 60-second timeout across every table at once. Use phpMyAdmin, where you can pick individual tables and watch the operation.

POST /databases/{database}/export

Permission: database (manage) | Throttle: 6/min

Dump a database to a file. Queued.

manage, not the read tier: this copies an entire database off the server, which is the most data-revealing thing this feature does.

Response 202: {"export": {"id": 1, "status": "queued", "file": null}}

Poll GET /databases/exports.

One export per database at a time. A second request while one is queued or running is a 422 naming that, rather than a second mysqldump writing another full copy to the same disk. A run whose worker was killed is closed out automatically before the check, so a stranded row cannot block a database permanently — it gets status: failed, reason: worker.

Since 2026-10-14: also a 422 while an import into the same database is queued or running — a dump taken while the import drops and reloads the database would be a half-empty file that looks like a good one.


Database import — /databases/{database}/imports

Added 2026-10-14. Upload a dump and replace the database's contents with it (operator decision: replace, never merge — afterwards the database holds exactly the file). All four engines:

Engine Accepted (judged by the file's bytes, not its name)
MySQL, MariaDB .sql, .sql.gz
PostgreSQL .sql, .sql.gz, a pg_dump -Fc custom archive
MongoDB a mongodump --archive file, gzip or not

Limit: 2 GB per file (SERVER_DB_IMPORT_MAX_BYTES). Permission: database manage for everything but the list.

What a run does, in order — nothing is dropped before step 3:

  1. Checks the file. SQL is decompressed and cleaned into what the database's own user can load: CREATE DATABASE/USE, definers, GTID lines (MySQL) and ownership/grants/role switches (PostgreSQL) are removed, the same things the panel's own exports leave out. A PostgreSQL file containing a psql client command (\!, \connect, \copy… outside strings, comments and data) is refused: psql runs those itself, and \! is a shell command.
  2. Backs up the database — an ordinary export, listed in GET /databases/exports with purpose: "import_safety" (label it "Automatic backup before import"), kept until the user deletes it.
  3. Replaces: drop, recreate, give the users back, load the file as the database's own user (never the admin account — a dump's USE other_db reaches nothing). PostgreSQL loads in one transaction.
  4. If the load fails, puts the backup back automatically.

The import needs a user of this database whose password the panel knows and who connects from this server (connection_preference localhost or anywhere). A database with none — e.g. only users adopted from v7 — is refused before the upload, with a message saying to add one or set a new password.

Frontend: before start, show what will happen in plain words and require an explicit confirmation — the API refuses without confirm_replace. Suggested wording: "Importing replaces everything in <database> with the contents of <file>. The panel backs the database up first and puts it back automatically if the import fails; the backup stays in Exports."

POST /databases/{database}/imports — begin

Throttle: 20/min. Request: {"file_name": "shop.sql.gz", "size_bytes": 10485760}

Response 201: {"import": {…}, "max_chunk": 66584576} — then send the file in chunks of at most max_chunk bytes.

422: over the limit (size_bytes); not enough free disk space (size_bytes); no usable database user (database); another import of this database is open (database).

PUT /databases/{database}/imports/{import}/chunk — one chunk

Raw body (not multipart), appended in order. Throttle: 1200/min (per chunk; the global API throttle does not apply). Response 200: {"received": 4194304} — bytes stored so far, also the resume point after a dropped connection. A body that would go past size_bytes is cut off and refused (422). 409 once the import is no longer uploading.

POST /databases/{database}/imports/{import}/start — run it

Throttle: 6/min. Request: {"confirm_replace": true} — required; without it 422 on confirm_replace.

Response 202: {"import": {"status": "queued", …}}. 422: the file has not fully arrived (file); an export of this database is running (database). 409: not uploading any more.

GET /databases/{database}/imports — progress and history

Permission: database (view). The latest 20, newest first. Poll this while one runs:

{"imports": [{
  "id": 7, "database": "shop_db", "engine": "mariadb", "file_name": "shop.sql.gz",
  "size_bytes": 10485760, "size_human": "10 MB", "received_bytes": 10485760,
  "status": "running", "step": "importing",
  "reason": null, "message": null, "line": null, "reference": null,
  "restored": false, "safety_export_id": 41, "requested_by": {"id": 1, "username": "admin"},
  "created_at": "14-10-2026 11:02:10", "finished_at": null
}]}

DELETE /databases/{database}/imports/{import}

Cancel an upload or clear a finished row; removes the uploaded file. 409 while queued or running.


GET /databases/exports

Permission: database (view)

All exports, newest first. Includes in-flight rows.

purpose (added 2026-10-14): null for an export someone asked for; "import_safety" for the backup an import takes automatically before replacing a database — show it as "Automatic backup before import". It is kept, downloadable and deletable like any other export.

{"exports": [{
  "id": 1, "database_id": 1, "database": "shop_db", "engine": "mariadb", "purpose": null,
  "status": "completed", "file": "shop_db-2026-07-28-100000.sql.gz",
  "size_bytes": 2097152, "size_human": "2 MB",
  "reason": null, "message": null, "reference": null,
  "available": true,
  "download_url": "https://panel.example.com/api/databases/exports/shop_db-2026-07-28-100000.sql.gz",
  "requested_by": {"id": 1, "username": "admin"},
  "created_at": "28-07-2026 10:00:00", "created_at_human": "Yesterday",
  "finished_at": "28-07-2026 10:00:12", "finished_at_human": "Yesterday"
}]}

The database name is database, not database_name.

available is not the same as status: "completed" — it also checks the file is still on disk. A retention sweep or a manual delete leaves a completed row whose file is gone; render the download button off available, and download_url is null in exactly that case.

reason is a classified failure code and message its localised sentence; reference is the id to quote to support. requested_by is absent unless the endpoint loads the user, and null when the export was made by the system rather than a person.


GET /databases/exports/{file}

Permission: database (manage)

Stream a previously-created export for download. Filename is strictly validated (alphanumeric + . - _ only).

manage, matching the backup download and for the same reason: this hands over an entire database in one request. Listing the exports stays on the read tier — knowing a dump exists is not the same as being handed it.

Response: Binary file stream.


DELETE /databases/exports/{export}

Permission: database (manage)

Delete the export row and its file.

409 reason: export_in_progress while the export is queued or running (since 2026-10-08, FS-C14) — its job would still write the file afterwards. A stale one (its worker is gone) can be deleted.

Response 204:


GET /databases/{database}/users

Permission: database (view)

password and connection_string are null unless the caller has database (manage) (2026-09-28, DB-01 — a read-only role was handed working credentials). password_known still says whether a password is stored, so a read-only screen can show "set" without the value. Applies everywhere a database user is returned, including GET /databases/{database}.

{"users": [{
  "id": 1, "database_id": 1, "username": "shopuser",
  "password": "s3cr3t…",
  "password_known": true,
  "connection_preference": "localhost",
  "host": "localhost",
  "connection_string": "mariadb://shopuser:s3cr3t%E2%80%A6@127.0.0.1:3306/shop_db",
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}]}

connection_string is a full URI with the password in it, not a user@host label — it exists to be copied into an application's config. It is null when the password is unknown or the database relation is not loaded. localhost and % are rendered as 127.0.0.1 so the string works when pasted.

password_known: false is the adopted-database case. A user imported from an existing server has no password the panel ever saw, so password is null and no connection string can be built — offer "set a new password" rather than showing an empty field that looks like a bug.

The password is returned in full, deliberately: the user has to paste it into their application and will come back for it. Same reasoning as the system-user password.


POST /databases/{database}/users

Permission: database (manage)

Request: {"username": "shopuser2", "password": "…", "connection_preference": "localhost"}

connection_preference: localhost | remote | anywhere. Remote/anywhere opens the engine port in the firewall (origin: db_user); the rule is closed again when the last user needing it is deleted, moved, or its database deleted.

PostgreSQL, more than one user (2026-10-04, bug #23). A PostgreSQL database has one owner, and the second user used to take it over: it could read none of the first user's tables, and the first lost the ability to create any. Now, when a second user is added, the database and everything the first user owns in it move to a shared role ({database}#owner, cannot log in, never listed as a user), both users become members, and each acts as that role inside this database — so every user can read, write and alter every table, whoever made it. Further users just join. Deleting a user hands what it made to the shared role. A database with one user is unchanged.

Restoring a PostgreSQL database (2026-10-04) gives it back to the users recorded here before loading it, and loads it as its owner (the one user, or the shared role), so the restored tables are theirs. Before this a restored database belonged to the panel's account and every user was refused at connect. A dump that needs an extension only a superuser may create (e.g. PostGIS) still loads as the panel account. MySQL/MariaDB grants survive the drop and need nothing.

Response 201: {"user": {...}}


PATCH /databases/{database}/users/{user}

Permission: database (manage)

Update username, connection preference, or password.

Request: {"connection_preference": "anywhere", "host": "0.0.0.0/0"}

Response 200: {"user": {...}}


PUT /databases/{database}/users/{user}/password

Permission: database (manage)

Request: {"password": "newpassword"}

Response 200: {"user": {...}}


DELETE /databases/{database}/users/{user}

Permission: database (manage)

The panel's own account (panel_…) and the engine's built-in accounts are refused with 422 on username — here and on PATCH …/users/{user} (since 2026-10-08, FS-C14).

Response 204:


POST /databases/{database}/phpmyadmin-sso

Permission: database (manage) — was view until 2026-09-28 (DB-02): the session signs in as the database's own user, which can write, so a read-only role could run an INSERT. Hide the button for view-only users; they get 403.

One-click auto-login to phpMyAdmin for the database's user. Works only for MySQL/MariaDB databases (MongoDB is not supported by phpMyAdmin — see mongo-express instead). Requires a running phpMyAdmin site on this server.

The frontend receives a redirect_url and should immediately redirect the browser to it. The URL contains a one-time token (TTL 60 s) that the sso.php script on the phpMyAdmin site consumes — it deletes the token before using it, so the link works exactly once.

HTTPS only (changed 2026-10-03, bug #98). A phpMyAdmin site without a servable certificate is refused with 422 (below): the link and the session cookie it buys would otherwise cross the network in clear text. Magic Login already refused the same case. redirect_url is therefore always https://; still redirect to the URL as given rather than building one.

Query (optional): ?database_user_id=1 — log in as a specific database user. Without this the first available user is used.

Query (optional): ?application_id=7 (added 2026-09-09) — which phpMyAdmin to open, when the server has more than one. Omit it and the oldest Active phpMyAdmin site is used, which is stable between requests.

This has to be chosen here: the one-time token is written into the chosen site's own directory, so a client cannot pick a different installation by redirecting afterwards. Before this parameter existed the endpoint took the first row an unordered query returned, so a second installation was unreachable and the choice could change between two identical requests.

To build a picker, list GET /applications?filter[site_type]=phpmyadmin and pass the chosen id here.

Response 200:

{"redirect_url": "https://pma.example.com/sso.php?token=***"}

Response 422 — the phpMyAdmin site has no HTTPS (added 2026-10-03). Offer to issue a certificate for it:

{"message": "This phpMyAdmin site has no HTTPS, so the sign-in link and the database session would travel unencrypted. Issue an SSL certificate for it first."}

Response 422 — MongoDB database:

{"message": "phpMyAdmin does not support MongoDB databases."}

Response 422 — no phpMyAdmin site deployed:

{"message": "No phpMyAdmin site is installed on this server."}

Response 422 — application_id names something that is not an Active phpMyAdmin site. Deliberately distinct from the message above: telling someone to install phpMyAdmin when they named the wrong site sends them to install what they already have.

{"message": "The selected site is not an active phpMyAdmin installation."}

Response 422 — no database user exists for this database:

{"message": "Create a database user before accessing phpMyAdmin."}

Response 422 — the phpMyAdmin site shares the server-wide PHP pool. The sign-in link is delivered through a file only that site's own account may read, which is only true when the site has its own PHP-FPM pool. On OpenLiteSpeed, where no per-site pool exists, this endpoint is never available and the user should open phpMyAdmin and sign in with the database credentials:

{"message": "This phpMyAdmin site shares the server-wide PHP pool, so a sign-in link would be readable by every other site. …"}

Signing in this way writes a database.phpmyadmin_signed_in row to the activity log, naming the panel user, the database and the database account used.


POST /databases/{database}/adminer-sso

Permission: database (manage) — the same bar as phpMyAdmin's sign-in, for the same reason: the session it opens is the database user's, and it can write. Hide the button for view-only users; they get 403.

Added 2026-10-14. One-click sign-in to Adminer for the database's user. Adminer is a site type (site_type: "adminer", installed from the application catalog like phpMyAdmin) that opens MySQL, MariaDB and PostgreSQL — the browser client PostgreSQL databases had no equivalent of before. MongoDB is refused (422): Adminer's MongoDB driver needs a PHP extension the panel does not install.

Same contract as phpmyadmin-sso above: redirect the browser to redirect_url as given. It is always https://, holds a one-time token (TTL 60 s) that the site's sso.php deletes before use, and lands the browser in Adminer signed in and opened on this database.

The Adminer site is locked to this server's own database servers: its login form offers "MySQL / MariaDB" and "PostgreSQL" from a list instead of a free-text host, so it cannot be used to reach other machines or open SQLite files by path. Typing a database user's own credentials there still works.

Query (optional): ?database_user_id=1 — sign in as a specific database user (default: the first one). ?application_id=7 — which Adminer, when the server has more than one (default: the oldest Active one). Picker: GET /applications?filter[site_type]=adminer.

Response 200:

{"redirect_url": "https://adminer.example.com/sso.php?token=***"}

Response 422 — each with a readable message:

Signing in writes a database.adminer_signed_in row to the activity log (panel user, database, database account).


GET /databases/processes

Permission: database (view)

Live process list for the active SQL engine.

Query: ?engine=mariadb (required; missing or unknown → 422 on engine)

{"processes": [{
  "id": 42, "user": "shopuser", "host": "localhost",
  "db": "shop_db", "command": "Sleep", "time": 5,
  "state": "", "query": null, "is_panel": false
}]}

is_panel (since 2026-10-08) is true for the panel's own connection: give it no Stop button.


DELETE /databases/processes/{id}

Permission: database (manage)

Kill a process/op (KILL). The panel's own connection is refused with 422, reason: panel_process.

Query: ?engine=mariadb (required; missing or unknown → 422 on engine)

Response 204:


GET /databases/status/{engine}

Permission: database (view)

{"status": {
  "connections": 5, "max_connections": 100,
  "threads_running": 2, "queries": 12847, "slow_queries": 3,
  "uptime_seconds": 864000
}}

Any field may be null, and null is not 0. It means the engine does not expose that counter, and the two must render differently — "0 slow queries" reads as good news the panel has not earned.

Today: slow_queries is null on PostgreSQL (no counter without pg_stat_statements, which the panel does not install) and on MongoDB. queries on PostgreSQL counts transactions, not statements — PostgreSQL keeps no global statement counter, and the nearest true number was preferred to a made-up one.


GET /databases/metrics/history

Permission: database (view)

24h QPS + connection history for the Setup page / Database Metrics chart.

Query: ?engine=mariadb (required; missing or unknown → 422 on engine)

{"metrics": [
  {"sampled_at": "28-07-2026 00:00:00", "qps": 12.4, "connections": 3, "threads_running": 1},
  …
]}

System Users

GET /system-users

Permission: system_user (view)

password is null unless the caller has system_user (manage) (2026-09-28, SU-01 — it is a working SSH/SFTP login). New field password_known says whether one is set. Same on GET /system-users/{id} and every endpoint returning a system user.

Paged. ?search= case-insensitively matches the username; ?sort=created_at|username, default -created_at; ?per_page=10|20|30|50|100, default 10. Responds meta{current_page, per_page, total, last_page, ssh_access_enforced}.

Server Sync reads ssh_access from the server (2026-10-05, SYNC-01): an imported account gets ssh_access: true when it is in the ssh-users group, and accounts already in the panel are corrected on apply, as sudo already was.

meta.ssh_access_enforced — whether the ssh_access toggle actually keeps anyone out on this server: true once sshd's AllowGroups names ssh-users (written by saving Settings → Security), false before that, null if sshd could not be asked. When it is false, users with ssh_access: false can still log in — show that next to the toggle rather than presenting it as a control.

{"system_users": [{
  "id": 1, "username": "siteowner",
  "home_path": "/home/siteowner",
  "shell": "/bin/bash",
  "shell_title": "Full shell access (bash)",
  "shell_allows_login": true,
  "sudo": false, "ssh_access": false,
  "password": null,
  "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"
}]}

POST /system-users

Permission: system_user (manage)

Request: {"username": "newuser", "password": "…", "shell": "/bin/bash", "sudo": false, "ssh_access": false, "public_key": null}

Everything but username is optional. shell defaults to /bin/bash; sudo and ssh_access default to false.

Response 201: {"system_user": {...}}

422 on ssh_access when it is true alongside a shell with allows_login: false.

422 on username when a user or group of that name already exists on the server (e.g. lsadm, ssh-users, the panel's own account) — the name is only unique among the panel's rows.

422 on password when it contains a line break or any other control character. 422 on public_key when it is more than one line, or its key data is not the type it claims to be.


GET /system-users/{systemUser}

Permission: system_user (view)

{"system_user": {
  "id": 1, "username": "siteowner", "home_path": "/home/siteowner",
  "shell": "/bin/bash", "sudo_access": false, "ssh_access": true,
  "applications": [{"id": 1, "name": "shop"}, {"id": 2, "name": "blog"}],
  "created_at": "23-07-2026 10:00:00"
}, "meta": {"ssh_access_enforced": false}}

DELETE /system-users/{systemUser}

Permission: system_user (manage)

422 if the user owns any applications, or still has running processes (an open SSH session, a running job) — nothing is removed; end them and retry.

?end_sessions=1 (added 2026-10-08, FS-C31) ends every process the account has first — an open SSH session, a shell, a running job — and then deletes it. Offer it as an "End sessions and delete" option on the dialog after the 422; without it nobody is signed out behind their back.

Response 204:


DELETE /system-users

Permission: system_user (manage) · Throttle: 6/min — added 2026-10-17 (v7's bulk delete)

Delete several system users. Each goes through the single delete above, with the same refusals; one refused account does not stop the others.

Request: {"ids": [3, 5, 8], "end_sessions": false} — 1 to 50 existing ids, no duplicates (422 otherwise, and nothing is deleted). end_sessions as ?end_sessions=1 above, for every account in the batch.

Response 200:

{"deleted": false, "succeeded": [3, 8],
 "failed": [{"id": 5, "username": "deploy", "reason": "has_processes"}]}

deleted is true only when every one went. reason is a key for the client to word: has_applications (still owns a site — delete or move it first), has_processes (signed in or running something — retry with end_sessions), failed (the server refused; carries reference).


PUT /system-users/{systemUser}/password

Permission: system_user (manage)

Request: {"password": "newpassword"}

Response 200: {"message": "Password updated."}

422 when the password contains a line break or other control character — chpasswd reads one account per line.


PUT /system-users/{systemUser}/sudo

Permission: system_user (manage)

Request: {"sudo": true}

Response 200: {"system_user": {...}} (the full resource)


GET /system-users/shells

Permission: system_user (view)

The shells that may be assigned and are installed on this server, in words. Build the picker from this — do not hardcode the paths or invent labels for them. A shell the panel knows but the server lacks (zsh on a stock Ubuntu) is left out, so the list can be shorter than below.

{"shells": [
  {"value": "/bin/bash",        "title": "Full shell access (bash)", "description": "The standard Linux shell. The user can log in over SSH and run commands.", "allows_login": true},
  {"value": "/bin/sh",          "title": "Basic shell (sh)",         "description": "…", "allows_login": true},
  {"value": "/usr/bin/zsh",     "title": "Full shell access (zsh)",  "description": "…", "allows_login": true},
  {"value": "/usr/sbin/nologin","title": "No login",                 "description": "The user owns its files and runs the site, but cannot log in…", "allows_login": false},
  {"value": "/bin/false",       "title": "No login (legacy)",        "description": "…", "allows_login": false}
]}

title and description are localised. Anything outside this list is rejected with 422 — a known shell that is not installed gets its own message saying so.


PUT /system-users/{systemUser}/shell

Permission: system_user (manage)

Request: {"shell": "/usr/sbin/nologin"}

Response 200: {"system_user": {...}} (the full resource)

422 on shell when the user currently has ssh_access: true and the chosen shell has allows_login: false — sshd would authenticate and the session would close immediately. Turn SSH access off first, or pick a login shell.


PUT /system-users/{systemUser}/ssh

Permission: system_user (manage)

Enable/disable SSH login for this system user.

422 on ssh_access when enabling it for a user whose shell has allows_login: false — the same contradiction from the other side.

Enforced only when meta.ssh_access_enforced is true. Enforcement needs AllowGroups ssh-users in sshd's config, which the panel writes when Settings → Security is saved — not automatically, because a whitelist applied unasked can lock out accounts the panel does not know about. Until then a user with a key or password and a login shell can still connect while this reads false.

Request: {"ssh_access": false}

Response 200: {"system_user": {"id": 1, "ssh_access": false}}


GET /system-users/{systemUser}/ssh-keys

Permission: system_user (view)

{"ssh_keys": [{"id": 1, "name": "MacBook Pro", "fingerprint": "SHA256:abc123…", "created_at": "23-07-2026 10:05:00"}]}

POST /system-users/{systemUser}/ssh-keys

Permission: system_user (manage)

Request: {"name": "MacBook Pro", "public_key": "ssh-rsa AAAA…"}

Response 201: {"ssh_key": {"id": 1, "name": "MacBook Pro", "fingerprint": "SHA256:abc123…"}}

Keys added by hand on the server are kept (2026-10-06, SU-03). The panel writes its keys between # BEGIN panel-managed keys (edit outside this block) and # END panel-managed keys in ~/.ssh/authorized_keys, and leaves every other line alone. It used to write the whole file, so adding or removing a key here deleted any key put there by hand. On a file from before this change, the panel's own keys are moved into the block, and a key deleted here is removed wherever it is. If the file exists but cannot be read, nothing is written and the answer is a 500 with a reference.


DELETE /system-users/{systemUser}/ssh-keys/{sshKey}

Permission: system_user (manage)

Response 204:


Cronjobs

GET /cronjobs/schedule-presets

Permission: cronjob (view)

Dropdown options for schedule expressions.

{"presets": {"frequencies": [{"value": "hourly", "label": "Hourly"}, …], "minutes": [{"value": 0, "label": ":00"}, …], "hours": [{"value": 0, "label": "00:00"}, …], "days_of_month": [{"value": 1, "label": "1st"}, …], "days_of_week": [{"value": 0, "label": "Sunday"}, …]}}

GET /cronjobs/command-presets

Permission: cronjob (view)

Framework shortcuts for the command field.

{"presets": [
  {"key": "laravel", "label": "Laravel Scheduler", "command": "php {path}/artisan schedule:run", "expression": "* * * * *"},
  {"key": "wordpress", "label": "WP Cron", "command": "php {path}/wp-cron.php", "expression": "*/5 * * * *"},
  {"key": "craftcms", "label": "Craft CMS queue", "command": "php {path}/craft queue/run", "expression": "* * * * *"},
  {"key": "custom", "label": "Custom", "command": null, "expression": null}
], "placeholder": "{path}"}

Each preset carries a command and a separate expression — the schedule is not embedded in the command string. custom has both null; it is the "let me write my own" entry, not a preset with a missing command. label is localized, key is not — render the label, key off the key.

Substitute {path} with the application's path field (see GET /applications/{id}), not document_root. They are the same directory for most site types and different for Craft and Statamic, where the binary these presets invoke sits above the served directory. A cron job pointed at a file that is not there does not error — it runs, finds nothing, and reports success.


GET /cronjobs

Permission: cronjob (view)

Paginated, filterable. Filters: filter[system_user_id], filter[application_id], filter[username], filter[active].

{"cronjobs": [{
  "id": 1,
  "name": "Laravel scheduler",
  "slug": "laravel-scheduler",
  "username": "siteowner",
  "system_user": {"id": 1, "username": "siteowner"},
  "application_id": null,
  "command": "cd /home/siteowner/shop.example.com && php artisan schedule:run",
  "expression": "*/5 * * * *",
  "active": true,
  "timezone": "Europe/Berlin",
  "log_key": "cron-laravel-scheduler",
  "next_run_at": "29-07-2026 10:00:00", "next_run_at_human": "in 4 minutes",
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}], "meta": {"current_page": 1, "per_page": 10, "total": 3, "last_page": 1,
  "usernames": ["root", "siteowner", "www-data"], "cron_running": true}}

Newest first; jobs created in the same second are ordered by id (newest first), so paging is stable.

meta.usernames is every account a job runs as, across all pages (sorted) — what the "Runs as" filter offers, so an account whose jobs are on another page (root, www-data, …) is still offered.

meta.cron_running is whether the cron daemon itself is running (systemctl is-active cron): false means no job runs at all, the panel's own scheduler included, whatever next_run_at says — show it. null means it could not be told.

The account is username (a plain string, always present), not user. system_user is the linked record and is absent unless the endpoint loads it, and null for a job whose account is not a panel-managed system user — a job adopted from an existing crontab is the normal case.

There is no human field (render the expression client-side or use the schedule presets endpoint) and no last_run_at — cron does not record one. next_run_at is computed from the expression against timezone, which is the server's timezone and is returned on every row so the frontend never has to assume UTC.

slug is the identity on disk (/etc/cron.d/<slug>); log_key addresses this job's captured output through the logs endpoints.


POST /cronjobs

Permission: cronjob (manage)

Request:

{"name": "Laravel scheduler", "system_user_id": 1, "username": null,
 "command": "cd /home/siteowner/shop.example.com && php artisan schedule:run",
 "expression": "*/5 * * * *", "active": true}

name is required and unique — it is the job's identity on disk (slugified into /etc/cron.d/<slug>), so a duplicate is a 422 on name, not a second file. Reserved cron filenames are refused.

The account is one of two fields, not one: send system_user_id to target a panel-managed System User, or username for a raw OS account that the panel does not manage (the normal case on a migrated server). username is required_without:system_user_id, so a request carrying neither is a 422 — there is no user_id field.

command must be a single line, max 1000 chars, and may not still contain the {path} placeholder from a command preset — an unresolved placeholder is rejected rather than written to cron as literal text. A shell comment is refused (errors/cronjob.shell_comment): a # at the start of a word outside quotes (echo hi # note) would cut off the panel's log redirect on the cron line. echo "#tag", a#b, $# and URL fragments are fine.

expression must be one Linux cron reads: five fields of numbers, *, ranges, lists, steps and month/day names (mon-fri, jan), or one of @yearly @annually @monthly @weekly @daily @midnight @hourly. Quartz extensions (L, W, ?, #) are refused — cron ignores the whole file for them — and so is @reboot (it has no next run).

name may be up to 255 characters, but the slug (file name) is at most 64: a cron.d file named after a 251-character name crashed cron on Ubuntu 26.04. A cut name gets -2, -3, … within the same 64 when it clashes.

application_id scopes the job to a site. Optional; null (or omitted) is a server-level job. Send it when the job is created from a site's own Cronjobs screen — filter[application_id] on the list endpoint then returns it, which it could not before, because nothing was able to set the column.

Response 201: {"cronjob": {...}}

Response 500 — the job could not be written to the server. Writing one touches seven privileged steps, and code names the one that failed so the message can be acted on rather than just reported:

code what failed
cronjob_log_dir creating the shared cron log directory
cronjob_log_touch creating this job's log file
cronjob_log_chown handing the log to the account the job runs as
cronjob_log_chmod setting the log's permissions
cronjob_rotation installing the logrotate policy — the job is refused rather than left to grow its output without limit
cronjob_write the /etc/cron.d file itself
cronjob_chmod the cron file's mode — cron ignores a file it does not trust, so the job would never run
{"message": "The cron file could not be written. Check there is free disk space.",
 "code": "cronjob_write", "reference": "…"}

No half-made job is left behind — the row is deleted if the write fails, because a schedule the panel lists and the server does not have is worse than none. The attempt is recorded in the activity log as cronjob.create_failed with the failing step, so a failure that vanished from the UI is still traceable afterwards.

503 with code: server_busy instead means a lock was held and the write never started — that one is worth retrying.

500 with code: server_sudo_denied is the opposite advice, and it can come back from any endpoint that touches the server, not only this one. sudo refused the command before it ran, because this server's grant in /etc/sudoers.d is older than the panel running on it — the file is written by install.sh and by the update's sync_privileges step, and that step is never fatal. Nothing was changed and retrying cannot help; sudo php artisan panel:sudoers on the server rewrites the grant from the panel's own list. panel:doctor's privilege check names the missing binaries.

Update and delete report the same way: cronjob_remove (the file could not be deleted, so the job is still scheduled), cronjob_remove_stale (the old file after a rename), cronjob_detach_source (the file an adopted job was imported from). In every one of those the panel restores what it changed, so a failed edit never leaves two schedules running.


GET /cronjobs/{cronjob}

Permission: cronjob (view)

Same shape as one row of the list endpoint — there is no user_id on it.

{"cronjob": {"id": 1, "name": "Laravel scheduler", "slug": "laravel-scheduler", "username": "siteowner", "system_user": {"id": 1, "username": "siteowner"}, …}}

PUT /cronjobs/{cronjob}

Permission: cronjob (manage)

Every field is sometimes — send only what changed. Accepts name, command, expression, active, and the run-as account.

The account can be changed here. Send system_user_id or username, exactly as on create. An account that does not exist on the server is a 422 on username — cron accepts a file naming an unknown user and then silently fails to run it every tick, so it is refused up front. (This used to be accepted and ignored: the request answered 200 and the job kept running as its old account.)

Request: {"expression": "*/10 * * * *", "active": false}

Response 200: {"cronjob": {...}}


DELETE /cronjobs/{cronjob}

Permission: cronjob (manage)

Removes the /etc/cron.d file entry.

Response 204:


Server — Dashboard

GET /server/facts

Permission: dashboard (view)

{"facts": {
  "hostname": "srv1", "os": "Ubuntu 24.04 LTS", "kernel": "6.8.0-36-generic",
  "arch": "x86_64", "uptime": {"seconds": 1296000, "human": "15 days"},
  "ip": "10.0.0.5", "public_ip": "23.172.120.38",
  "cpu": {"model": "AMD EPYC 7282", "cores": 8},
  "memory_total": 8589934592, "memory_total_human": "8 GB",
  "disk_total": 107374182400, "disk_total_human": "100 GB",
  "timezone": "UTC", "reboot_required": false,
  "runtimes": {"php": "8.4", "node": "20.11.0", "nginx": "1.24.0", "redis": "7.2.0", "mysql": null}
}}

public_ip is the address the internet reaches this server on — the one to point DNS at and the one a dashboard should show. Detected from the local route table, falling back to the cloud metadata service (link-local 169.254.169.254, so nothing is disclosed to a third party) when the machine can only see a private address on its own interfaces. null when it genuinely cannot be determined — bare metal behind a hardware NAT — which callers must render as "could not determine" rather than as a blank.

ip is the machine's own first local interface address (hostname -I), not necessarily its public one. On a VPS with a directly-attached public address — Hetzner, DigitalOcean, Linode, Vultr — the two are the same, which is the usual case. Behind NAT (most of AWS, GCP, Azure, or with a floating IP) this is the private address, e.g. 10.0.0.5.

It is therefore not the same value as capabilities.server_ip on GET /server/capabilities, which is public-or-null and never reports a private address. Use server_ip when the answer has to be reachable from the internet (DNS, certificates, a hostname you hand to a user); use facts.ip only to show the operator what is on the box's own interface. The same server can legitimately report facts.ip: "10.0.0.5" and server_ip: null.


GET /server/metrics/live

Permission: dashboard (view)

Current snapshot — poll every 2–5s for live gauges. Network and disk I/O are rates between two polls — the first poll returns 0.

{"metrics": {
  "cpu": {"percent": 12.5, "cores": 8},
  "memory": {"total": 8589934592, "used": 4294967296, "free": 4294967296, "percent": 50, "total_human": "8 GB", "used_human": "4 GB", "free_human": "4 GB"},
  "swap": {"total": 2147483648, "used": 0, "free": 2147483648, "percent": 0, "total_human": "2 GB", "used_human": "0 B", "free_human": "2 GB"},
  "disk": {"total": 107374182400, "used": 64424509440, "free": 42949672960, "percent": 60, "total_human": "100 GB", "used_human": "60 GB", "free_human": "40 GB"},
  "load": {"1": 0.5, "5": 1.2, "15": 2.0},
  "network": {"in": 10240, "out": 5120, "in_human": "10 KB/s", "out_human": "5 KB/s"},
  "disk_io": {"read": 2097152, "write": 524288, "read_human": "2 MB/s", "write_human": "512 KB/s", "read_ops": 45, "write_ops": 12}
}}

Nothing here is ever null. CPU percent needs two samples, and on the very first poll there is no previous one — the endpoint compares the reading against itself, so cpu.percent comes back as 0 (a float), not null. Same for network and disk_io, which are per-second rates. A first-poll 0 is therefore indistinguishable from a genuinely idle server: discard the first sample rather than rendering it, or accept one tick of understated load.


GET /server/metrics/history

Permission: dashboard (view)

24h series for the charts (5-min cadence).

{"metrics": [
  {"sampled_at": "28-07-2026 00:00:00", "cpu": 8.2, "memory": 45.1, "swap": 0, "disk": 58,
   "load_1": 0.3, "load_5": 0.42, "load_15": 0.51,
   "net_in": 5120, "net_out": 2048, "disk_read": 0, "disk_write": 0},
  …
]}

GET /server/processes

Permission: dashboard (view)

Top processes by CPU.

meta added 2026-09-11. meta.total is how many processes the server is running; the list is the top meta.limit by CPU. Label the table with meta.total — the row count is the limit, so it never changes and the screen looks frozen when a process is stopped.

{"processes": [
  {"pid": 1234, "user": "www-data", "cpu": 25.3, "memory": 4.2, "command": "php-fpm: pool www", "stoppable": false, "reason": "This process belongs to a protected service and cannot be stopped here."},
  {"pid": 5678, "user": "siteowner", "cpu": 8.1, "memory": 1.5, "command": "node /home/siteowner/shop.example.com/server.js", "stoppable": true, "reason": null}
], "meta": {"total": 187, "limit": 25}}

stoppable / reason added 2026-10-04 (bug #7). Whether DELETE below would refuse this process, from the same rules, and the refusal's message when it would. Disable Stop on a row with stoppable: false and show reason. The endpoint still checks again at stop time.


DELETE /server/processes/{pid}

Permission: dashboard (manage)

Stop a process.

Request (optional): {"signal": "KILL"} — default is TERM.

Response 200: {"process": {"pid": 1234, "command": "php-fpm: pool www", "user": "www-data", "signal": "TERM"}}

404 — PID no longer running, or a number no process can have (above Linux's 4194304; was a 500 until 2026-10-04). 422 — PID 1, kernel threads, the panel's PHP, a database server, SSH, the panel's own units, the operating system's own services (dbus, cron, systemd-journald/logind/networkd/resolved/udevd/timesyncd, polkit, rsyslog, chrony — added 2026-10-04, bug #5), or protected service processes. Since 2026-10-06 (PK-01) also fail2ban, supervisor, docker and containerd: they are managed from the Services screen under its own permission, and a clean TERM left fail2ban down for good. 500 — signal failed.

409 (2026-10-04, bug #6) — the signal was delivered and the process is still running after 5 seconds. For TERM the message suggests Force stop ({"signal": "KILL"}); after KILL it says the process is stuck in the kernel and no signal can end it yet. Before this, Stop answered 200 without checking.


Services

GET /services

Permission: service (view)

Live status of every managed systemd service. Compatibility aliases are collapsed by systemd's canonical unit ID, so one daemon produces one row. For example, when mysql.service is an alias of mariadb.service, the response contains only the canonical MariaDB catalog row; actions on a duplicate MySQL row are never offered.

{"services": [{
  "key": "nginx", "label": "Nginx", "unit": "nginx",
  "state": "installed",
  "status": "active", "enabled": true, "protected": true,
  "actions": ["start", "stop", "restart", "reload"],
  "testable": true,
  "install_reason": null, "install_message": null, "retryable": false,
  "usage": {"memory_bytes": 5242880, "memory_human": "5 MB", "memory_percent": 0.06, "cpu_percent": null, "tasks": 4}
}]}

Every row has the same shape, whatever kind of service it is — a database engine, a PHP-FPM version, nginx. Two fields, two jobs:

state status meaning
installed systemd's answer the unit exists
installing inactive being installed right now; no unit yet, so nothing is running
install_failed failed the install was attempted and failed; install_reason and install_message say why, retryable is true

A failed install reports status: failed deliberately — to the person looking at the row it is broken and should read as broken, and the badge only speaks those three words. The distinction between "the service crashed" and "it never installed" lives in state, so nothing is lost.

A service the panel can install now appears while it is installing and after a failed install, instead of being absent — the row vanishing entirely reads as "the panel forgot", when the truth is "still going" or "it failed". Covers the database engines, fail2ban, and each PHP-FPM version. Anything with no installer (nginx, Apache, Redis, Supervisor) is still absent until its unit exists, and OpenLiteSpeed contributes no PHP rows at all because LSPHP has no per-version unit.

Those rows are inert: actions is [], usage and log_keys are empty, testable is false. There is no unit to act on. Retry belongs on the setup page, which is where the install was started — retryable is there so the row can link to it, not so it grows its own button.

protected: true — cannot be stopped or disabled (nginx, php-fpm, the panel's own services).

actions — rendered buttons directly from this array. testable: true — shows a Test configuration button.

usage is null for stopped services or when systemd accounting is off. cpu_percent is null on first read (cumulative counter needs two samples).


POST /services/{service}/config-test

Permission: service (view)

Validate the service's configuration. Read-only — never reloads.

Response 200:

{"config_test": {"ok": true, "output": "nginx: the configuration file /etc/nginx/nginx.conf syntax is ok\nnginx: configuration file /etc/nginx/nginx.conf test is successful"}}
{"config_test": {"ok": false, "output": "nginx: [emerg] unknown directive 'invald_directive'\n"}}

PUT /services/{service}

Permission: service (manage)

Control a service. {service} = the service key.

Request: {"action": "start | stop | restart | reload | enable | disable"}

Response 200: {"service": {…refreshed service object…}}

422 if the action is blocked for a protected service (stop/disable). 404 if the service key is unknown.


Firewall

GET /firewall/presets

Permission: firewall (view)

Inbound rule presets for the UI.

{"presets": [
  {"label": "SSH", "port": 22, "protocol": "tcp"},
  {"label": "HTTP", "port": 80, "protocol": "tcp"},
  {"label": "HTTPS", "port": 443, "protocol": "tcp"},
  {"label": "MySQL", "port": 3306, "protocol": "tcp"}
]}

GET /firewall

Permission: firewall (view)

The payload is flat — there is no firewall wrapper object.

{
  "enabled": true,
  "default_policy": {"incoming": "deny", "outgoing": "allow"},
  "rules": [{
    "id": 1,
    "port_from": 22, "port_to": null,
    "protocol": "tcp",
    "action": "allow",
    "source_ip": "203.0.113.5/32",
    "description": "Office IP",
    "origin": "user",
    "enabled": true,
    "protected": false,
    "summary": "Allow 22/tcp from 203.0.113.5/32",
    "created_at": "23-07-2026 10:00:00", "created_at_human": "3 weeks ago"
  }],
  "your_ip": "203.0.113.5",
  "ssh_port": 22,
  "listening": [{"port": 3306, "protocol": "tcp", "address": "0.0.0.0", "program": "mariadbd"}],
  "risky_ports": [{"port": 3306, "service": "MySQL", "reason": "Database server"}]
}

A rule is action (allow / deny) over a port range — port_from plus optional port_to — not type + port. The source is source_ip (IP or CIDR, null meaning anywhere), and the note is description, not label. protocol is all · tcp · udp.

Ports are 1–65535 on both create and edit. 65535 is a real port and ufw accepts it; 0 is not one and is refused with a 422 rather than stored and failed later against ufw.

summary is the whole rule as one localised sentence ("Allow 443/tcp from Anywhere") — use it for the row rather than reassembling the parts in the frontend.

enabled: false means kept but not applied; disabling is not deleting.

protected: true marks system-seeded rules (origin: default — SSH, HTTP, HTTPS). origin: db_user is a rule opened for a remote database user: not protected, and closed by the panel when the last database user needing that port and address is deleted or moved. While the firewall is enabled, such a rule is description-only: delete, and any PUT touching port_from, port_to, protocol, action, source_ip or enabled, answer 422 with a message naming the port and the way out. Disable the firewall and it is fully editable again — that is the escape hatch, and the reason the lock is not permanent.

Treat that as one rule in the UI: disable the edit control and the enable/disable toggle on a protected row, not just the delete action. Until now only delete was refused, so hiding delete alone left the toggle as the one control nothing checked — and switching off the seeded port-443 rule is ufw delete allow 443/tcp, which on a deny-incoming server takes every site on the box offline. Renaming stays available, so an edit dialog that only submits description should still be offered.

enabled: true with an empty rules no longer means "the firewall is on and lets nothing through" — it used to be reachable, because enabled is read live from ufw while rules comes from the panel's own table, and only a toggle made through the panel ever wrote the default rows. The installer now records what it allowed (SSH plus the web ports) and a panel update backfills it, so the two halves agree from the first page load. An empty list on an enabled firewall is now a genuine "no rules", worth saying so rather than rendering a blank table.

your_ip is the caller's own address, so "only my IP" is one click; without it people open ports to everyone rather than go and look their address up.

ssh_port is read here rather than from Settings on purpose: a user with firewall access but not settings access would get a 403 there and fall back to 22, and being wrong about the SSH port on this screen is how people lock themselves out.

listening is what is actually bound right now, from ss. program is often null — the panel's PHP process is unprivileged and cannot see another user's process name — so render the port even when the program is unknown.

risky_ports — ports detected from installed database engines + config, to warn before opening them.

This endpoint can fail with 500 ({message, code, reference}) when ufw status cannot be read. It used to answer enabled: false in that case, which is not "unknown" but a specific wrong answer — the screen reported an active firewall as off. Render the error rather than a disabled firewall.

unmanaged_rules (2026-10-05, FW-08): rules ufw enforces that the panel has no row for (added with the ufw CLI). Each: {"key", "rule", "adoptable", "reason"} — adoptable: false means the panel cannot hold it (LIMIT/REJECT, outbound, an application profile) and reason says why. null when ufw could not be read. Show them under "Added outside the panel".

Activity entries (FW-09): "Added/Removed a firewall rule" now carry the whole rule, e.g. "Deny 80/tcp from 1.2.3.4", the same sentence as the rule's summary.

?rules=0 (added 2026-10-08, OLD-12) leaves the unpaged rules list out ("rules": null). Page the rules through GET /firewall/rules and send it; the default still includes them for the current screen.

GET /firewall/rules

Permission: firewall (view)

The rules on their own, paged — use this for the rules table rather than reading rules out of GET /firewall.

Separate endpoint on purpose: GET /firewall also reports live UFW status and the listening ports, and building listening[] shells out to ss. Turning a page should not re-run that. GET /firewall still returns its full rules array unchanged, so nothing breaks before the frontend migrates.

?search= case-insensitively matches port, source IP and description (text match, so 80 finds both 80 and 8080); ?filter[enabled]=0|1, ?filter[action]=allow|deny, ?filter[origin]=user|default|db_user; ?sort=created_at|port_from|action|protocol, default -created_at; ?per_page=10|20|30|50|100, default 10.

search also accepts a service name — ssh, http, https, mysql, postgresql, redis, ftp, smtp, dns (the GET /firewall/presets keys, minus custom, which has no port and falls through to the text match). The name resolves to its port and matches any rule on that port, including a range covering it: search=ssh finds the rule for 22 and a rule for 20:30. This exists because the rules the panel seeds itself carry no description, so searching for a service by name previously returned nothing while the row was on screen. Matching is on the name, not the rule's origin — a rule someone opened on 3306 for their own reasons is returned by search=mysql.

{"rules": [{"id": 1, "port_from": 443, "…": "…"}],
 "meta": {"current_page": 1, "per_page": 10, "total": 12, "last_page": 2}}

POST /firewall/rules

Permission: firewall (manage)

Add a rule.

Request: {"action": "allow", "port_from": 22, "port_to": null, "protocol": "tcp", "source_ip": "203.0.113.5/32", "description": "Office IP"}

action, port_from and protocol are required. port_to is optional and must be ≥ port_from. source_ip accepts a bare IP or CIDR; null means anywhere.

A /0 mask is refused (422 on source_ip): 0.0.0.0/0 reads as a rule restricted to a source and is not one. Leave source_ip null to mean anywhere — that is what the row then says.

Response 201: {"rule": {…}} — note the key is rule, not firewall_rule.


PUT /firewall/rules/{firewallRule}

Permission: firewall (manage)

Every field is sometimes. Accepts port_from, port_to, protocol, action, source_ip, description, enabled. There is no address field — the source is source_ip.

Request: {"source_ip": "203.0.113.10/32"}

Response 200: {"rule": {...}} — the same key as create, not firewall_rule.

422 on port_to when a partial edit would invert the range. Sending port_from alone is compared against the stored port_to, so moving a 9000–9100 rule to start at 9500 is refused rather than saved backwards.

422 on rule when the edit would remove the last way in — see the lockout guard below.


DELETE /firewall/rules/{firewallRule}

Permission: firewall (manage)

Response 204:

422 on rule — the lockout guard. While the firewall is enabled, a rule may not be deleted, switched off, or moved off the port if it is the last allow rule covering the live SSH port. The message names the port and says what to do (add another rule for it, or disable the firewall first). Surface it on the row, not only as a toast — it is the difference between an edit being refused and the user losing the server.

This is not the same as protected. protected marks system-seeded rules; the lockout guard asks whether a way in survives, so it also fires for a rule the user made themselves and for range rules like 20:30 that happen to cover SSH.


PUT /firewall/toggle

Permission: firewall (manage)

Enable or disable the firewall entirely.

Request: {"enabled": false}

Response 200: flat, no wrapper — {"enabled": false, "default_policy": {"incoming": null, "outgoing": null}}. default_policy values are null when ufw does not state them (an inactive ufw prints none) — no longer a guessed "deny/allow" (OLD-1).

Enabling seeds allow rules for the web ports and for the port SSH is actually listening on, read from the live sshd configuration rather than from a stored default. If any of those cannot be applied, enabling is refused with a 500 rather than leaving the box behind a deny-incoming policy with no way in.

A seeded rule the user switched off stays off (changed 2026-09-11). Enabling used to apply every default rule regardless of its enabled flag, so a web port closed while the firewall was down came back open on the next enable while the panel still showed the rule as off — and, because a seeded rule is locked while the firewall is enforcing, it could not then be corrected. The rules list and the box now agree.

SSH is the exception. If the rule for the SSH port is recorded as off, enabling switches it back on, applies it, and writes a firewall.rule_enabled entry to the activity log. A box whose only way in is recorded shut is one enable away from being unreachable, so this is not left to the user's last click.


Fail2ban

GET /fail2ban

Permission: fail2ban (view)

{"fail2ban": {
  "installed": true,
  "running": true,
  "version": "0.11.2",
  "install": null,
  "your_ip": "203.0.113.5",
  "settings": {"bantime": 3600, "findtime": 600, "maxretry": 5, "ignore_ips": ["203.0.113.5"]},
  "jails": [{
    "name": "sshd", "label": "SSH", "lockout_risk": true,
    "options": {"mode": "aggressive", "port": "{ssh_port}"},
    "enabled": true,
    "banned": ["198.51.100.9"],
    "stats": {"total_bans": 12, "current_bans": 1}
  }],
  "banned": ["198.51.100.9"],
  "bantime_presets": [{"key": "hour", "seconds": 3600, "label": "1 hour"}]
}}

lockout_risk is a boolean, not a level — it marks the jails that watch SSH and can therefore lock the caller out of their own server. your_ip is the caller's own address, so the UI can offer "add my IP to the ignore list" before they enable one. See PUT /fail2ban for the 422 that enforces this.

settings and banned are null/[] until fail2ban is installed. A jail that is not enabled reports banned: [] and stats: null rather than zeros — not running and nothing caught are different statements.

install is the progress of an install in flight, and null once fail2ban is on disk:

{"install": {
  "status": "installing", "queued": false,
  "reason": null, "reason_title": null, "reference": null,
  "started_at": "12-08-2026 15:20:11", "finished_at": null
}}

status is installing or failed. On failure reason is a stable code (package_not_found, apt_lock, network, no_space, worker, unknown), reason_title is that rendered in the caller's locale, and reference locates the server-ops log entry for support.

The screen needs three states, not one boolean. installed is derived from the package being on disk and apt is allowed ten minutes, so a UI built on installed alone shows nothing at all for ten minutes and nothing when it fails. Render installing (progress), failed (reason_title + retry + reference), and installed.


POST /fail2ban/install

Permission: fail2ban (manage)

Install fail2ban if not present.

Response 202: {"message": "Installing Fail2ban."}


PUT /fail2ban

Permission: fail2ban (manage)

Update ban settings and which jails are on. The thresholds are server-wide, not per jail — the request is flat, with jails as an on/off map:

Request:

{
  "bantime": 3600, "findtime": 600, "maxretry": 5,
  "ignore_ips": ["203.0.113.5", "10.0.0.0/8"],
  "jails": {"sshd": true, "nginx-http-auth": false},
  "acknowledged": false
}

bantime, findtime and maxretry are required. bantime accepts -1 for a permanent ban; otherwise the floor is 60 seconds, because a shorter ban expires before it inconveniences anyone and only looks like protection. The ceiling is a year. findtime is 30–86400. maxretry is 2–100 — one failure is a typo, not an attack.

ignore_ips is an array of IPs or CIDRs (max 100). jails is an object keyed by jail name with boolean values; omitted jails keep their current state. Omitting ignore_ips or jails entirely leaves them as they are.

acknowledged is the lockout guard. Enabling a jail flagged as lockout-risky (the SSH jail) while the caller's own IP is not in ignore_ips is refused with 422 — that request can lock the user out of their own server. The frontend should catch that 422, warn, and offer either "add my IP to the ignore list" or a re-submit with acknowledged: true. The guard does not fire if the jail is already active, or if the caller's IP is already ignored.

Response 200: {"fail2ban": {"settings": {...}, "jails": [...]}}

Updates are rollback-safe: the panel validates the new /etc/fail2ban/jail.local with fail2ban-client -t before reload. If validation or reload fails, it restores the prior configuration and reloads that known-good version. The API returns the original operation failure with its support reference; the frontend should refetch GET /fail2ban after a failed save rather than keeping optimistic form values.


POST /fail2ban/ignore-ips · DELETE /fail2ban/ignore-ips

Permission: fail2ban (manage) · added 2026-10-16 (v7's ignore-ip add/remove)

Add or remove one ignore-list entry, against the list as it is on disk now. PUT /fail2ban replaces the whole list, so two people editing at once lose the first save; these do not, and every write of jail.local (including PUT) now goes one at a time. Ban settings and jails are left exactly as they are.

Request: {"ip": "203.0.113.5"} or a range {"ip": "198.51.100.0/24"}. For DELETE, ip may also go in the query (?ip=198.51.100.0/24); a range's slash cannot go in a path. The address is stored in the form fail2ban uses (::ffff:1.2.3.4 → 1.2.3.4). /0 ranges are refused.

Response 200: {"fail2ban": {"settings": {...}, "jails": [...]}} (same as PUT).

Status When
422 ignore_ip_exists POST: already on the list
422 ignore_list_full POST: 100 entries already
404 ignore_ip_missing DELETE: not on the list (the screen is out of date; refetch)
422 lockout_risk DELETE: removing your own address while the SSH jail runs, with no other entry still covering you. Re-send with "acknowledged": true to confirm
422 not_installed fail2ban is not installed

The two loopback entries (127.0.0.1/8, ::1) are always there and never part of ignore_ips, so they cannot be removed.


POST /fail2ban/bans

Permission: fail2ban (manage)

Ban an IP manually.

Request: {"ip": "203.0.113.50", "jail": "sshd"}

Response 200: {"banned": {"ip": "203.0.113.50", "jail": "sshd"}}

422 when the address is your own, or is covered by the jail's ignoreip list. Since 2026-10-06 (F2B-01) addresses are compared the way fail2ban reads them: ::ffff:203.0.113.50 is 203.0.113.50, and a range such as 10.0.0.0/8 in ignoreip covers every address in it. The ban is stored in that form too.


DELETE /fail2ban/bans/{ip}

Permission: fail2ban (manage)

Unban an IP from all jails.

Response 200: {"unbanned": {"ip": "203.0.113.50", "jails": ["sshd"]}}


DELETE /fail2ban/bans

Permission: fail2ban (manage)

Unban every address from every jail.

Response 200: {"unbanned": {"ips": ["203.0.113.50", "203.0.113.51"]}}


Server — Activity Log

GET /server/activity-log/filters

Permission: activity_log (view) — added 2026-10-08 (FS-C11)

The filter options for this log, for someone without admin access: the same types / actions shape as /admin/activity-log/filters, limited to the server scope, plus kinds.

GET /server/activity-log

Permission: activity_log (view)

Every event in the server scope (config/activity.php) — the same set the admin log's filter[scope]=server selects: sites, databases, backups, PHP, Node, system users, services, firewall, fail2ban, settings and the rest. (Until 2026-09-23 it had its own list of nine types and silently left out PHP, system users, build tools, sites, databases and backups.) Separate from access-admin which gates the admin-wide log.

Query: ?filter[type]=cronjob&filter[action]=created&search=something&page=1&per_page=20 — per_page ∈ 10, 20, 50, 100 (default 20); anything else is 422.

{"activity_log": [{"id": 1, "type": "cronjob", "action": "created", "scope": "server", "description": "Cronjob created", "user": {"id": 1, "username": "admin"}, "is_system": false, "created_at": "29-07-2026 10:00:00", "created_at_human": "3 hours ago"}], "meta": {"current_page": 1, "per_page": 20, "total": 45, "last_page": 3}}

There is no properties field on the row. The raw properties bag is the input to description — it is interpolated into the localised sentence server-side and never returned, so do not plan a UI that reads values out of it. Every row here has scope: "server" by construction.

search matches type, action and what the event was about (site name, domain, version, IP) — unlike the admin-wide log, it does not reach the actor's name.


Logs (Server-wide)

GET /logs

Permission: logs (view)

All available log sources on the server.

{"logs": [{
  "key": "nginx_error", "label": "Nginx Error Log", "group": "web",
  "kind": "file", "size": 4096, "modified": "29-07-2026 11:00:00",
  "readable": true, "follow": true, "downloadable": true,
  "clearable": true, "clear_sensitive": false
}]}

kind — file · privileged · journal. Most sources are files the panel reads directly. privileged is a file it cannot open as its own user and reads through the system instead (Let's Encrypt: /var/log/letsencrypt is 0700 root; a cron job's output; and every worker, whose log lives in {application root}/logs, which is root:{site user} 0750 so that the site's owner can read its own logs and cannot replace them). journal is the systemd journal, which is not a file at all — as of 2026-09-08 only the server-wide System Journal source uses it, since workers moved from systemd to supervisord and now write a file.

Two flags follow from that, and both are worth honouring rather than inferring from kind:

For a journal source size and modified are null — it has no single size or last-write time, and a zero would read as an empty log last touched in 1970.

group: web | database | cache | php | system | security | daemon | cronjob | worker. (cache is Redis; cronjob is one entry per cron job that has captured output, labelled with the job's name; worker is one entry per background worker.)

The list is what exists on this server, not a fixed set. Every source is filtered by whether it is really there — a file check, a privileged file check, or asking journalctl for one line — so a box with no MTA has no mail entry, a box running Apache has no nginx_* entries, and a box without certbot has no letsencrypt. Render whatever comes back rather than expecting fixed keys. System sources currently registered: syslog, auth, kernel, mail, journal.

A source is omitted only when the server answered "not there". If the panel could not run the check at all — an out-of-date sudo grant is the usual reason — the source is kept, with readable: false. Those are opposite statements: dropping the second would say this server has no such log, and someone would reasonably conclude their cron jobs never wrote anything.

Worker logs are privileged file sources, one per worker, keyed by WorkerResource.log_identifier. The default file is logs/supervisor-{program}.log for v7-layout workers and logs/sv-worker-{slug}.log for unchanged legacy workers. An explicit log_file is preserved. Supervisor rotates the stream at 10 MB with 3 backups. These sources support clearing; follow/download are unavailable.

Cron job logs are privileged. They are 0640 owned by the account the job runs as, whose group is that account's own — so the adm membership that makes nginx, mysql and syslog readable does not apply, and the panel reads them through the system like Let's Encrypt. follow and downloadable are therefore false for them.

readable: false — the panel cannot open this source: the file exists but is not readable by its account, or the elevated reader it would need is not permitted. Disable the open action; opening it anyway answers 403 with code: log_not_permitted rather than an empty log.


GET /logs/{key}

Permission: logs (view)

Query: ?lines=200&grep=error&after=1048576

Response 200:

{"log": {
  "key": "nginx_error", "label": "Nginx Error Log", "group": "web",
  "lines": ["2026/07/29 11:00:00 [error] 1234#0: *1 connect() failed", …],
  "cursor": 1048576, "truncated": false
}}

For live tail: poll with ?after=<cursor>.

search_window_capped (added 2026-10-08, OLD-15): true when a grep searched only the last 5,000 lines of a source read through sudo and there was more log above them — an empty result then means "not in the last 5,000 lines", not "not in the log". Always false for a plain file, which is searched whole. Same field the application logs carry.

GET /logs/{key}/download

Permission: logs (view)

Stream the full file as a download (Content-Disposition: attachment).


DELETE /logs/{key}

Permission: logs (manage)

Empty one log. manage, not view: reading a log and destroying it are not the same trust, and a viewer gets 403.

Response 200: {"log": {"key": "nginx_error", "lines": [], "truncated": false}} — the source still exists, it is simply empty.

Only sources whose clearable is true. That flag is opt-in per registry row and covers every file-based source. Read it off the source rather than inferring it from the key — a client that guesses will offer what the server refuses.

clear_sensitive: true marks auth, ufw, fail2ban, syslog, kernel, mail and letsencrypt. These are clearable — operator's decision — but they are how the server records what happened to it: sign-ins, blocked requests, bans. They are what an investigation needs and the first thing an intruder would erase, so a client must confirm them with copy that names what is being destroyed, not the sentence it uses for an access log. The flag is the registry's answer, not the client's: do not keep a list of these keys locally, it will drift.

journal is the one refusal left, and it is technical rather than policy. It is not a file, so there is nothing to truncate; emptying it means journalctl --vacuum, which discards the host's entire journal rather than one source. A different operation with a different blast radius, and not this endpoint.

A refused or unknown key both answer 404, deliberately the same: from the client's side they are equally not on offer, and a 403 would advertise a capability the panel does not intend to have.

Truncated, never deleted. An open log that is unlinked keeps its disk space until the writer restarts and breaks its own handle — rm would free nothing and leave nginx or MySQL appending to a file no screen can read. truncate -s 0 reclaims the space with the writer attached and keeps the inode's owner and mode, which the service will not restore on its own. Same mechanism as the Disk Cleaner's service-log target.

Every clear is written to the activity feed as log.cleared with the source named.

See also DELETE /applications/{application}/logs/{key} for a site's own logs, which is the same operation under the app_log grant.


Disk Cleaner

GET /disk-cleaner

Permission: disk_cleaner (view)

{"disk": {"path": "/", "total": 107374182400, "used": 64424509440, "free": 42949672960, "percent": 60, "total_human": "100 GB", "used_human": "60 GB", "free_human": "40 GB"}, "categories": [{
  "key": "apt_cache", "label": "APT Cache", "description": "Cached apt packages", "group": "package", "method": "command",
  "paths": ["Cached apt packages"], "safe": true, "available": true, "reclaimable": 104857600, "reclaimable_human": "100 MB"
}]}

method: delete | truncate | command. note (localised) explains what each category does and what it keeps.

safe: false means the category may be cleaned manually but never on a schedule. Today that is apt_orphans and site_logs: everything else here removes files that come back, while that one removes packages, on the strength of apt's auto-installed flags — which are routinely wrong on a migrated or script-built server — and --purge takes their configuration too. Show it in the manual list, and hide or disable it in the schedule form; PUT /disk-cleaner/schedule refuses it with a 422 on categories.*.

paths is display-only and may include an exclusion note rather than a pattern — rotated_logs lists "excluding /var/log/mysql", because database binary logs live there and are removed with PURGE BINARY LOGS, never by deleting files.


site_logs (2026-09-23) empties every hosted site's current *.log files in {home}/{slug}/logs, on every web server — truncated, never deleted. It is its own category, not part of service_logs, because it removes the site owner's visitor and error history, so it is safe: false. Before this, the cleaner looked for site logs under /usr/local/lsws/conf/vhosts/*/logs, where they no longer live, and cleaned none. Both log categories now list their files through the server, so root-only directories such as /usr/local/lsws/logs are included — with PHP's own glob() they were silently missing.

A new schedule first runs at its first slot after being saved (2026-09-23). It used to run within a minute of saving, whatever slot was chosen, while next_run_at said otherwise.

POST /disk-cleaner/clean

Permission: disk_cleaner (manage)

Request: {"categories": ["apt_cache", "journal"]}

Response 200: {"run_id": 12, "disk": {…refreshed…}, "cleaned": [{"key": "apt_cache", "freed": 104857600, "freed_human": "100 MB"}], "freed_total": 524288000, "freed_total_human": "500 MB"}

⚠️ This runs synchronously and can outlive the request. A clean may take several minutes — apt_orphans alone is allowed 300 seconds — while nginx/PHP-FPM cut the request at around 60, so a large clean returns a gateway error while it is still running and finishes without the caller ever seeing the result. Known; a queued version returning a run id to poll is planned, and it will change this response shape. Until then, treat a timeout as "probably still running" and re-read GET /disk-cleaner/runs rather than telling the user it failed.


GET /disk-cleaner/schedule

Permission: disk_cleaner (view)

{"schedule": {"enabled": true, "frequency": "daily", "categories": ["apt_cache", "journal"], "threshold_percent": 80, "last_run_at": "27-07-2026 03:00:00", "last_run_at_human": "2 days ago", "next_run_at": "30-07-2026 03:00:00", "next_run_at_human": "in 15 hours", "timezone": "UTC"}}

null if no schedule is set.

frequency hides an hour the form never asks for: the cleaner runs at 03:00, one hour after backups, so the two do not contend for disk and CPU on a small VPS. next_run_at is what to show — it is null while enabled is false, rather than naming a run that will not happen, and timezone is the clock it is in (the panel's, not the server's — see GET /applications/{application}/backup-target for why the two differ).


PUT /disk-cleaner/schedule

Permission: disk_cleaner (manage)

Request: {"enabled": true, "frequency": "daily", "categories": ["apt_cache"], "threshold_percent": 80}

Response 200: {"schedule": {...}}

categories must hold at least one entry — an empty array is a 422, by design. A schedule with nothing selected is a cron entry that runs on time and cleans nothing: protection on the screen, nothing on the disk. Two honest ways to say "don't do this" already exist — send enabled: false, or DELETE the schedule — so the empty list is not a third.

So keeping Save disabled while every box is unchecked is correct, and it has been raised as a bug twice. Removing only the button's guard turns a disabled button into a failed save. Point the user at the toggle or the delete instead.


DELETE /disk-cleaner/schedule

Permission: disk_cleaner (manage)

Remove the schedule entirely.

Response 204:


GET /disk-cleaner/runs

Permission: disk_cleaner (view)

Manual + scheduled run history, paginated.

categories is what was asked for; freed is a per-category map of bytes actually reclaimed, so a category that ran and freed nothing is visible as 0 rather than missing. disk_percent is disk usage after the run, which is what makes the history worth keeping — it answers whether cleaning is still helping.

{"runs": [{
  "id": 1, "trigger": "scheduled",
  "categories": ["apt_cache", "journal"],
  "freed": {"apt_cache": 83886080, "journal": 20971520},
  "freed_total": 104857600, "freed_total_human": "100 MB",
  "status": "success",
  "disk_percent": 58,
  "created_at": "27-07-2026 03:00:00", "created_at_human": "5 days ago"
}], "meta": {"current_page": 1, "per_page": 20, "total": 5, "last_page": 1}}

Settings

GET /settings

Permission: setting (view)

{"settings": {
  "general": {"timezone": "UTC", "ntp": true, "clock_synchronized": true, "hostname": "srv1"},
  "swap": {"enabled": true, "path": "/swapfile-panel", "size": 2147483648, "size_human": "2 GB", "used": 0, "used_human": "0 B", "free": 2147483648, "free_human": "2 GB"},
  "security": {"port": 22, "permit_root_login": "prohibit-password", "password_authentication": false, "has_ssh_key": true},
  "updates": {"security_updates_enabled": true, "auto_reboot": false, "reboot_time": "06:00", "reboot_required": false, "updates_available": 3, "security_updates_available": 1, "lists_refreshed_at": "29-07-2026 04:00:00", "unattended_last_run_at": "27-07-2026 06:18:00", "unattended_last_result": "success", "unattended_last_error": null, "unattended_last_log": null, "unattended_last_log_truncated": false, "unattended_log_readable": true},
  "redis": {"maxmemory": "256mb", "maxmemory_policy": "volatile-lru", "unsafe_policies": ["allkeys-lru", "allkeys-lfu", "allkeys-random"], "has_password": true, "password": "s3cr3t-redis", "password_out_of_sync": false, "password_manageable": true, "running": true, "memory_used": 8388608, "memory_used_human": "8 MB"}

swap.path is /swapfile-panel, not /swapfile (changed 2026-09-14). It is the file install.sh creates, and the panel manages exactly that one — swap.enabled is answered by looking for this path in the kernel's swap list. The two names used to disagree, so on a fresh install the installer's swap was live and this block read enabled: false, size: 0. A server whose swap was created through this screen before the change has it at /swapfile; that file is now reported under unmanaged / system_total and is no longer resizable from the screen. Don't hardcode the path — render swap.path.

redis.password is the real value, sent so the panel can show and copy it (operator decision, 2026-08-31). It comes from the panel's own configuration — the value install.sh generated, wrote to redis.conf and recorded in .env — not from CONFIG GET requirepass. Reading it back from Redis required authenticating with that same value first, so a successful answer only echoed it, a failed one left the field blank on the very screen someone opens to investigate, and CONFIG GET can be renamed or disabled on a hardened Redis.

redis.password_out_of_sync answers the separate question of whether that stored value still works:

value meaning
false verified — the panel authenticated with it
true Redis is up and rejected it: requirepass was changed outside the panel, so its copy is stale and every session, cache read and queued job is failing behind the same rejection
null Redis is not running, so there is nothing to verify against — unverified, not wrong

has_password is now a plain boolean: whether the panel is configured with a password. It is no longer nullable; "could not ask" moved to password_out_of_sync, which is a different question. password is null when none is configured, or when the caller holds setting view without manage.

Only a caller who could change it receives it — setting manage, not view. This is not equivalent to a system user's password: Redis backs the panel's own sessions, cache and queue, so the credential unlocks the panel rather than one customer's account, and a read-only role can already see that a password exists without being able to act on the value.

redis.has_password is nullable. true and false are answers; null means the panel could not ask — its stored REDIS_PASSWORD does not open a connection to the running Redis. Reporting false there claimed no password on a server that requires one.

In that state PUT /settings/redis refuses a password change with a 422 rather than accepting it: the change is applied after the response, so a credential the panel cannot authenticate with would fail silently having already told the user it worked.

A successful change also refreshes the panel itself — it rebuilds the config cache (only if one exists) and restarts the queue worker. Writing .env alone would not be enough: install.sh runs config:cache, so the old password stays compiled into the file the panel reads, and queue:work loaded its configuration when it started. Since the installer points sessions, cache and the queue at Redis together, a change the panel had not picked up would make all of them answer NOAUTH — a successful password change that takes the panel down. The queue restart means a short gap in background work; nothing is lost, as jobs stay queued.

}, "last_changed": {"security": {"user": {"id": 1, "username": "admin"}, "at": "27-07-2026 10:00:00"}}}

redis group is omitted if Redis is not installed.

null for unavailable facts (e.g. updates_available: null when the check failed) — render differently from 0 (which means "nothing waiting").

Why an automatic update failed. unattended_last_result is "success", "failed" or null (never run). When it is "failed", three fields carry the evidence:

field meaning
unattended_last_error the one log line that decided the verdict — de-timestamped, redacted, bounded at 300 characters. Verbatim and untranslated: this is the string an operator pastes into a search box, and a paraphrase is not searchable.
unattended_last_log the failed run itself, from both unattended-upgrades.log and unattended-upgrades-dpkg.log, 8 KB per file, every line redacted. null on success, and null for a caller holding setting view without manage.
unattended_last_log_truncated either file was cut to fit, or the run began before the 500-line tail window reached back — so what is shown is the end of a run and not all of it.

Both logs, because they answer different halves of the question: unattended-upgrades narrates what it decided to do and that something went wrong, and when the failure is a package refusing to configure, the maintainer script's actual output is only in the dpkg log. A report built from the first file alone says that something broke and nothing about what. The dpkg portion is preceded by a line naming its path, so its ordinary Setting up ... chatter is not read as part of the error.

unattended_log_readable is a separate answer from every field above being null. false means the panel could not open the log at all — a missing adm group membership, a sudoers grant that never synced — which is a broken panel, not a server that has simply never run an upgrade. The two were indistinguishable and both rendered as silence.

security_update is the panel's own most recent run, or null if the button has never been pressed. Same shape as POST /settings/updates/run returns; carried here so the page renders a run in progress on its first paint instead of waiting for the first poll. output follows setting,manage.


POST /settings/updates/run

Permission: setting (manage)

Install the security updates that are waiting, now, instead of at apt's next timer.

Runs unattended-upgrade -v — unattended-upgrades' own binary, not apt-get upgrade. It applies exactly the Allowed-Origins policy that PUT /settings/updates writes, so the button means "run the automation now" rather than "upgrade everything". It also works with the automation disabled: the enable flags gate apt's timer, not the binary, so manual-only patching is a supported posture.

Response 202: {"security_update": {"id": 4, "status": "running", "reason": null, "reference": null, "exit_code": null, "packages_upgraded": null, "reboot_required_after": null, "output": null, "started_at": "12-09-2026 09:20:11", "started_at_human": "1 second ago", "finished_at": null, "finished_at_human": null}}

Never a finished answer — the upgrade takes minutes and can restart php-fpm, the frontend and the queue worker. The row is written before the job is dispatched, so there is no window where the run exists and nothing can see it.

The panel never reboots here, even when the upgrade demands one. reboot_required_after records that the box now wants one; restarting is its own confirmed action (POST /settings/reboot).

GET /settings/updates/run

Permission: setting (view) · throttle:progress, outside the ordinary API limiter

{"security_update": {…}|null} — the current or last run. Polled while one is in flight; expect connection failures mid-run and treat them as progress, because the upgrade can restart the services answering the request.

output is null without setting,manage. Watching is not changing, but apt's output can carry conffile diffs, debconf answers and mirror URLs, so it follows the heavier permission — the same split the failed-automatic-run excerpt uses.

reason on a failed run is a code the client words: locked, stale_lock, denied, timeout, dpkg, fetch, disk_full, dependencies, worker, unknown. worker is the one that is not a failure of the upgrade: the process running it disappeared — most likely restarted by the upgrade itself — so apt may well have finished the work. A run is also moved out of running by age for that reason, because when the worker dies nothing else is left to report.


PUT /settings/general

Permission: setting (manage)

Request: {"timezone": "Europe/London", "hostname": "newsrv", "ntp": true}

Response 200: {"general": {...}}

PHP 5.6–7.4 are told the new zone (2026-10-05, junior re-test #14). Those versions die with "Timezone database is corrupt" when they have to guess any zone but UTC, so every site on them answered 500 after a change here. A timezone change now writes date.timezone into 00-panel-timezone.ini in each of their scan directories and reloads them; installing PHP below 8.0 does the same with the current zone. PHP 8.x is left alone. A site's own php_timezone still wins. Never fails the request: the server timezone has already changed.


PUT /settings/swap

Permission: setting (manage)

Request: {"size_mb": 4096} — 0 disables.

Response 200: {"swap": {...}}


POST /settings/reboot

Permission: setting (manage)

Schedule a server reboot.

Request: {"delay_minutes": 5} — optional, 0–60, defaults to 0. 0 = now.

Response 202: {"reboot": {"scheduled": true, "when": "+5", "at": "31-08-2026 08:05:00", "delay_minutes": 5}}

Use at, not when. when is the literal shutdown argument (now or +N); at is the absolute moment, computed from the server's clock. Deriving a time from when in the browser uses the client's clock instead, and is wrong by whatever the two have drifted — on the one value where being wrong means somebody expects a restart at the wrong hour.


GET /settings/reboot

Permission: setting (view)

Whether a restart is already pending, and when.

Response 200: {"reboot": {"scheduled": true, "at": "31-08-2026 08:05:00"}} — or {"reboot": {"scheduled": false, "at": null}}.

Read from systemd's /run/systemd/shutdown/scheduled, not from anything the panel remembers: a reboot can be scheduled from a shell, and a panel that only knew about its own would report false while the machine counts down.

at is null on the rare pending shutdown whose record carries no USEC — scheduled is still the answer to act on.

500 with a reference when the panel could not look. Not the same as scheduled: false, and this is the endpoint someone calls to decide whether to cancel a restart.


DELETE /settings/reboot

Permission: setting (manage)

Cancel a pending restart (shutdown -c).

Response 200: {"reboot": {"scheduled": false, "at": null}}

Cancelling when nothing is scheduled is not an error: the caller wanted no pending restart, and there is none. Activity: setting.reboot_cancelled.


PUT /settings/security

Permission: setting (manage)

Request: {"port": 22022, "permit_root_login": "no", "password_authentication": false}

422 if disabling password auth with no SSH key present (lockout guard).

422 on port when another program already listens on the new port (e.g. the web server on 80) — SSH could not bind it. If the socket still fails to bind the new port, the old port is restored and SSH brought back on it before the 500 is returned. Saving also restarts an ssh.socket that is in the failed state, whatever the port.

Response 200: {"security": {...}}


PUT /settings/updates

Permission: setting (manage)

Request: {"security_updates_enabled": true, "auto_reboot": true, "reboot_time": "06:00", "reboot_with_users": false}

reboot_time is HH:MM (24-hour) or the literal string "now".

reboot_with_users decides whether an automatic reboot proceeds while someone is logged in over SSH. It is optional so an older client keeps working, and absent means false — note that this inverts the upstream unattended-upgrades default of true, so send it explicitly rather than relying on the default matching what the OS would do on its own.

Response 200: {"updates": {...}}


PUT /settings/redis

Permission: setting (manage)

Request: {"maxmemory": "512mb", "maxmemory_policy": "volatile-lru", "password": "newpassword"}

Omit password to leave it unchanged. {"remove_password": true} clears it.

A policy listed in unsafe_policies (the allkeys-* ones, whenever the panel's own queue is on this Redis) is refused with 422 on maxmemory_policy: once Redis is full it can evict a pending job silently.

Response 200: settings applied. {"message": "Password is being changed.", "reference": "…"} + 202 when a password change is in progress (applied after response).


GET /settings/reboot-schedule/presets

Permission: setting (view)

{"frequencies": [{"value": "daily", "label": "Daily"}, …], "hours": [{"value": 0, "label": "00:00"}, …], "days_of_week": [{"value": 0, "label": "Sunday"}, …]}

PUT /settings/reboot-schedule

Permission: setting (manage)

Request: {"enabled": true, "frequency": "weekly", "hour": 6, "day_of_week": 1}

day_of_month (1–28) for monthly. day_of_month caps at 28.

Response 200: {"reboot_schedule": {"enabled": true, "frequency": "weekly", "hour": 6, "day_of_week": 1, "timezone": "UTC", "next_run": "2026-08-04 06:02:00", "next_run_human": "in 4 days"}}


PHP

GET /php

Permission: php (view)

{"php": {
  "default": "8.4", "panel_version": "8.4",
  "versions": [{
    "version": "8.4", "path": "/usr/bin/php8.4", "is_default": true,
    "source": "apt", "in_use_by_panel": true, "in_use_by": 0,
    "service": "php8.4-fpm", "ini_path": "/etc/php/8.4/fpm/php.ini",
    "missing_packages": [],
    "status": "ready", "started_at": null, "reason": null, "message": null, "reference": null
  }, {
    "version": "8.3", "status": "installing", "started_at": "29-07-2026 05:20:11"
  }],
  "installable": [{"version": "8.5", "lifecycle": {"status": "active", "eol_date": "2027-01-01"}}]
}}

status on every row: installing | ready | removing | failed. ready is never stored — detected from the filesystem.

failed rows persist until retried. reason: package_not_found | apt_lock | no_space | network | worker | enable_failed | unknown. message is localised. reference locates raw apt output.

installing and removing rows have no other fields — the requested filesystem state is still changing.

🔴 missing_packages — a version that is present but not usable. Needs UI.

Empty for every version the panel installed. Non-empty means the interpreter arrived some other way and is a bare one — no curl, no sqlite3, no redis, no intl, no pgsql.

This is not hypothetical. On OpenLiteSpeed the openlitespeed package pulls in lsphp83 as its own apt dependency, so a fresh server lists a PHP 8.3 the panel never set up. Measured on a real box, 2026-09-21:

8.3 (apt dependency)   curl ✗  sqlite3 ✗  redis ✗  intl ✗  pgsql ✗
8.4 (panel installed)  curl ✓  sqlite3 ✓  redis ✓  intl ✓  pgsql ✓

In the picker the two are indistinguishable. A site placed on 8.3 then fails with "curl is not installed" — a real customer report.

What the frontend needs to do:

  1. Show it. A version with missing_packages.length > 0 is incomplete, not ready — e.g. Incomplete — 5 extensions missing. Today the row's status line is built only from in_use_by_panel, is_default and in_use_by, so a crippled version renders identically to a healthy one.
  2. Give it an action. POST /php/versions {"version":"8.3"} now repairs such a version instead of answering "already installed" — apt is idempotent, so it is the same endpoint the install button already calls. ⚠️ The install picker currently sets disabled={option.installed}, so an incomplete version cannot be selected there; the action has to live on the version row itself.
  3. Warn where it bites. The site-create PHP picker is where an incomplete version causes the damage, so flagging it there is worth more than flagging it on the PHP screen.

Names are apt package names (php8.3-curl, lsphp83-curl), already filtered to ones the index actually has — LiteSpeed compiles mbstring, xml, zip, gd, bcmath and soap into the interpreter, so those never appear here even though they have no package.


PUT /php/default

Permission: php (manage)

Request: {"default": "8.3"}

Response 200: {"php": {"default": "8.3", …}}

The CLI default moves, and (since 2026-10-01) it is also the version new sites start on: the create form preselects it and a PHP site created without php_version gets it — unless it is outside that site type's PHP range, in which case the newest installed version in range is used. server.default_php_version (install.sh's value) is only the fallback for when the server has no default the panel recognises. Existing sites keep whatever version they store — this never migrates a running site.

What runs depends on the stack, because the two register alternatives differently:

stack commands
FPM (ondrej) --set php, --set phar, --set phar.phar — all three groups exist already, and moving only php would leave phar on the previous version
LSPHP (OpenLiteSpeed) --install /usr/bin/php php <binary> <priority>, --set php, ln -sfn <binary> /usr/local/bin/php, then the same install/set pair for phar

The --install is not optional on LSPHP. LiteSpeed's packages install an interpreter and never call update-alternatives, so on a box without ondrej PHP there is no php link group and --set alone fails with update-alternatives: error: no alternatives for php. The priority is the compact version (84 for 8.4) so a later PHP outranks an earlier one if the link is ever put back into automatic mode; --set selects manual mode, which is what asking for a specific version means.

Nor is the symlink optional, and it is the part that decides the answer. Measured on a real OpenLiteSpeed server: with only the alternative set, /usr/bin/php -v reported the new version and php -v reported the old one. install.sh creates /usr/local/bin/php on this stack so the PHARs whose shebang is #!/usr/bin/env php have a php on PATH, and /usr/local/bin precedes /usr/bin there — so that symlink is what php resolves through. The group is kept correct for anything that reads it; the symlink is what makes the screen true.

Because the CLI default now owns that symlink, the panel no longer resolves its own interpreter through it: PanelPhpBinary prefers the CLI sibling of the binary currently executing the panel (<tree>/bin/php next to LSAPI's <tree>/bin/lsphp). Otherwise changing the CLI default would change the PHP the panel updates itself with. Panel-driven WordPress work was already immune — wp-cli is invoked with an explicit interpreter rather than through its shebang.

The phar groups are best-effort: a box that has the interpreter without those links still gets its PHP default changed, and the miss is recorded in the server-ops log rather than failing the request.

php.default is resolved by comparing the selected path against each installed version's own binary path, not by reading a version out of the path — /usr/local/lsws/lsphp84/bin/php contains no 8.4. It is null when the selected binary is not one of the versions listed in php.versions (a hand-made symlink, or a version removed while still selected) — that is "not one of these", not "none".


POST /php/versions

Permission: php (manage)

Request: {"version": "8.3"}

Response 202: queued (apt takes minutes). Already installed → 200.

422 on version (added 2026-09-26) when the version is neither installed nor in the server's package index — the same list GET /php returns as installable. It used to be queued and fail minutes later as package_not_found.


DELETE /php/versions/{version}

Permission: php (manage)

422 if the version is the panel's own, is currently the default, or is pinned by any site (site names returned in the message).

Response 204:


GET /php/versions/{version}/ini

Permission: php (view)

{"php_ini": {"version": "8.4", "path": "/etc/php/8.4/fpm/php.ini", "contents": "; PHP config\n…"}}

PUT /php/versions/{version}/ini

Permission: php (manage)

Replace the entire ini file. Requires acknowledged: true.

Request: {"contents": "; PHP config\n…", "acknowledged": true}

Sequence: back up → write → config test (php-fpm -t, or lsphp -c php.ini -v on OpenLiteSpeed) → reload. On validation failure, previous file restored, no reload.

contents is stored exactly as sent — not trimmed, so the file keeps its final newline.

422 on invalid ini — including a syntax error, which PHP itself only warns about (exit 0) while ignoring every line after it.


GET /php/versions/{version}/extensions

Permission: php (view)

status is the state of the operation, not the extension — installing | ready | failed. ready = nothing in flight, so installed/enabled can be trusted.

{"extensions": [
  {"name": "mysql", "package": "php8.4-mysql", "modules": ["mysqli","mysqlnd","pdo_mysql"],
   "installed": true, "enabled": true, "builtin": false,
   "summary": "MySQL module for PHP",
   "sapis": {"cli": true, "fpm": true}, "status": "ready"}
], "toggle_supported": true, "panel_required": ["curl", "mbstring"]}

summary — apt's one-line package description (English, from the package index), null for built-ins or when apt gives none. Show the screen's own translated description when there is one, else this.

toggle_supported — false on OpenLiteSpeed: an installed extension is always on and cannot be switched off (PUT … {"enabled": false} returns 422). Show installed extensions without an on/off switch; Install (enabled: true on a not-installed row) still works.


PUT /php/versions/{version}/extensions/{extension}

Permission: php (manage)

Request: {"enabled": true}

on, not installed → 202 (apt queued). off → 200 (unlinked, never purged). Built-in / panel-required → 422.

Every module of the package moves (2026-10-05, junior re-test #11). mysql is mysqli, mysqlnd and pdo_mysql, and all three are switched together; before, only the package name was passed, which Debian's tool skips with exit 0, so the answer was 200 and nothing changed. The state is now read back after the change: if the extension did not move, the answer is 422 with a message saying so (and a reference), and no activity is logged.

500 with a reference when the change was made but PHP could not be reloaded — it is not active yet, and the message says so. A queued install that ends that way fails with reason: "reload_failed" (distinct from enable_failed, where the module was not switched on at all).


GET /php/versions/{version}/ioncube

Permission: php (view)

{"ioncube": {
  "supported": true, "installed": true, "source": "panel", "php_version": "8.4",
  "loader_version": "15.5.0",
  "sha256": "e2193a63a87e2388a71854b0114de4fc71e2e40bcf0794faf74bb562691e5b62",
  "path": "/usr/lib/php/20240924/ioncube_loader_lin_8.4.so",
  "status": "idle", "reason": null, "message": null, "reference": null
}}

The ionCube Loader, which commercial PHP applications (WHMCS and most licensed scripts) need in order to run encrypted code. Its own card, not a row in the extensions list — there is no apt package for it, so nothing about the extensions endpoint applies.

supported: false is a real state, not an error. ionCube publishes no loader for PHP 8.0, and the panel still offers 8.0. Render an explanation, not an Install button. It can still come with installed: true, source: "external" — an earlier ServerAvatar version installed ionCube on 7.4 too — so show "installed outside the panel" with its version in that case, and no buttons either way. A PHP version that is not installed at all is a 404 — there is no card for a PHP that is not there.

loader_version is read out of PHP itself, so it is the version actually loaded rather than the one that was requested; null while installed is true means the ini is in place and the loader did not load. status / reason / reference follow the same pattern as PHP extension installs.

source — who installed it: "panel", "v7", "external", or null when not installed.

Show message, never reason. On a failed install, message is the sentence in the viewer's locale; reason is a code to branch on — the actual cause (ioncube_download_failed, ioncube_invalid_loader, ioncube_config_test_failed, ioncube_reload_failed, ioncube_rollback_failed, …, or worker if the job died), no longer always install_failed. message is null whenever the last run did not fail.

POST /php/versions/{version}/ioncube

Permission: php (manage)

202 — the archive is ~29 MB, so the install is queued. Poll GET until status leaves installing. A second call while one is running is a no-op 202, not a second download. A version ionCube does not publish for is refused 422 before anything is queued, and so is a version whose ionCube is v7 or external.

Installing a new PHP version installs ionCube for it too when another installed PHP version already has ionCube (any source), as v7 did. It is a separate queued install on that version's card, so poll its GET the same way; the activity log says "Started installing the ionCube Loader for PHP X, because other PHP versions on this server have it". Skipped for versions ionCube publishes no loader for. If it fails, PHP itself is still installed and the card shows the failure.

DELETE /php/versions/{version}/ioncube

Permission: php (manage)

200, synchronous — two files removed and a reload, nothing to download (for source: "v7" see above). 422 when source is external.

Removing a PHP version removes its ionCube too — the panel's own loader and ini are deleted after the version's packages are purged (an external one goes with the purge / the version's config directory).

Applies to every site on that PHP version. There is no per-application toggle, and the loader does not carry over between versions: installing it on 8.4 does nothing for 8.3. Each version has its own card and its own binary.

Node.js

GET /node

Permission: node (view)

{"node": {
  "manager": "fnm", "default": "20.11.0",
  "versions": [{
    "version": "20.11.0", "path": "/opt/fnm/node-versions/v20.11.0/installation/bin/node",
    "is_default": true, "source": "fnm",
    "npm_version": "10.2.4", "npm_latest": "11.19.1", "npm_update_available": true,
    "in_use_by": 7, "lifecycle": {"status": "lts", "eol_date": "2026-04-30", "lts_name": "Iron"}
  }],
  "system": {"version": "24.18.0", "path": "/usr/bin/node"},
  "installable": [{"version": "22.11.0", "lifecycle": {"status": "current"}}],
  "lifecycle_available": true
}}

Same status / started_at / reason / message / reference pattern as PHP. npm_version is read from that version's own npm.

npm_latest is per row, not per registry. npm declares which Node versions it runs on, and the newest release routinely excludes Node lines the panel still installs — npm 12 requires ^22.22.2 || ^24.15.0 || >=26.0.0. So this field is the newest npm that Node version can actually run: 12.0.2 on Node 24, 11.19.1 on Node 20.17, 10.9.9 on Node 18. Publishing the registry's own latest on every row would leave the update control lit forever on every version that can never reach it.

null when the panel cannot say — the catalog has never refreshed, or the box has no egress. Show no comparison rather than a number.

Bind the button to npm_update_available, not to npm_latest !== npm_version. It is a semver comparison done server-side; comparing as strings reads 9.8.1 as newer than 10.2.4. It is false whenever npm_latest is null.

The catalog is refreshed daily by runtimes:refresh-npm; the API never calls the registry inside a request.


PUT /node/default

Permission: node (manage)

Request: {"default": "20.11.0"}

Response 200: {"node": {"default": "20.11.0", …}}


POST /node/versions

Permission: node (manage)

Request: {"version": "20.11.0"}

Response 202: queued. Already installed → 200.


DELETE /node/versions/{version}

Permission: node (manage)

422 if a site pins it or it is the default.

Response 204:


POST /node/versions/{version}/npm

Permission: node (manage`

Update npm inside a specific Node version. Installs the newest npm that version can run — never npm@latest, which on an older Node replaces a working npm with one that cannot start.

When the npm release catalogue cannot answer (it has never been refreshed and the registry is unreachable), the request is refused with 422 and errors/node.npm_target_unknown rather than installing a guess. The catalogue is refreshed on demand first, so a server with internet access resolves this by itself.

Response 200: {"message": "npm updated to 11.19.1.", "npm_version": "11.19.1", "npm_latest": "11.19.1", "npm_update_available": false}

The last two are the same fields the list sends, so the row can be updated from this response instead of refetching.


Setup Page

GET /setup

Permission: setting (view)

The same component list as the Services page — one read drives both screens.

{"setup": {
  "complete": false, "status": "installing", "percent": 60,
  "key": "database", "label": "Installing Database",
  "stack": "lemp", "web_server": "nginx",
  "components": [
    {"key": "database", "title": "Database", "description": "MySQL or MariaDB",
     "state": "installing", "detail": null, "recommended": true,
     "action": null, "reason": null, "message": null, "retryable": false,
     "options": [
       {"value": "mariadb", "label": "MariaDB", "installed": false, "version": null,
        "installable": true, "recommended": true,
        "action": {"method": "POST", "endpoint": "/api/databases/engines/mariadb"}}
     ]},
    {"key": "php", "title": "PHP", "state": "installed", "detail": "8.4",
     "action": {"method": "POST", "endpoint": "/api/php/versions"}, "options": []}
  ]
}}

state: installed | pending | installing | failed. pending means "not found", not "not tried". action is null when the panel cannot install this component (e.g. Redis, MongoDB).

The wp_cli component (WP-CLI) is recommended: false, so it never affects complete. Its action is POST /api/wp-cli/install.


POST /wp-cli/install

Permission: application (manage) | Throttle: 10/min

Queues the WP-CLI download to /usr/local/bin/wp (the same file the WordPress installer fetches on first use). 202 {"message": "Installing WP-CLI."}; 422 when it is already installed. Poll GET /setup: the wp_cli row goes installing → installed, or failed with reason (network, no_space, incomplete, sudo_denied, unknown), a localized message, and retryable: true.


Integrations — Git Accounts

GET /integrations/git/providers

Permission: git (view)

What each provider needs to connect.

{"providers": {
  "github": {"name": "GitHub", "fields": ["token"], "token_label": "Personal Access Token", "token_type": "password"},
  "gitlab": {"name": "GitLab", "fields": ["token"], "token_label": "Personal Access Token", "token_type": "password"},
  "bitbucket": {"name": "Bitbucket", "fields": ["username", "app_password"], "token_label": "App Password", "token_type": "password"}
}}

GET /integrations/git/accounts

Permission: git (view)

{"git_accounts": [{
  "id": 1,
  "provider": "github", "provider_title": "GitHub",
  "label": "Personal",
  "identifier": "devuser",
  "host": null,
  "workspace": null,
  "scopes": ["repo", "read:user"],
  "last_verified_at": "29-07-2026 10:00:00", "last_verified_at_human": "2 hours ago",
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago"
}]}

Rows are ordered by label without case bias. The account name is identifier, not username. There is no status or expires_at here — token health is never cached on this record; ask GET /integrations/git/accounts/status for it, since a token can be revoked at any moment.

host is null for the hosted service and set for a self-hosted GitLab/Bitbucket. workspace is the Bitbucket workspace (or GitLab group) the token is scoped to, null elsewhere. scopes is what the token was found to grant at last_verified_at — an empty array means the check has not run or returned none, not that the token is scopeless.


GET /integrations/git/accounts/status

Permission: git (view) | Throttle: 30/min

Live token health — checked on demand, never cached (tokens can be revoked at any time). Rows use the same case-insensitive label ordering as the account list.

{"statuses": [
  {"id": 1, "label": "Personal", "provider": "github", "provider_title": "GitHub",
   "status": "valid", "status_title": "Valid token",
   "expires_at": null, "expires_in_days": null, "checked_at": "29-07-2026 10:00:00"}
]}

POST /integrations/git/accounts

Permission: git (manage) | Throttle: 20/min

Request (GitHub):

{"label": "Work", "provider": "github", "token": "ghp_…"}

Request (Bitbucket):

{"label": "Work", "provider": "bitbucket", "username": "devuser", "token": "APP_PASSWORD"}

Response 201: {"git_account": {"id": 2, "label": "Work", "provider": "github", "status": "valid"}}


PUT /integrations/git/accounts/{account}

Permission: git (manage) | Throttle: 20/min

Request: {"token": "ghp_newtoken…"}

Response 200: {"git_account": {...}}


POST /integrations/git/accounts/{account}/test

Permission: git (manage) | Throttle: 20/min

Verify the token is still valid with the provider.

Response 200: {"git_account": {"id": 1, "status": "valid"}}


DELETE /integrations/git/accounts/{account}

Permission: git (manage)

Response 200: {"deleted": true}

Response 422 while any application still deploys with the account — deleting it would leave those sites unable to deploy. A validation error on git_account that names them (first 5, then a count):

{"message": "Cannot disconnect Work GitHub — it is still used by Blog, Shop. Link those applications to another account first.",
 "errors": {"git_account": ["Cannot disconnect Work GitHub — it is still used by Blog, Shop. …"]}}

Show message in the delete dialog. Nothing is deleted and nothing is logged.


GET /integrations/git/accounts/{account}/repositories

Permission: git (view) | Throttle: 60/min

Query: ?search=shop&page=1

{"repositories": [
  {"name": "shop", "full_name": "devuser/shop", "url": "https://github.com/devuser/shop", "default_branch": "main", "private": true}
], "meta": {"page": 1, "has_more": false}}

GitHub search covers every repository the account can see (2026-10-05, junior re-test #15), not only the first page: the full list (up to 1,000, fetched 100 at a time) is filtered and paged through page / meta.has_more, and kept for 2 minutes per account so typing does not refetch it. Without search, paging works as before. GitLab and Bitbucket already search on their side.


GET /integrations/git/accounts/{account}/branches

Permission: git (view) | Throttle: 60/min

Query: ?repository=devuser/shop

Response 200: {"branches": [{"name": "main", "protected": true}, {"name": "develop", "protected": false}]} — objects, not bare names; protected is the provider's own branch-protection flag.


Integrations — Storage Destinations (S3-compatible, FTP, SFTP, Google Drive)

⚠️ Breaking change. The destination is now polymorphic. The read-only driver field (always "s3") is replaced by a real provider column, and the five S3 fields that used to sit at the top level (endpoint, region, bucket, access_key, secret_key) have moved inside a config object whose shape depends on the provider. Any client that inferred the provider by matching the endpoint hostname should delete that inference — the API now states it.

GET /integrations/storage/destinations

Permission: storage (view)

Rows are ordered by name without case bias.

{"storage_destinations": [{
  "id": 1,
  "name": "S3 Backup",
  "provider": "s3", "provider_title": "S3-compatible",
  "config": {"endpoint": "https://s3.eu-west-1.amazonaws.com", "region": "eu-west-1", "bucket": "my-backups"},
  "prefix": "backups/",
  "has_credentials": true,
  "status": "connected", "status_title": "Connected",
  "last_tested_at": "28-07-2026 10:00:00", "last_tested_at_human": "yesterday",
  "last_test_success": true,
  "last_test_error": null,
  "created_at": "25-07-2026 14:30:00", "created_at_human": "2 weeks ago",
  "updated_at": "28-07-2026 10:00:00", "updated_at_human": "yesterday"
}]}

provider is one of s3 · ftp · sftp · google_drive · google_drive_oauth. provider_title is the localized label — render that, don't map the code yourself. (There is no webdav provider — earlier versions of this document described one that was never built.)

The list response also carries google_oauth_redirect_uri at the top level: the address to paste into the Google OAuth client when creating it, before any google_drive_oauth destination exists.

config on a response is addressing detail only, never credentials — bucket/region/endpoint for S3, host/port/root for FTP and SFTP. It is not the same set of keys you send; secrets are silently absent rather than masked, because reading them would mean decrypting them into the response.

status distinguishes three states, and the third one matters: never_tested · connected · failed. last_test_success is deliberately nullable — never-asked is not the same as asked-and-failed, and rendering a red cross for an untested destination would be a lie. Drive the badge off status, not off the boolean.

The test result is cleared whenever the provider or any config key changes (the one exception is host_fingerprint, which a successful probe records itself).

FTPS certificate pinning (2026-10-05, FTP-01). An FTPS destination (ssl: true) records the server certificate's fingerprint the first time it connects (config.tls_fingerprint, sha256:<hex>, safe to show) and refuses a different one before any test, backup or restore connects: test.error_class tls_certificate_changed (or tls_certificate_unreadable when it cannot be read). After a legitimate certificate renewal, send PATCH … {"config": {"trust_new_certificate": true}} — the pin is forgotten (and the test result cleared) and the next connection records the new one. Plain FTP has no certificate and nothing is checked. Limit: the check runs on its own connection just before the transfer, because PHP's FTP functions expose no certificate. A stored "connected" describes the credentials that were tested, not the ones now saved.

last_test_error is a stable category, safe to branch on and to translate: invalid_credentials · bucket_not_found · wrong_region · tls_failed · unreachable · host_key_mismatch · invalid_private_key · mismatch. The raw exception is never echoed. (S3 only: before 2026-09-24 a wrong bucket, a wrong region and a broken TLS certificate were all reported as invalid_credentials. Backblaze B2 still answers a wrong-region endpoint with invalid_credentials, because B2 itself says "the key is not valid".)

Backblaze B2 keeps deleted backups. A B2 bucket's default lifecycle is keep all versions, so when the panel deletes a backup (by hand or by retention), B2 hides the file instead of removing it, and it is still stored and billed. Measured 2026-09-24. The panel does not work around this; tell the user to set the bucket's lifecycle to Keep only the last version in the Backblaze console. AWS, Wasabi and DigitalOcean Spaces keep no versions unless versioning was turned on deliberately; Cloudflare R2 has no versioning.

has_credentials reports whether the provider's secrets are populated, without returning any of them. For SFTP it is any of password / private key, since those are alternatives.


POST /integrations/storage/destinations

Permission: storage (manage) | Throttle: 20/min

Required for every provider: name (unique, single-line, max 100) and provider. Optional: prefix ([A-Za-z0-9._/-], max 255). There is no default provider — a request that omits it is a 422, not an implicit S3 destination.

S3 — {"name": "S3 Backup", "provider": "s3", "prefix": "backups/", "config": {"endpoint": "https://s3.amazonaws.com", "region": "eu-west-1", "bucket": "my-backups", "access_key": "AKIA…", "secret_key": "…"}}

key required notes
config.bucket yes [A-Za-z0-9._-], single-line
config.access_key / config.secret_key yes max 255 / 512
config.endpoint no https URL; omit for AWS itself and for anything carrying its region in the bucket host (R2, B2, Wasabi). Refused if it resolves to loopback or the cloud metadata range.
config.region no [A-Za-z0-9-], defaults to us-east-1

FTP — {"name": "NAS", "provider": "ftp", "config": {"host": "nas.example.com", "port": 21, "username": "backup", "password": "…", "root": "/backups", "ssl": true, "passive": true}}

key required notes
config.host yes hostname or IP. Refused if it resolves to loopback or 169.254.0.0/16; a private LAN address is allowed, because a NAS on the same network is a normal destination.
config.username / config.password yes single-line / max 512
config.port no 1–65535, defaults to 21
config.root no [A-Za-z0-9._/-], and .. segments are refused
config.ssl no boolean, defaults to true (FTPS). Sending false means credentials and backup contents both cross the network in cleartext — surface that as an explicit, labelled choice rather than a bare toggle.
config.passive no boolean, defaults true

SFTP — same host/port/username/root rules (port defaults to 22), plus config.password, config.private_key (max 16384) and config.passphrase, all individually optional.

⚠️ SFTP needs exactly one auth method and neither field can be required on its own. A request with neither a password nor a private key is refused: storing it would move the failure from this form to the first backup.

A private key is checked when it is saved (create and PATCH) and refused with a 422 on config.private_key saying which problem it is: not a key at all, a public key pasted by mistake, a passphrase-protected key sent without config.passphrase, or a passphrase that does not unlock it. On PATCH, a key rotated without a new passphrase is checked against the stored one.

One login attempt per connection. A wrong password or key is answered invalid_credentials from a single attempt — the SFTP library's default of five would lock the panel out of a backup server running fail2ban (five failures is its default ban).

The test creates the destination's folder (root + prefix) if it is missing, the same way a backup does; a new prefix no longer fails the test as unreachable. FTP cannot do this — it must enter its root to log in — and reports root_missing instead.

Host keys are trust-on-first-use. The first successful probe records the server's fingerprint on the destination; a later probe against a changed key fails with host_key_mismatch rather than connecting. Re-keying a server therefore requires clearing the stored fingerprint deliberately.

Google Drive — {"name": "Drive", "provider": "google_drive", "config": {"service_account_json": "{…}", "folder_id": "1AbC…"}}

Authenticated by a service account; there is no OAuth flow, so there is no client id, refresh token or callback. config.folder_id is the part of the folder URL after /folders/ — a pasted URL is a 422, because storing one fails much later with a "file not found" that names nothing.

🔴 Only a Google Workspace Shared Drive works. A service account has no Drive storage quota of its own, so a file it uploads to a personal "My Drive" folder has no quota to charge and Google refuses the write with storageQuotaExceeded — even when the account is empty. The test endpoint checks the folder's driveId before writing anything and answers drive_personal for a personal folder: a small probe object can be accepted where a real archive is refused, so writability is not a sufficient test. Surface this in the UI before the key is pasted.

A successful test records drive_name (which Shared Drive it is) and client_email (the address the folder must be shared with) onto the destination; both come back in config and neither is a credential.

Drive-specific last_test_error categories: drive_personal · drive_not_shared · drive_folder_missing · drive_not_a_folder · drive_bad_key · drive_quota · drive_incomplete.

Google Drive (your own account) — {"name": "My Drive", "provider": "google_drive_oauth", "config": {"client_id": "1234-abc.apps.googleusercontent.com", "client_secret": "…"}}

Authenticates as the user through Google's OAuth consent, so files land in their own Drive and their own storage pays for them — the only way a free Gmail account can use Drive. Different from google_drive, which is a service account and works with a Workspace Shared Drive only.

key required notes
config.client_id yes must end in .apps.googleusercontent.com
config.client_secret yes
config.folder_id no set by the connect flow, not typed

Create the destination first, then connect it:

  1. POST /integrations/storage/destinations/{id}/oauth/start (storage manage, 20/min) → {"oauth": {"authorize_url": "https://accounts.google.com/…"}}. Send the browser there.
  2. Google redirects back to google_oauth_redirect_uri (from the list response) with code and state. The page forwards both to POST /integrations/storage/oauth/callback (storage manage, 10/min) → {"oauth": {"status": "connected" | "failed", "message": null | "<sentence in the viewer's locale>"}, "storage_destination": {…} | null}. Which destination it was for is sealed inside state, not taken from the URL; storage_destination is null when state did not resolve to one.

Response 201: {"storage_destination": {...}}


GET /integrations/storage/destinations/{storageDestination}

Permission: storage (view)

{"storage_destination": {"id": 1, "name": "NAS", "provider": "ftp", "provider_title": "FTP",
 "config": {"host": "nas.example.com", "port": 21, "root": "/backups"}, …}}

PATCH /integrations/storage/destinations/{storageDestination}

Permission: storage (manage) | Throttle: 20/min

Update name, prefix and any config key. Secrets omitted = unchanged, so a rename must not resend them.

config is merged, not replaced — patching config.host alone keeps the username, password and root that were already stored.

🔴 provider is immutable (prohibited — sending it at all is a 422, even with the current value). The stored config is only meaningful in the shape its provider defines, so changing the provider would reinterpret a bucket as a hostname. Changing provider means delete and recreate.

Request: {"name": "EU Backup", "config": {"bucket": "eu-backups"}}

Response 200: {"storage_destination": {...}}


DELETE /integrations/storage/destinations/{storageDestination}

Permission: storage (manage)

422 if any backup target uses this destination — a site's (the message names the sites) or a database's own schedule (names the database, "still used by the backup schedule of database …") — or if any backup's archive is still stored in it (errors.storage_destination, "still holds N backup(s)"), which happens after a target is repointed elsewhere. The second case used to be refused by the database as a 500. Delete those backups first, which removes their archives too; the panel never deletes them as a side effect.

Response 204:


POST /integrations/storage/destinations/{storageDestination}/test

Permission: storage (manage) | Throttle: 20/min

Probe credentials and reachability by uploading/reading/deleting a sentinel object.

Response 200:

{"test": {
  "success": true, "latency_ms": 142, "message": "Connection successful.",
  "error_class": null, "tested_at": "29-07-2026 10:00:00"
}}

Always 200 — the request itself succeeded, test.success carries the verdict.


Docker — Image discovery (search, versions, inspect)

Added 2026-10-07 (DS-02). These three endpoints let the Docker create form fill itself in. They read registry APIs and never pull: nothing lands on the server until the user deploys. They are only on Docker servers (409 elsewhere). Each needs application (view). Passing registry_id also needs registry (view).

Two kinds of "no" are kept apart:

GET /docker/images/search?q=<text>&limit=10

Throttle: 60/min. q 2–100 chars, limit 1–50. Searches Docker Hub and re-ranks: the exact name typed first, then official images, then most pulled. A repository whose namespace and name both contain the search term (umamisoftware/umami) counts its pulls five times, but only from 10,000 pulls up, so a near-empty look-alike never takes first place (DS-09). Cached 10 min.

{ "results": [ { "image": "neosmemo/memos", "registry": "docker.io", "description": "A privacy-first, lightweight note-taking service.",
  "stars": 233, "pulls": 11672941, "official": false, "verified_publisher": false } ] }

Offline: {"results": [], "offline": true}. verified_publisher is always false today, because Hub's search API does not report that badge. It stays in the contract so the shape does not change later.

GET /docker/images/tags?image=<ref>&limit=20[&registry_id=]

Throttle: 30/min. limit 1–100. Tags are ordered like this:

  1. Plain version numbers, newest first. 0.31.0 comes before 0.31.
  2. Variants such as 1.31.6-alpine, 3.2.1-nextcloud.
  3. Everything else, such as latest, edge, sha-… and pre-releases (-rc1, -beta, -devel — whole words only), most recently updated first.

A date-shaped version (2021.11.28) beside plain ones is ranked after them, unless the registry's dates show the date tags are the newer scheme. An image that only publishes dates (Home Assistant) is ranked normally.

stable is true for groups 1 and 2. recommended is the newest plain version, a dotted one (4.140.0) before a bare number (39, code-server's Fedora build). If there is none, it is latest, stable, release or lts, then Hub's most recently pushed tag unless it is a pre-release, and otherwise null (the form asks). recommended is always one of the returned tags, even when it ranks past limit. Docker Hub images and lscr.io/linuxserver/* are read from Hub's API, which gives updated_at. Other registries are read from tags/list, so updated_at is null.

{ "image": "ghcr.io/usememos/memos", "recommended": "0.31.0",
  "tags": [ { "name": "0.31.0", "updated_at": null, "stable": true }, { "name": "0.31", "updated_at": null, "stable": true } ] }

Unknown image: tags: [], recommended: null. Registry unreachable: the same, plus "offline": true.

GET /docker/images/inspect?image=<ref>[&registry_id=]

Throttle: 30/min. image is any reference Docker accepts (nginx, usememos/memos:0.31.0, ghcr.io/o/a@sha256:…, registry.example.com:5000/a). No tag means latest. A reference Docker would refuse, for example an uppercase repository, is a 422 on image.

The image is resolved for this server's architecture. A manifest list is narrowed to the server's platform before the config is read. Answers are cached 10 min per reference; config blobs are cached 1 h by digest.

{ "image": "neosmemo/memos:0.31.0", "digest": "sha256:…", "found": true,
  "exposed_ports": [5230], "suggested_port": 5230, "port_confidence": "declared",
  "volumes": ["/var/opt/memos"], "suggested_volumes": [ { "path": "/var/opt/memos", "name": "memos-data" } ],
  "env": [ { "key": "TZ", "default": "UTC", "required": false }, { "key": "MEMOS_PORT", "default": "5230", "required": false } ],
  "workdir": "/var/opt/memos", "user": "", "size_bytes": 26239339, "architectures": ["arm64", "amd64", "arm/v7"],
  "uses_app_dir": false, "warnings": [] }

Not found, 200:

{ "image": "nginx:nosuchtag", "found": false, "reason": "tag_not_found", "message": "The image nginx has no version nosuchtag. Pick one from the version list." }

reason is one of:

Errors:

Credentials: registry_id is sent only to the registry it was saved for. A GHCR token is never sent to Quay, even when asked to. It goes to the registry's token endpoint as basic auth and never appears in a response.


Docker — creating a site from an image (DS-03)

POST /applications with site_type: "docker" (simple mode, no compose):

Field
image Required.
container_port Optional. Empty → read from the image's EXPOSE (the suggested_port of /docker/images/inspect). An image that declares none, one that cannot be found, or a registry that cannot be reached is a 422 on container_port asking for it. There is no default of 80 any more (rows created before keep theirs). A port the image does not declare is accepted with a warning. On PUT …/container leave it out to keep the current port; null there is a 422 (DS-09).
env Optional, max 100: [{key, value}]. key matches ^[A-Za-z_][A-Za-z0-9_]*$, unique; value may be multi-line. Written to the site's env file (the one GET/PUT …/environment edits) when it is provisioned, quoted so Compose reads it back literally ($, # and quotes included). Kept encrypted until then and never returned.
volume_mounts Optional, max 20: [{path, volume?}], the same shape as PUT …/container. A missing volume is named <site slug>-<last path segment>, with -2, -3… if taken. An existing volume name is mounted as is; a new one is created. Absent (and no volume_new) → the image's own VOLUMEs are used; [] means none. volume_new + volume_path still work and come first.

env and volume_mounts are refused (422) alongside a pasted compose, which says its own.

An image listed in server.docker.images.required_env (e.g. Umami → DATABASE_URL) is refused with a 422 on env when any of those keys is missing or empty: "This image will not start without DATABASE_URL. …" (DS-09). Checked from config, so it applies even when the registry cannot be read.

Response 201: { application, warnings: [] }. warnings is always a list of sentences, e.g. "The image listens on 5230, not 8082. The site will not answer unless the application really listens on 8082."

Readiness check, container_status, last_failure

After compose up, a simple-mode or pasted-compose site (not the one-click apps, whose installers wait themselves) is checked until anything answers HTTP on 127.0.0.1:<app_port> — any status counts — or until DOCKER_READINESS_TIMEOUT seconds (90) of wall-clock time have passed, capped at 150 (DS-09). The cap bounds the check, not the request: the wait and the diagnostics after it (compose ps/logs, at most 30 s each) stay inside the panel's 300 s request timeout, but the compose up and pull before it may take up to DOCKER_COMMAND_TIMEOUT (600 s) because an image can be gigabytes (the first install, which runs in the queued provisioning job rather than a request, gives that compose up DOCKER_INSTALL_TIMEOUT instead: 1200 s, for every one-click app and every Container-tab site, RC-20) — so on a slow pull the synchronous PUT …/container or Pull can still end in a gateway timeout. PHP normally carries on past the gateway and stores the outcome on the site, so re-read the application rather than treating a timeout as a failure (DS-12). A container that exits fails at once. One that keeps restarting with nothing answering fails after DOCKER_READINESS_RESTART_GRACE seconds (30) — except in a pasted compose file, whose services may restart while they wait for each other: there it fails only at the deadline. The same check runs on PUT …/container and on Pull and redeploy, and a pass clears the previous failure. seconds in a container_not_answering failure is the time actually waited. A pasted compose file has no single container port, so its message names none rather than the host port.

An image with its own HEALTHCHECK is judged by it instead (DS-14). healthy passes, unhealthy fails at once with container_unhealthy, and while Docker still says starting the panel does not request / at all — Docker's check is already asking, and a second requester during a first boot is what left Kanboard's lazy migration half applied (DS-11). If the check is still starting at the deadline, one request to / decides: an answer passes the deploy with container_status: starting, which the live read below turns into running or unhealthy once Docker has a verdict. Images without a healthcheck are checked exactly as before.

On the application:

{ "reason": "container_port_mismatch",
  "message": "Nothing answers on container port 8082 — the image listens on 5230. Set the container port to 5230 and deploy again. Last log line: …",
  "last_line": "…", "log": "<last 50 lines>", "at": "2026-10-07T18:40:00+00:00" }

message is in the viewer's locale. log and last_line (and the last line in message) only for users with app_log view; otherwise null. The failure is also on failed_step / failed_reason (see the table above).


Application — Container credentials

GET /applications/{application}/container/secrets

Permission: app_container (manage), authenticated. Throttle: 6/min. Non-container/site-type-unsupported applications return 404 through the existing feature gate.

curl --fail-with-body -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' "$PANEL/api/applications/1/container/secrets"

The existing secrets map is unchanged. credentials adds the recipe's curated first-run rows, in declaration order; for example Grafana:

{"secrets":{"GF_SECURITY_ADMIN_PASSWORD":"stored-admin-value"},"credentials":[{"label":"username","value":"admin","secret":false},{"label":"password","value":"stored-admin-value","secret":true}]}

The values above are fixture examples, not real credentials. Each row is {label, value, secret}. A literal uses secret: false; a declared secret reads the stored docker_secrets[KEY] with secret: true; an input reads installSettings()[name] with secret: false; a path reads the site's actual url(path) (HTTP until its certificate can serve HTTPS), also secret: false. No password is regenerated by this GET. Empty/missing values are skipped and remaining rows are always a JSON list.

credentials is null for direct Docker/non-recipe sites or recipes declaring none (e.g. Ghost's setup wizard), so clients can use their existing legacy key-list fallback. If rows are declared but every value is absent, it is [], not null. Chatwoot's curated list shows the admin email and password without database/cache rows; the unchanged legacy secrets map still includes all stored secrets, including ADMIN_PASSWORD and its database/cache secrets. Retirement does not hide an existing site's credentials.

The existing application.container_secrets_viewed activity is still recorded before disclosure with secret key names only, never values. View-only callers receive 403, unauthenticated callers 401, and throttled requests 429. This adds nothing to ApplicationResource, and never changes the site's acknowledgement state.


Application — Command-line access

GET /applications/{application}/container/access

Permission: app_compose (view), the same as reading the compose file. Throttle: 60/min. A site type without container screens (WordPress, PHP, Node, …) returns 404 through the feature gate; a container type on another serving profile returns 422 with errors/application.not_a_container, as …/container/compose does (RC-23).

What the dashboard's Command-line access card needs. Since RC-28 the way in is the app's own system user: with container login on, ssh -t <owner>@<host> lands inside the app's main container, with no sudo grant and no docker group (see Container login below).

curl --fail-with-body -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' "$PANEL/api/applications/12/container/access"
{
  "host": "203.0.113.10",
  "ssh_port": 22,
  "project": "sv-app-12",
  "containers": [
    { "service": "openclaw", "name": "sv-app-12-openclaw-1", "main": true }
  ],
  "ssh_users": [
    { "username": "adarsh", "ssh_access": true, "sudo": true, "has_password": true }
  ],
  "password_login": true,
  "owner": {
    "username": "adarsh-oc", "ssh_access": true, "container_login": true,
    "has_password": false, "ssh_keys": 1, "other_apps": 0
  },
  "app": "openclaw",
  "commands": {
    "shell": "ssh -t adarsh-oc@203.0.113.10",
    "exec_example": "ssh adarsh-oc@203.0.113.10 'ls -la'",
    "logs": "ssh adarsh-oc@203.0.113.10 @logs --tail 100"
  },
  "cli_examples": [
    { "label": "List pending browsers", "command": "ssh adarsh-oc@203.0.113.10 'openclaw devices list'" },
    { "label": "Approve a browser", "command": "ssh adarsh-oc@203.0.113.10 'openclaw devices approve <requestId>'" }
  ]
}

POST /applications/{application}/container/access

Permission: system_user (manage). Throttle: 10/min. Same 404/422 as the GET for sites that are not containers, answered before the body is validated.

Turns container login on for the app's owner (RC-28): sets its key and/or password if sent, gives a nologin user /bin/bash (the forced command runs through the login shell), makes sure the server pieces are installed (containers:sync-shell-access), rewrites the access map, adds the user to sv-app-shell, asks sshd -T -C user=<owner> whether the Match block now applies, and only then turns ssh_access on through the System Users path. Answers 200 with the GET body.

{ "ssh_key": "ssh-ed25519 AAAA… me@laptop", "password": null, "confirm_container_only": false }

DELETE /applications/{application}/container/access

Permission: system_user (manage). Throttle: 10/min. Takes the owner out of sv-app-shell. If ssh_access is still on, its next login is an ordinary one on the server. Answers 200 with the GET body.

What the user can type (container login on)

Command Does
ssh -t u@host a shell (bash if present, else sh) in the main container
ssh u@host '<command…>' docker exec -i <c> sh -c -- '<command…>'; the text is interpreted by the container's shell only
ssh -t u@host @<service> / ssh u@host @<service> <command…> the same in another container of the app (only when the user has one Docker app)
ssh -t u@host @<app> / @<app>/<service> picks one app when the user owns several; with no pick the list is printed and the exit code is 2
ssh u@host @logs [<target>] [--tail N] [--follow] docker logs --tail N [-f] (default 200 lines)
ssh u@host @list the user's apps and containers

Anything else after @ is refused with not your app (exit 1), with no hint about other apps. sftp, scp and every kind of forwarding are off for these users. A container that is privileged or mounts the Docker socket is never opened. Each session writes one auth-log line: app-shell[pid]: user=… app=… container=… action=shell|exec|logs|refused.


Utility

GET /basic-info

Unauthenticated. The bootstrap call — safe to make before anyone is logged in, and the thing that decides whether the app shows a login form or a first-run registration form.

{"basic_info": {
  "registration_open": false,
  "password_policy": {"min_length": 10, "requires_mixed_case": true, "requires_number": true, "requires_symbol": false},
  "app_version": "1.0.0",
  "locales_available": ["en", "es", "de", "fr", "pt", "ja", "ru", "hi"],
  "cookie_auth_enabled": true
}}

registration_open is true only while no user exists at all — it is the first-admin bootstrap, not an open sign-up. Once the first account is created it is false forever and POST /auth/register stops accepting.


GET /timezones

Unauthenticated.

{"timezones": [{"value": "UTC", "label": "UTC"}, {"value": "Europe/London", "label": "London (GMT+1)"}, …]}

?for=php (added 2026-10-08, FS-B8): only the zones PHP's date.timezone accepts — use it for the PHP settings timezone field. The OS list has names PHP refuses (US/Eastern); the PHP field validates against PHP's own list including its backward-compatible names (Etc/UTC, Etc/GMT+5, EST), which it used to refuse.

GET /health

Unauthenticated. Health check for load balancers / uptime monitors. It has its own 60/min limiter and does not consume the authenticated API polling bucket.

Response 200: {"health": {"status": "ok", "version": "1.0.14"}}

version is the version actually detected for the running checkout and may be null when it cannot be established. Panel updates use it after a release swap to verify that the new code is answering.


Central Management Connection

Lets a central panel drive this OSS install over Authorization: Bearer <token>. These three endpoints are the OSS admin's own settings screen and need a normal Sanctum session — they are not the central-facing API.

POST /central/enable

Auth: administrator session

Generates a token, replacing any existing one. Enabling again rotates it and invalidates the old one.

Response 201: {"central_token": "sv_central_…", "message": "…"}

central_token is returned in full only in this creation response, so the frontend can copy it into Central or pass it to an installer. It is never returned again: GET /central/status stays masked.

GET /central/status

Auth: administrator session

Response 200: {"central": {"enabled": true, "token": "sv_central_a***************", "connected": false, "last_used_at": null}}

token is null when enabled is false. Never the raw value.

enabled means a key exists; connected means Central has used it (last_used_at, ISO 8601, updated at most once a minute). Between the two, show "Waiting for Central". Generating a new key sets connected back to false.

DELETE /central

Auth: administrator session

Revokes the current token. Response 200: {"message": "…"}

What the central token may not do: create, edit, delete, re-password, re-role or impersonate panel users, or create/edit/delete roles — 403 with a translated message. It can still list them (GET /admin/users, GET /admin/roles). Who runs the panel is decided on the panel.


Central — Paid Addons (WP Toolkit, Log Monitoring Suite)

Central only. Every route below answers 404 to anyone but Central (administrators included) and is reached with Authorization: Bearer <central token>. The OSS panel does not offer these features itself. The addon binaries (/usr/local/bin/wp-toolkit, /usr/local/bin/insighthub-toolkit) are installed by Central and check their own licence against ServerAvatar on every run — the panel holds no licence logic.

Answers. A synchronous route returns the addon's own JSON unchanged ({"status":"success","message":…, …}, same keys as the v7 agent routes). A route marked queued returns 202 with a run: {"data":{"id","application_id","addon","command","status":"queued|running|succeeded|failed","http_status","result","created_at","started_at","finished_at"}}; poll GET /central/addons/runs/{id} until status is succeeded or failed. result then holds what the synchronous call would have returned, and http_status its status.

Errors — every body has a machine code and a translated message:

Status code Meaning
403 addon_licence_required The addon says this server has not bought it
404 addon_not_installed The binary is not on this server
409 addon_site_not_registered Log Monitoring Suite has no record of this site (call register)
422 addon_command_failed The addon ran and refused; its own error fields are under addon (e.g. addon.message, Log Monitoring Suite's addon.field)
502 addon_bad_output It printed something that is not its JSON
504 addon_timed_out It did not finish (55 s for direct calls, 30 min for queued runs)

Plain 422 with errors is ordinary request validation. WordPress routes on a non-WordPress or not-yet-active site answer 404.

GET /central/addons

{"addons":[{"name":"wp-toolkit","label":"WP Toolkit","installed":true,"version":"…"},{"name":"insighthub-toolkit","label":"Log Monitoring Suite","installed":false,"version":null}]}

GET /central/addons/runs/{run}

A queued run (shape above).

WP Toolkit — /central/addons/applications/{application}/wordpress/…

Runs WP-CLI as the site's owner, on its document root, under the site's own PHP version. {slug} is a plugin/theme slug ([A-Za-z0-9][A-Za-z0-9._-]*). Changes are written to the activity log (application.addon_command); reads are not.

Method Path Body Queued v7 agent route
GET plugins — plugins/list
POST plugins plugin (slug or https URL), activate?, version? ✓ plugins/install
DELETE plugins/{slug} keep_files? plugins/uninstall
POST plugins/{slug}/toggle action: activate|deactivate plugins/toggle
POST plugins/{slug}/update — ✓ plugins/update
POST plugins/update-all — ✓ plugins/update-all
GET themes — themes/list
POST themes theme, activate?, version? ✓ themes/install
DELETE themes/{slug} — themes/uninstall
POST themes/{slug}/activate — themes/activate
POST themes/{slug}/update — ✓ themes/update
POST themes/update-all — ✓ themes/update-all
GET core/version — —
POST core/update version?, minor? (also updates the database) ✓ core-update
POST core/update-db — ✓ database/update
POST core/verify-checksums — (a mismatch is a succeeded run with verified:false) ✓ checksums/verify
GET summary/{part} part: site|users|themes|plugins|cron summary, user/summary, …
POST search-replace search, replace, dry_run?, include_guid? (guid skipped by default) ✓ search-replace
POST rewrite/flush hard? rewrite/flush
POST cache/flush — cache/flush
POST cron/run all? (default: due now) ✓ wp-cron/run
PUT cron enabled wp-cron/toggle
GET debug — debug/info
PUT debug setting: WP_DEBUG|WP_DEBUG_LOG|WP_DEBUG_DISPLAY, value debug/update
GET settings — site-settings/get
PUT settings any of site_language, timezone, date_format, time_format, permalink_structure ("" = plain links), search_engine_visibility, wp_memory_limit, wp_max_memory_limit — only the keys sent are changed site-settings
GET maintenance-mode — maintenance-mode/status
PUT maintenance-mode active maintenance-mode/toggle
GET security — xmlrpc / uploads part of summary
PUT security/{rule} rule: xmlrpc|uploads-php; blocked xmlrpc/toggle, php-execution-upload-directory/toggle

Blueprint — POST …/wordpress/blueprint (queued)

The saved blueprint, sent whole (Central stores blueprints, as v7's backend did): selected_plugins / selected_themes ([{slug, name?, activate?}]), custom_theme_plugins ([{label?, link (https), type: custom-plugin|custom-theme, activate?}]), remove_hello_world, remove_sample_page, delete_all_themes, delete_all_plugins, delete_unneeded_core_file, language, timezone, date_format, time_format, se_indexing_disable, organize_upload_folders, permalink_structure, debug_mode, debug_log, debug_error, and script (≤ 64 KB) — run after every other step as the site's owner, never root, from the WordPress root, 10-minute limit.

The run's result lists every step (steps: [{step, status: ok|failed, message?}], completed, failed) and the script's exit_code, output (last 64 KB), timed_out. A step that fails does not stop the rest; completed is false and the run's status is succeeded (the blueprint ran; read completed). The blueprint, script included, goes to the toolkit on stdin and is stored encrypted only until the run finishes.

Object Cache Pro — …/wordpress/object-cache-pro

Method Path Body
GET object-cache-pro — {"object_cache_pro":{"active","configured","version"}}
POST object-cache-pro token, plugin_url (https), optional prefetch, split_alloptions, strict, debug, maxttl, timeout, read_timeout queued (downloads the plugin)
POST object-cache-pro/rotate — new Redis password, written into the site
DELETE object-cache-pro — removes the drop-in, plugin and settings, then the site's Redis login

The Redis connection is not taken from the caller: the panel gives each site its own Redis 6 ACL user, sv_site_<id>, that can reach only keys under sv<id>:, with no channels and no administrative or dangerous commands — so one site cannot read another's cache or the panel's own data in the same Redis. (v7 opened the Redis socket to every account with unixsocketperm 777.) Rotation adds the new password before removing the old, so the site is never locked out, and withdraws it if the site could not take it. Refused with 409 object_cache_redis_unavailable when Redis is not running, is older than 6, or has no password (without one, any site could log in as the all-powerful default user).

security writes web server rules into the site's root-owned rules directory (/etc/panel-site-rules/<slug>), which survive every domain, certificate and HTTPS change.

Log Monitoring Suite — access-log analytics

The product launched as InsightHub; the binary, its repository and its database keep that name (insighthub-toolkit).

Method Path Notes
GET /central/addons/log-monitoring/applications Every site Log Monitoring Suite knows
POST /central/addons/applications/{application}/log-monitoring/register Registers the site (key v8-<id>) and stores its id. Safe to repeat: an earlier registration of the same site is adopted. A name/domain held by another site → 422 addon_command_failed, addon.field says which.
DELETE /central/addons/applications/{application}/log-monitoring/register Unregisters it. Deleting the site does this too.
GET /central/addons/applications/{application}/log-monitoring/{group}/{report} Every report insighthub-agent had, on its own paths (below). ?limit=1..1000 on lists. Cached 5 minutes; errors are not cached.

Reports (the agent's route names; answers keep its keys — a single object under data, lists with per_page / total / data):

Group Reports
dashboard stats, top-records, daily-log-count, top-url-count, method-count, bot-count, device-type-count, url-method-status-count, status-count, country-count, mimetype-bandwidth-sum, url-mimetype-bandwidth-sum, bot-vs-human
traffic summary, daily-request-count, top-ips-by-url, top-ips-by-country, latest-access-logs (default 100 rows), referer-count, url-and-field-count (?field= an access-log column, required), sitemap-url, url-and-method-count, url-and-status-count
errors stats, error-rate, error-code-breakdown, status-code-data (?status_code= 1xx…5xx, required), bot-vs-human, status-summary, status-code-trends, referer-error-logs
bots summary, bot-traffic-trends, url-status-count, traffic-distribution, most-crawled-urls, traffic-by-ip, traffic-percentage
user-agents summary, breakdown, popular, url-request-count, os-pie-chart
bandwidth summary, trends, bot-vs-human, high-usage-urls, by-file-type, top-ips

Fixed vs the agent (numbers can differ from v7 on these): dashboard/top-records ranks the top IP by requests (v7 always reported 1); dashboard/bot-count counts bots (v7 counted humans); bots/traffic-distribution is ordered by count; traffic/latest-access-logs returns at most 100 rows unless limit says otherwise (v7 returned every row); percentages on a site with no traffic are 0 (v7 failed).

Incoming Deploy Webhooks

POST /webhooks/deploy/{identifier}

Unauthenticated by design. This is the URL GitHub/GitLab/Bitbucket call; they carry no session or token, so the HMAC signature over the raw body is the credential. Configure it with PUT /applications/{application}/webhook, which returns the identifier for the path.

Not a frontend endpoint — documented so it isn't mistaken for a gap. The general throttle:api guest limiter is deliberately removed (a provider delivers from shared egress and would trip a per-IP limit); a limiter keyed on the webhook itself applies instead.

Response 202: {"deployed": true, "reason": null} — accepted; a deploy may still be declined for a reason such as a non-matching branch, in which case deployed is false and reason says why.

Response 401: {"deployed": false, "reason": "invalid_signature"}

Response 410 — deploy-on-push is switched off in the panel, and the delivery is authentic (valid signature for the stored secret): {"deployed": false, "reason": "webhook_disabled", "message": "Deploy on push is turned off for this site in the panel…"}. Nothing is deployed. Meant for the provider's own delivery log, so whoever reads it knows why; a non-2xx on purpose, so the provider counts the hook as failing (GitLab disables it after a few).

A disabled webhook receiving a delivery without a valid signature, and an identifier that never existed, both answer 404, identically — anything else would confirm which applications exist.


Admin API Error Logs

GET /admin/error-logs

Auth: administrator session or token. Returns failed API requests and server operations from the rotating structured server-ops log; it is file-backed, not database-backed.

Query: lines integer, optional, 1–500 (default 100); reference UUID, optional (exact lookup).

Response 200: {"error_logs":[{"occurred_at":"…","status":500,"method":"POST","route":"api/applications/{application}","exception":"…","message":"Undefined method foo() on App\\Models\\Application","file":"app/Services/Server/Foo.php:212","trace":["app/Http/Controllers/API/Server/Bar.php:44"],"reference":"…","user_id":1,"feature":null,"operation":null,"exit_code":null,"command":null,"duration_ms":null,"attempts":null,"error":null}],"meta":{"truncated":false}}

message is the exception's own message, redacted and capped at 500 characters — not a fixed string. file is path:line relative to the install, and trace is up to five frames inside the application with vendor frames dropped. Entries written before this shipped have neither field.

Server-operation records use feature, operation, exit_code, command, duration_ms, attempts, and a redacted, 1,000-character error summary to make a support reference actionable. command is the command line that failed, redacted where it was written rather than here. attempts and duration_ms separate a lock retried three times over twelve seconds from something that died once in 40ms — a distinction the timestamps cannot make. The summary uses non-empty stderr first and falls back to stdout because installers and other CLI tools often report failures there; it remains null when neither stream contains output. API failures use the existing fields. Validation, authentication, authorization, and not-found responses are excluded. Entries never expose request bodies, credentials, cookies, tokens, SQL bindings, unredacted command output, or stack traces. The log rotates automatically and retains 30 days by default.

Background failures (2026-10-05, ERR-02): failed queued jobs and failed scheduled tasks are listed too — operation job.failed / schedule.failed, message "A background job failed." / "A scheduled task failed.", command the job class or the command, exit_code for a command, error the cause. An API 500 entry now uses the same reference the user was shown.


Activity Log Types & Verbs

Activity entries are written for every mutation. type and action are separate columns. Use GET /admin/activity-log/filters to get the live catalog — this is only a reference index.

type action
user registered, logged_in, password_changed, impersonation_started, impersonation_stopped
role created, updated, deleted
system_user created, deleted, sudo_toggled, shell_changed, ssh_access_changed, ssh_key_added, ssh_key_removed
application created, updated, deleted, provisioned, provision_failed, deployed, deploy_failed, disabled, enabled, domain_added, domain_removed, certificate_issued, certificate_uploaded, certificate_deleted, file_edited, file_deleted, directory_created, permissions_fixed, php_isolated, php_unisolated, php_settings_updated, environment_updated, environment_restored, worker_created, worker_updated, worker_deleted, worker_started, worker_stopped, worker_restarted, deploy_script_updated, deploy_settings_updated, staging_created, staging_pushed
database created, deleted, user_created, user_updated, user_deleted, export_queued, export_completed, export_failed, export_deleted, imported
backup configured, run, completed, failed, downloaded
disk_cleaner cleaned, auto_cleaned, clean_failed, auto_clean_failed, schedule_updated
cronjob created, updated, deleted, create_failed
service started, stopped, restarted, reloaded, enabled, disabled, config_test
fail2ban installed, updated, ban_added, ban_removed, all_bans_removed
firewall rule_added, rule_updated, rule_removed, rule_enabled, rule_disabled, enabled, disabled
git_account connected, updated, test_passed, test_failed, disconnected
node install_started, uninstalled, npm_updated, default_changed
setting updated, reboot_requested, reboot_cancelled, reboot_scheduled, reboot_schedule_removed
panel_update started, completed, failed