# The shape of an external Nextcloud app

> Nextcloud is a PHP monolith. Its most interesting new apps aren't PHP. Here's the small, surprisingly elegant protocol that makes that work — and what a minimal Python app looks like on disk.

*Companion piece: [Designing access for Talk recordings](talk-recording.md).*

---

## 01 · The premise

Nextcloud is, at its center, a PHP server with a plugin system where every "app" is a folder of PHP under `apps/`. That model works fine for a calendar or a bookmarks manager. It falls apart the moment the "app" is really a Whisper transcription service, a llama.cpp inference endpoint, or anything that wants Python, CUDA, a long-running worker, or a native binary that has no business living inside a PHP process.

**AppAPI** is Nextcloud's answer. Instead of loading the new app into PHP, Nextcloud runs it as an external HTTP service — an *external app*, usually shortened to *ExApp* — and treats a small XML manifest as the contract between host and app. From Nextcloud's perspective the ExApp is a black-box HTTP server somewhere on the network. From the developer's perspective it's a Docker container with a manifest. The protocol between the two is small enough to fit in your head, which is the part worth explaining.

## 02 · The proxy and the tunnel

```
  ┌─────────┐       ┌────────────┐       ┌──────┐       ┌────────┐
  │ Browser │──────▶│ Nextcloud  │──────▶│ HaRP │──────▶│ ExApp  │
  │         │       │  PHP proxy │       │ frpc │       │ your   │
  │         │       │   + ACL    │       │ ↔frps│       │ ctnr   │
  └─────────┘       └────────────┘       └──────┘       └────────┘
       │  HTTPS         │ AUTH-APP-API     │ HTTP             │
       │  + cookie      │ b64(user:secret) │ over tunnel      │
       │                │                                     │
       └ ─ ─ response ─ ┴ ─ ─ response ─ ─ ─ ─ ─ ─ response ─ ┘

         ┌── outbound: OCS / WebDAV as the app, ──┐
         │   user id passed as parameter          │
         ▼                                        │
      Nextcloud                                   ExApp
```

One proxied request, end to end. The user's Nextcloud session ends at the PHP proxy; what reaches the container is a fresh AppAPI header set anchored on `AUTHORIZATION-APP-API`. Outbound calls travel back as the app *itself*, with the user id passed as a parameter.

Two directions worth separating:

- **Inbound:** user-facing requests arrive at Nextcloud, get authenticated against a Nextcloud session, then are proxied to your container with a fresh set of AppAPI headers that re-encode who the user is.
- **Outbound:** your container can call back into Nextcloud — make API calls, write files into a user's storage, post notifications — using app-scoped credentials AppAPI hands it on startup. It does not impersonate the user; it acts as itself and passes the user ID as a parameter.

Both directions are authenticated. Neither requires your app to know anything about Nextcloud's PHP internals. You speak HTTP; AppAPI does the bridging.

## 03 · The manifest is the contract

Every app ships an `appinfo/info.xml`. For a normal PHP app it describes metadata and migrations. For an external app it does something more interesting: it declares the container image and the routes Nextcloud is allowed to forward to it. Here is the real one from the [Python skeleton](https://github.com/nextcloud/app-skeleton-python/blob/main/appinfo/info.xml):

```xml
<info>
  <id>app-skeleton-python</id>
  <name>App Skeleton</name>
  <version>3.0.3</version>
  <dependencies>
    <nextcloud min-version="31" max-version="35"/>
  </dependencies>

  <external-app>
    <docker-install>
      <registry>ghcr.io</registry>
      <image>nextcloud/app-skeleton-python</image>
      <image-tag>latest</image-tag>
    </docker-install>

    <routes>
      <route>
        <url>^/public$</url>
        <verb>GET</verb>
        <access_level>PUBLIC</access_level>
      </route>
      <route>
        <url>^/admin$</url>
        <verb>GET</verb>
        <access_level>ADMIN</access_level>
      </route>
    </routes>
  </external-app>
</info>
```

The interesting bit is `<access_level>`. Three values exist — `PUBLIC`, `USER`, `ADMIN`[^2] — and they are *enforced by Nextcloud before your container ever sees the request*. When a request reaches your `/admin` handler through the AppAPI proxy, an admin check has already happened. You do not write that check in your handler. It's declarative, in XML, and Nextcloud's PHP proxy refuses to forward anything that doesn't match a declared route — undeclared routes 404 at Nextcloud, not at your app.

> This is the part that should land for anyone who has built plugin systems before: the security boundary is not a function call inside your app. It's the proxy in front of it, configured by a manifest your app ships alongside its code.

## 04 · How the network actually works

Production Nextcloud rarely runs in the same network namespace as your container. It might be on a different host, behind a NAT, in a different DC. So the question is: how does `https://cloud.example.org` reliably reach a Docker container that might be sitting on someone's laptop behind a router?

Older versions used a daemon called **DSP** (Docker Socket Proxy). The current answer is **HaRP**, and once you see what's inside the skeleton's [`start.sh`](https://github.com/nextcloud/app-skeleton-python/blob/main/start.sh), the mechanism is obvious:

```bash
# start.sh (excerpt)
cat <<EOF > /frpc.toml
serverAddr = "$HP_FRP_ADDRESS"
serverPort = $HP_FRP_PORT
transport.tls.enable = true
metadatas.token = "$HP_SHARED_KEY"

[[proxies]]
remotePort = $APP_PORT
type = "tcp"
name = "$APP_ID"
[proxies.plugin]
type = "unix_domain_socket"
unixPath = "/tmp/exapp.sock"
EOF

frpc -c /frpc.toml &
exec "$@"
```

`frpc` is the client from [FRP](https://github.com/fatedier/frp) (Fast Reverse Proxy), a Go project by *fatedier*. The ExApp container bundles `frpc` alongside the app process; `frpc` dials *out* to an `frps` server ([HaRP](https://github.com/nextcloud/HaRP)) that Nextcloud knows how to reach, and registers a tunnel. From that moment, Nextcloud's PHP code can post HTTP to a local-looking address and traffic emerges inside your container. The skeleton's `start.sh` ties the FRP endpoint to a Unix domain socket at `/tmp/exapp.sock` — that's a skeleton-level choice, not a protocol invariant. When `/certs/frp` is mounted, the tunnel runs with mutual TLS; if not, `start.sh` falls back to plain TLS-disabled FRP secured only by the shared token.

Two consequences:

- Your app does not need to be reachable on a public IP. It opens an outbound connection. Behind NAT, behind a firewall, anywhere.
- The tunnel is the *reachability* boundary: random clients on the internet cannot dial your container, because there is nothing inbound to dial. The authorization boundary stays with the proxy and the AppAPI auth header.

## 05 · What the app code looks like

Strip away the boilerplate and a Nextcloud external app in Python is just a FastAPI app with one middleware and one helper:

```python
from fastapi import FastAPI
from nc_py_api import NextcloudApp
from nc_py_api.ex_app import AppAPIAuthMiddleware, run_app, set_handlers

APP = FastAPI(lifespan=lifespan)
APP.add_middleware(AppAPIAuthMiddleware)

@APP.get("/admin")
async def admin_get(request):
    return "Admin page!"

def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
    # Called by Nextcloud when the admin enables/disables the app.
    # Return "" on success, an error string to block the toggle.
    return ""
```

Four things are doing real work:

- **AppAPIAuthMiddleware** — Verifies the `AUTHORIZATION-APP-API` header on every proxied request. The header carries `base64(userId:appSecret)`[^1] — Nextcloud writes it, the middleware recomputes against the shared secret, mismatch is 401. That check is what lets you trust the user ID.
- **NextcloudApp()** — The reverse channel. Picks up credentials from env vars on startup and gives you a client for files, users, notifications, OCS, the whole API surface — as the app, not as a user.
- **set_handlers** — The lifecycle hooks. AppAPI POSTs to a special init endpoint when the app is enabled, disabled, or updated. You get a chance to migrate, register capabilities, or refuse.
- **run_app** — A thin wrapper around `uvicorn.run` that respects `APP_HOST` and `APP_PORT` from the environment, so the container binds where AppAPI expects.

## 06 · The file layout, annotated

Once you know the protocol, the [skeleton repo](https://github.com/nextcloud/app-skeleton-python) reads as exactly the minimum needed to satisfy it:

```
app-skeleton-python/
├── appinfo/
│   └── info.xml          ← the manifest. routes, image, env vars, version range.
├── ex_app/
│   └── lib/
│       └── main.py       ← your actual app. FastAPI here, but it's just HTTP.
├── Dockerfile            ← multi-stage build. python:3.12-slim + frpc binary.
├── start.sh              ← writes frpc.toml from env, launches frpc, execs the app.
├── healthcheck.sh        ← Docker HEALTHCHECK: is frpc alive?
├── requirements.txt      ← fastapi, nc_py_api, uvicorn.
└── krankerl.toml         ← packaging metadata for the Nextcloud app store.
```

Notice what isn't there: no PHP, no Composer, no `lib/Controller/*.php`, no routes.php, no service container. The app store knows how to ingest this because `info.xml` contains an `<external-app>` block; the AppAPI machinery on the Nextcloud side does the rest.

## 07 · What the manifest deliberately doesn't say

A natural next question, especially if you're thinking about shipping an ML app: can `info.xml` declare that the container needs a GPU? Or express a preference for one? The answer is *no, and on purpose.*

GPU support in AppAPI is a property of the **deploy daemon**, not of the app. A deploy daemon is the thing on the Nextcloud admin's side that actually runs your container — usually a Docker or Podman endpoint, sometimes a Kubernetes namespace. When an admin registers a daemon, they tag it with one of three compute devices: `cpu`, `cuda` (NVIDIA), or `rocm` (AMD). The app publishes image variants; the admin registers a daemon with a device class; AppAPI joins those two facts at install time.

At install time, AppAPI reads the daemon's compute device and does two things on your behalf:

- **It wires the container's device access correctly.** For `cuda` it sets `HostConfig.DeviceRequests` on the Docker create call, or — on Podman 5.4+ — uses CDI selectors (default `nvidia.com/gpu=all`). For `rocm` it bind-mounts `/dev/kfd` and `/dev/dri` into the container.[^5] Your app doesn't ask for any of this; the daemon's tag is enough for AppAPI to do the right thing with the container runtime.
- **It rewrites the image tag.** If your manifest names the image `ghcr.io/you/yourapp:latest` and the daemon is tagged `cuda`, AppAPI pulls `ghcr.io/you/yourapp:latest-cuda` instead. The convention, by which an app advertises GPU support, is simply: publish multiple variants of the same image (`latest`, `latest-cuda`, `latest-rocm`) and AppAPI will reach for the right one based on the host.

At runtime your container also receives a `COMPUTE_DEVICE` environment variable — `CPU`, `CUDA`, or `ROCM`[^6] — so a single codebase can branch on what the host actually gave it (load the CUDA wheels, load the ROCm wheels, fall back to CPU).

What this means in practice: there is no `<gpu required="true"/>` or `<compute-device>cuda</compute-device>` element to put in your manifest, and there is no scheduling layer that will refuse to install your app on a CPU-only host. Hardware requirements live in your README and in the image variants you do or don't publish. The decision of which daemon (and therefore which device class) hosts your app is the Nextcloud admin's, made at install time. The daemon is the only party that knows what hardware is present; the app only knows what it would like. Keeping compute-device resolution on the daemon side lets one unchanged `info.xml` install onto a CPU-only test box, a CUDA workstation, and a ROCm server — the daemon picks the matching image variant on each.

## 08 · Can you put an existing app behind this?

Given that everything above is "it's just HTTP," a reasonable question: if I already have a Flask app, a Next.js server, a Rails app, a static SPA with a backend — can I drop it into a container, point AppAPI at it, and have it work? *Almost.* Five places an unmodified app will collide with AppAPI's assumptions:

### Routes are an allowlist, not a default-allow

Anything not declared in `info.xml` with a matching regex and verb is rejected at the proxy. If your app exposes `/api/v1/anything`, you either enumerate it (fine for a few endpoints) or declare a broad catch-all like `^/.*$` at a single access level (fine if your whole app has one trust tier).

### Bind address and port are dictated

Your server must listen on `$APP_HOST:$APP_PORT`, the values AppAPI injects on startup. Most frameworks expose these as flags or env vars; [`run_app`](https://github.com/cloud-py-api/nc_py_api/blob/main/nc_py_api/ex_app/uvicorn_fastapi.py) in nc_py_api does it for you, but anything else needs a one-line wrapper.

### Enabling the app is a synchronous handshake

When an admin flips your app to "enabled," Nextcloud makes a real HTTP request to your container and waits for a 200. If you don't answer, the toggle fails and the app stays disabled.

You need two routes: `PUT /enabled` (called on enable/disable) and `POST /init` (called once after install, for things like downloading models).[^3] The Python skeleton wires both up via `set_handlers(app, enabled_handler)`; in any other language you write them yourself.

### Cookies don't reach you

AppAPI's proxy strips the user's Nextcloud session cookie before forwarding. By the time the request hits your container, the cookie jar is empty — whatever `request.session` mechanism your framework relies on will find nothing.

### Identity arrives in a header, but not the one you might expect

Nextcloud re-encodes the authenticated user into a small set of AppAPI headers and attaches them to the proxied request.[^1] The important one is `AUTHORIZATION-APP-API`, which carries `base64(userId:appSecret)`. That single header doubles as identity *and* auth: only Nextcloud and your container know the shared secret, so a matching base64 string proves both that the request came from Nextcloud and which user it's for.

The remaining headers are scaffolding: `EX-APP-ID` and `EX-APP-VERSION` name the target app, `AA-VERSION` names the AppAPI version, `AA-REQUEST-ID` is a trace ID. There is no separate cryptographic signature header — the scheme is a shared-secret bearer token, not a signed envelope. Your "current user" plumbing needs to be redirected to decode `AUTHORIZATION-APP-API` rather than read a cookie.

### Outbound calls go out as the app, not the user

When *your* app calls back into Nextcloud, you are not impersonating anyone. AppAPI gives the container its own credentials at startup, and outbound requests are authenticated as the app itself, with the user ID you want to act on behalf of passed as a parameter. The user is no longer a session; they're a string you supply.

---

So the realistic answer splits along one axis: how much identity does the app handle itself? A static site or a pure-API backend with no login flow of its own can be wrapped in a thin adapter — a manifest, a `start.sh` that binds to `$APP_HOST:$APP_PORT`, and a tiny handler for the lifecycle ping — and run essentially unmodified. A full app with its own login screen, session cookies, and per-user database state has to make a decision: keep its own auth (and confuse users who are already logged into Nextcloud), or rip it out and trust the user ID decoded from `AUTHORIZATION-APP-API` as the source of truth. The HTTP protocol won't fight you. The identity model will, until you pick one.

One bit of architectural good news for "lift and shift" attempts: with HaRP, the only thing on the outside of your app's listening socket is `frpc`, and the only thing `frpc` is connected to is the Nextcloud-side proxy. The public internet cannot reach your container directly. That narrows the threat surface, but it does not make verifying `AUTHORIZATION-APP-API` optional: a compromised deploy daemon, a co-tenant container that can dial your listening socket, or a misconfigured port exposure would let an attacker forge the base64 bearer header. Treat the middleware as required, not as defense-in-depth.

## 09 · Why this design is interesting

The boring version of "let people write plugins in any language" is RPC over stdio (the LSP approach) or a sidecar pattern with a shared socket. AppAPI is something stranger: it's a manifest-driven HTTP proxy whose authorization rules live in XML, fronted by a reverse tunnel so the plugin can run literally anywhere, with a bidirectional client library so the plugin can call back into the host as a first-class actor.

A few properties fall out of that:

- **Language is free.** The protocol is plain HTTP plus a handful of headers; any runtime that can serve HTTP works. The [Python skeleton](https://github.com/nextcloud/app-skeleton-python) is the one officially maintained starting point, and the in-tree client library is [nc_py_api](https://github.com/cloud-py-api/nc_py_api).
- **Heavy workloads stop being awkward.** The AI-flavoured Nextcloud apps — [context_chat](https://github.com/nextcloud/context_chat), [llm2](https://github.com/nextcloud/llm2), [stt_whisper2](https://github.com/nextcloud/stt_whisper2), [Visionatrix](https://github.com/cloud-py-api/visionatrix), and the meeting recorder [nextcloud-talk-recording](https://github.com/nextcloud/nextcloud-talk-recording) — are all ExApps. They get to use the right runtime for the job — Torch, ONNX, llama.cpp, gstreamer — without contaminating the PHP server with native deps.
- **Deployment is the admin's problem, not the developer's.** The Nextcloud admin UI can pull and start the container; you ship an image to a registry and a version range in XML.
- **The security model is centralized.** Per-route access levels in the manifest and a shared-secret bearer scheme at the proxy mean the PHP side stays the policy enforcement point. You can audit it once.

If you've spent time on plugin architectures, the move worth noticing is the inversion. The plugin is not loaded into the host's process and granted host capabilities; it runs as a peer process and is granted a tunnel back. The host doesn't execute the plugin's code — it *enforces* the policy the plugin declared in its manifest, and treats the plugin as an external HTTP service. The plugin in turn trusts only what the shared secret can prove.

That's the narrow waist of AppAPI: XML for authority, one header for identity, HTTP for everything else. The interesting move is not that Nextcloud can run Docker containers. It's that the host stays the policy engine while the app gets to be an ordinary HTTP service in whatever language it wants — and HTTP is something every language already knows how to do.

---

## Sources

[^1]: AppAPI proxy headers — the canonical set `AA-VERSION`, `EX-APP-ID`, `EX-APP-VERSION`, `AUTHORIZATION-APP-API`, `AA-REQUEST-ID` is constructed in [`app_api/lib/Service/AppAPICommonService.php`](https://github.com/nextcloud/app_api/blob/main/lib/Service/AppAPICommonService.php). The Python-side verification lives in [`nc_py_api/_session.py` (sign_check)](https://github.com/cloud-py-api/nc_py_api/blob/main/nc_py_api/_session.py).

[^2]: Three access levels enforced server-side: see the `match` in [`ExAppProxyController.php` (`passesExAppProxyRouteAccessLevelCheck`)](https://github.com/nextcloud/app_api/blob/main/lib/Controller/ExAppProxyController.php) — exactly `PUBLIC`, `USER`, `ADMIN`, with everything else falling through to `false`. There is no `GUEST` level in production.

[^3]: `set_handlers` wires up both `PUT /enabled` and `POST /init`: see [`nc_py_api/ex_app/integration_fastapi.py`](https://github.com/cloud-py-api/nc_py_api/blob/main/nc_py_api/ex_app/integration_fastapi.py).

[^4]: HaRP — [nextcloud/HaRP](https://github.com/nextcloud/HaRP), "Fast Proxy for AppAPI (Nextcloud 32+)". The reverse-tunnel client is [fatedier/frp](https://github.com/fatedier/frp), written in Go.

[^5]: GPU wiring is daemon-side: image-tag suffix (`-cuda`/`-rocm`) and Docker `DeviceRequests` / Podman CDI selectors all live in [`app_api/lib/DeployActions/DockerActions.php`](https://github.com/nextcloud/app_api/blob/main/lib/DeployActions/DockerActions.php). Default CDI selector for NVIDIA: `nvidia.com/gpu=all`.

[^6]: `COMPUTE_DEVICE` environment variable injection — same file, see the `// Always set COMPUTE_DEVICE=CPU|CUDA|ROCM` line in [`DockerActions.php`](https://github.com/nextcloud/app_api/blob/main/lib/DeployActions/DockerActions.php).

[^7]: The worked example throughout is [nextcloud/app-skeleton-python](https://github.com/nextcloud/app-skeleton-python), including [Dockerfile](https://github.com/nextcloud/app-skeleton-python/blob/main/Dockerfile), [`main.py`](https://github.com/nextcloud/app-skeleton-python/blob/main/ex_app/lib/main.py), and [`start.sh`](https://github.com/nextcloud/app-skeleton-python/blob/main/start.sh).

[^8]: Other ExApps named: [context_chat](https://github.com/nextcloud/context_chat), [llm2](https://github.com/nextcloud/llm2), [stt_whisper2](https://github.com/nextcloud/stt_whisper2), [Visionatrix](https://github.com/cloud-py-api/visionatrix) — each carries an `<external-app>` block in its `info.xml`. The recording daemon is [nextcloud-talk-recording](https://github.com/nextcloud/nextcloud-talk-recording).

A more detailed evidence trail, with line numbers and commit hashes, lives in [SOURCES.md](SOURCES.md).

---

*Worked example:* `git clone https://github.com/nextcloud/app-skeleton-python && docker build .`

*Companion piece: [Designing access for Talk recordings](talk-recording.md) — how to think about authorization when an ExApp is the meeting recorder.*
