Backends
An app’s backend is a Docker Compose file in the package that the daemon checks against an allowlist, rewrites and runs. Browsers reach it only through the daemon’s proxy, every request carries the signed-in user’s identity in headers the proxy sets, and the containers run on a private network, with their own user id, on the pool the administrator chose.
Compose subset
Section titled “Compose subset”The file is backend/compose.yaml. The rules are in sdk/schema/compose.v1.rules.json, shared by the CLI and the daemon, so pithyx validate gives the box’s answer. A key that is not on the list is refused (app.compose.key_unsupported). The box rewrites the file before it runs it.
Limits: 65,536 bytes per file, nesting depth 10, 256 entries, 16 services, 32 volumes, memory limits from 6 MiB to 256 GiB, at most 256 CPUs. YAML anchors, aliases, tags, merge keys, duplicate keys and x-* keys are refused.
Top level: services (required) and volumes.
Per service:
imageis required and pinned by digest (name@sha256:...).- Allowed:
command,entrypoint,environment,working_dir,volumes,tmpfs,depends_on,healthcheck,expose,read_only,init,stop_signal,stop_grace_period,shm_size,mem_limit,cpus,cap_dropandlabels(not with the prefixescom.pithyxorcom.docker). - Needs an elevated right, which this version does not grant (refused with
app.elevated.unsupported):network_mode: host,pid: host,devices,cap_addandprivileged. - Mounts are named volumes or
tmpfs. Bind mounts are refused because host paths are never mounted. The targets/proc,/sysand/devand the Docker sockets are refused. - Refused with a reason in the error text:
ports(backends are reached only through the proxy),build(the box never builds),networks,links,env_file,secrets,configs,user(the box sets the user id),restart(the box runs the lifecycle),logging,deploy(usemem_limitandcpus),security_opt,sysctls,ulimits,container_name,extends,profilesand more.
Source form. During development you may keep backend/compose.dev.yaml, which follows the same rules plus build: (a string or context, dockerfile, target, args). Each service has exactly one of image or build. pithyx build turns it into compose.yaml and images.lock.json. The box never reads the dev file.
Image lock. backend/images.lock.json lists, for each image (up to 16), the manifest and config digests for amd64 and arm64. The box checks every image it loads against the config digest (app.image.mismatch). A missing or stale lock is app.package.lock_invalid. Images come from backend/images/<arch>/<digest>.tar in the package or are pulled by digest.
What the box adds when it rewrites the file:
user: <uid>:<uid>(or0:0withbackend.root), and the group ids of mapped shared folders,- a private network for the app, and a second one out of the box only if the app has
network.internetornetwork.lan(for the services inbackend.network, all without it), cap_drop: [ALL]for every service unless the app runs as root (backend.root), which keeps Docker’s default set; your owncap_dropis replaced,restart: "no"(the daemon starts and stops the backend), logging to journald,pids_limit: 1024,oom_score_adj: 500,no-new-privileges,- labels
com.pithyx.app,com.pithyx.serviceandcom.pithyx.version, - named volumes as folders under
<pool>/.pithyx/apps/<id>/volumes/<name>, and mapped shared folders as bind mounts (read-only if the administrator granted less).
Endpoints and routes
Section titled “Endpoints and routes”An endpoint maps a name to a service and a container port in the manifest. The route never contains the port:
- endpoint
api:/apps/<id>/api/... - endpoint
<name>:/apps/<id>/api-<name>/...
The path after the prefix is forwarded. The SDK offers app.backend.fetch(path, { endpoint }), url, socket and token; see @pithyx/sdk.
Authentication at the proxy. HTTP requests carry Authorization: Bearer <token> or ?pithyx-token=<token>. WebSockets carry a one-time ?pithyx-ticket=<ticket> for that endpoint. A cookie never authenticates. A token for another app, or a bad one, gets 401 app.token.invalid.
Behavior:
- A browser preflight (
OPTIONSwithOrigin: null) is answered withAccess-Control-Allow-Origin: *; other origins get 403. - Request bodies have no size limit at the proxy; the backend decides.
- The connect timeout is 5 seconds. Idle WebSockets are kept for up to 24 hours of inactivity.
- If the backend is stopped or starting, the request wakes it and waits up to 30 seconds. If it is not running, updating or unavailable, the answer is 503
app.backend.unavailable. - The proxy removes headers that would be dangerous from the answer (for example
Set-Cookie,Clear-Site-Data, HSTS) and adds the sandbox headers.
Who is calling
Section titled “Who is calling”The proxy strips Cookie, Authorization, Proxy-Authorization, Referer, Forwarded, any X-Pithyx-* and X-Forwarded-* header and the pithyx-token and pithyx-ticket query parameters from the incoming request. It then sets:
| Header | Value |
|---|---|
X-Pithyx-User |
the user name, the Linux user name in lower case |
X-Pithyx-User-Id |
the user id: the Linux uid, the same as context.user.id and DirectoryUser.id, never given to another user |
X-Pithyx-Groups |
the group names, comma separated; empty for a user in no group |
X-Pithyx-Admin |
true or false |
traceparent |
the trace of the request |
X-Forwarded-Prefix |
/apps/<id>/api or /apps/<id>/api-<name> |
X-Forwarded-For, -Host, -Proto |
the original client and host |
An incoming traceparent or tracestate is dropped as well: the proxy writes the trace of its own request, so a window cannot choose it. Every endpoint and every WebSocket upgrade gets the same headers. A backend can trust these headers only because it is reachable solely through the proxy.
Time limits of the proxy. It gives up connecting to a backend after 5 seconds and waits up to 100 seconds for each read of the answer (a long-running answer that keeps sending stays open); then the window gets 502 or 504. An idle WebSocket stays open for 24 hours.
Schedules. A schedule’s call does not come through the proxy and has no user: the daemon sends POST <path> with an empty body and only X-Pithyx-Schedule: <schedule id> and traceparent, without any user header. Because the proxy strips every incoming X-Pithyx-* header, a request from a window can never carry X-Pithyx-Schedule, so a backend can rely on it: accept a schedule path only with that header, and answer it without one with 403. A run shows in the Log Center (source daemon, with its status code), not in the job list.
Tokens. The desktop requests them with POST /api/apps/{appId}/tokens (cookie and CSRF header). A token lasts 5 minutes, has 256 random bits and is bound to the user, session, app and window. host tokens work only for /api/app/**, backend tokens only for the proxy. A ticket is valid for 30 seconds and one use. Every use checks the session, user and grant again; revoking a permission or signing out closes proxied sockets. Tokens and tickets are masked in logs.
Storage
Section titled “Storage”- Each app has a dataset on the app pool the administrator picked at the first install:
/<pool>/.pithyx/apps/<id>/withversions/<version>,volumes/and run files. Docker’s own data is on the same pool. Nothing is stored on the system disk. - Declare top-level
volumes:in Compose and mount them by name; they become folders owned by the app’s user id. - Manifest
storagerequests appear as a Compose volume with the request’sid. An optional request that nobody mapped becomes a folder inside the app’s dataset. A required, unmapped request stops the backend from starting; the window then seesunavailablewith the reasonstorage. - If the pool is locked or missing, the backend does not start, the Store says why, and the window sees
unavailablewith the reasonpool. - An update snapshots the dataset; a rollback restores it. On uninstall the administrator chooses whether to keep the data.
- The SDK’s
app.storage(per user key/value) is separate: keys up to 128 characters, values up to 64 KB, 1 MB per user and app.
A storage request in the manifest and its volume in Compose, for one service:
"storage": [{ "id": "clips", "name": { "en": "Clips" }, "access": "readWrite", "required": true, "services": ["api"] }]services: api: volumes: - clips:/clipsvolumes: clips:What the backend sees in such a folder:
- It cannot tell mapped from unmapped. An optional request nobody mapped is a folder inside the app’s dataset, and the service sees an empty or app-owned folder either way. Only the window knows (
app.context.storage,storageChange); a backend that must know asks its frontend or keeps a marker file of its own. - Mapping later starts over. When an administrator maps an optional request later, the backend restarts with the new folder. What it wrote into the internal folder stays there and is not moved; copy it over yourself if it matters.
- Files belong to the app and follow the shared folder’s rights. A file the backend writes into a mapped folder is owned by the app’s user id and inherits the folder’s default ACL, so users with write access to the shared folder can change and delete it over SMB, SFTP, WebDAV and FTP. Never keep the app’s own state in a mapped folder; use a named volume for that.
Network
Section titled “Network”- Each app gets its own internal Docker bridge network without internet. Services reach each other by service name.
- With
network.internetornetwork.lan, the services named in the manifest’sbackend.network.services(every service without it) also join a second, non-internal network. Keep the others off it: in an app that fetches pages, only the fetching service needs the way out. - Where that network leads is up to the box’s firewall: with
network.internetto the internet, withnetwork.lanto the local network (private, shared and link-local ranges:10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,169.254.0.0/16,fc00::/7,fe80::/10, plus loopback and multicast), with both to both.network.internetalone does not reach your router, printer or other servers. DNS to the box’s resolvers always works, so names resolve withoutnetwork.lan. - An app that fetches addresses users type in should still check them: with
network.lana user could point it at any device in the network. - No ports are published on the host, and an nftables rule drops new connections from the app networks to the host, also with
network.lan. - Each app has its own user id (and group id) from 40000 to 49999. With
root: truethe user inside the container is root, but user namespace remapping keeps it unprivileged on the host. - Hardening is always on:
no-new-privileges, a process limit of 1024, a raised out-of-memory score, and no Linux capabilities for services that do not run as root. - Docker socket and
privileged(the rightsdocker.socketandhost.full) are refused in this version. Host networking, host PID, devices and capabilities need the matching elevated right, which the install dialog shows.
Lifecycle
Section titled “Lifecycle”- always: runs whenever it can.
- onDemand: runs while a user holds a window lease, a request or a schedule holds it, and for
idleTimeoutseconds (default 600) after the last one. The desktop subscribes to the topicapps/<id>/contextonce per app and tab; closing the last window ends the lease at once, and a lost connection keeps it for 2 minutes. - Installing, updating or enabling an app starts an
onDemandbackend once, so a broken backend fails the install; it then idles out afteridleTimeoutlike after a closed window. - A failed start is retried after 1 minute; opening a window retries at once.
- Start timeouts: Compose
up --waitwaits up to 10 minutes, then the manifest’s health check up to 60 seconds. - Stop:
docker compose down --timeout 30. Containers get theirstop_signal(SIGTERM by default) and are killed after 30 seconds; the timeout replaces astop_grace_periodof the Compose file. Volumes and images stay. - The health call is a plain
GET <path>on its endpoint without user headers. Each try may take 5 seconds; it runs only while the backend starts, not afterwards. - Health: prefer the manifest’s
health(an HTTP check the daemon makes, every 100 ms while the backend starts) to a Composehealthcheck. A Compose check starts a process in the container at every interval, andup --waitwaits for its first pass. Measured with a small Node backend on the box: a cold start took 2.5 seconds with a Node health check every second duringstart_periodand 1.05 seconds with the manifest’shealthonly; running idle, the Compose check cost about 2 percent of a core (in the container and in Docker), without it about 0.15 percent.pithyx validatewarns about a Compose healthcheck next tohealth(cli.compose.healthcheck_redundant). An image’s ownHEALTHCHECKalso runs; switch it off withhealthcheck: { disable: true }. - A browser on the box itself sees every backend start as a change of the network (Docker attaches the container to the app’s bridge), and Chromium aborts the requests in flight with
net::ERR_NETWORK_CHANGED. Browsers on other devices do not see it. Load again whenbackendStatusbecomesrunning(see the edge cases of the backend guide). - Schedules wake the backend without a window, call
POST path(holding the backend for up to 10 minutes) and then let it idle. - The window sees the status
stopped,starting,running,updatingorunavailable, with the reasonstorageorpoolwhen it cannot start (SDK eventbackendStatus). - Docker starts only while a backend app is installed and its pool is mounted.
Pithyx has no keep-alive lease for background work; use always or schedules.