Nextcloud architecture notes · 1 of 2
Architecture · Nextcloud AppAPI

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.

01The 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.

02The proxy and the tunnel

Browser Nextcloud HaRP ExApp user agent PHP proxy + ACL frpc ↔ frps your container HTTPS AUTH-APP-API HTTP + session cookie b64(user:secret) over tunnel outbound: OCS / WebDAV as the app, user id as parameter (separate from the inbound request flow above)
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. The curved arrow shows the reverse direction — the ExApp calling back into Nextcloud as itself, with the user id passed as a parameter.

Two things are happening here, and they're worth separating in your mind:

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

03The 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:

<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, ADMIN2 — 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.

04How 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, the mechanism is obvious:

# 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 (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) 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:

05What 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:

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.

06The file layout, annotated

Once you know the protocol, the skeleton repo 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.

07What 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:

At runtime your container also receives a COMPUTE_DEVICE environment variable — CPU, CUDA, or ROCM6 — 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.

08Can 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:

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.

09Why 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:

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. The Python-side verification lives in nc_py_api/_session.py (sign_check).
  2. Three access levels enforced server-side: see the match in ExAppProxyController.php (passesExAppProxyRouteAccessLevelCheck) — exactly PUBLIC, USER, ADMIN, with everything else falling through to false.
  3. set_handlers wires up both PUT /enabled and POST /init: see nc_py_api/ex_app/integration_fastapi.py.
  4. HaRP — nextcloud/HaRP, "Fast Proxy for AppAPI (Nextcloud 32+)". The reverse-tunnel client is 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. 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.
  7. The worked example throughout is nextcloud/app-skeleton-python, including Dockerfile, main.py, and start.sh.
  8. Other ExApps named: context_chat, llm2, stt_whisper2, Visionatrix — each carries an <external-app> block in its info.xml.

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