External Runtime

What It Is

With the external runtime, a Custom App's routes are served by the app's own HTTP service instead of sandboxed PHP handlers. The platform stays in front of the service:

  1. A request reaches /app/<slug>/api/<path> as usual.
  2. The platform makes every check it makes for a PHP handler: app access, the route's auth, CSRF, rate limits.
  3. The platform's gateway forwards the request to the service, with a signed assertion of who is calling.
  4. The service verifies the assertion and does its work. To read or change data it calls the platform back, with the app's credential.
browser ──► /app/notes/api/notes ──► platform checks ──► gateway ──► the app's service
                                                                          │
               REST API, runtime.* ◄── runtime token ◄── runtime.exchange ◄┘ (app credential)

The service is ordinary code in its own process: any framework, any library, long-lived connections, background workers. It never holds a user's session or password. The platform tells it who is calling, and it acts with the permissions the platform grants.

Why It Matters

Sandboxed PHP handlers run everywhere and need no infrastructure, so they stay the default. Choose a service when an app needs what the sandbox does not offer:

  • Composer libraries, PHP extensions or code from an existing codebase
  • work that takes longer than a handler may run
  • connections that outlive a request: queues, sockets, caches
  • a team that deploys and monitors services the way it already does

Runtimes mix. An app with a service can keep some routes on PHP handlers, and events, schedules and MCP tools can target either kind.

The external runtime needs a host that offers it, typically a containerized installation where the service runs on the same network. The deploy plan reports missing.runtime when a tenant does not offer the runtime a release needs, and such a deploy is refused. Shared hosting does not offer it.

Declaring A Service

The manifest names the runtime and where the service is. The address comes from an environment variable, so every environment (development, staging, production) sets its own:

{
  "manifestVersion": 2,
  "runtime": { "type": "external", "upstream": "env:NOTES_SERVICE_URL", "timeoutMs": 15000 },
  "env": ["NOTES_SERVICE_URL"],
  "routes": {
    "GET /notes": { "auth": "user" },
    "POST /notes": { "auth": "user" },
    "GET /reports/export": { "auth": "role:case_master", "stream": true },
    "POST /jobs/tidy": { "auth": "internal" },
    "GET /ping": { "handler": "server/ping.php", "auth": "public" }
  }
}
  • A route without a handler is forwarded to the service; {} forwards it with the default options. A route with a handler runs in the sandbox as before.
  • runtime.upstream is env:<VARIABLE>, a variable also listed in env. Set its value in Customization → Environment Variables, for example https://notes.internal.example. A deploy is refused while the variable is missing.
  • runtime.timeoutMs is how long a forwarded request may take: 30 seconds by default, at most 120 seconds. A route's own timeoutMs overrides it, within the same limit.
  • runtime.maxBodyBytes is the largest request body forwarded: 10 MB by default, at most 50 MB.
  • stream: true (GET routes only) lets the web server stream the answer instead of the platform buffering it. Use it for large downloads and exports. It needs a host configured for it; elsewhere the route works as usual.

See the App Manifest Reference for every key.

What The Service Receives

The request keeps its method, the path after /app/<slug>/api/, the query string and the body. The path is appended to the service's address, so GET /app/notes/api/notes?page=2 reaches GET https://notes.internal.example/notes?page=2.

The gateway adds:

HeaderMeaning
X-Jetstack-AssertionWho is calling, signed by the tenant (see below)
X-Jetstack-Request-IdThe request's id, also written to the app's log
X-Jetstack-ViaWhy the platform calls when no browser does: event, schedule, mcp, action or async
X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-PrefixThe tenant's address and /app/<slug>/api, for building links
X-Forwarded-ForThe caller's address

The gateway never forwards the caller's cookies, Authorization header, CSRF token or any X-Jetstack-* header. A service cannot tell a session apart from a forged header, so it only trusts the assertion.

Form uploads (multipart/form-data) are re-encoded with the same field names. A file's content type is the one the platform detected, not the one the browser claimed.

The service's answer goes back to the caller with its status, headers and body, except:

  • Set-Cookie: the service does not set cookies on the tenant's domain
  • Access-Control-*: the tenant's own CORS settings decide who may read the app's routes
  • Server, X-Powered-By and web-server control headers such as X-Accel-* or X-Sendfile

The Assertion

Every forwarded request carries a short-lived statement of who is calling: a compact JWS signed with Ed25519 (alg: EdDSA).

ClaimMeaning
subuser:<id> (a signed-in user), anonymous (a visitor on a public route) or app:<slug> (the platform calling as the app, for example from a schedule)
rolesThe caller's roles, by system name
userFor a user: id, name, email, locale
app, routeThe app's slug and the manifest route, such as GET /notes/:id
ridThe request id
issThe tenant, as https://<tenant host>
audjetstack-app:<slug>
iat, nbf, exp, jtiIssued, valid from, expires (at most 60 seconds later), unique id

The service verifies each assertion against the keys the tenant publishes for the app:

GET /app/<slug>/__jetstack/keys.json   → {"keys": [ …Ed25519 JWKS… ], "issuer": "https://<tenant host>"}

The keys document is public, and the PHP SDK fetches and caches it. To replace the signing key, use Rotate signing key on the Custom apps screen. The previous key stays published for an hour, so assertions signed just before still verify, and services pick up the new key the first time they see it.

Verify the signature, iss, aud (your app) and the time claims on every request. Answer 401 when the assertion is missing or wrong, even if the service is reachable only from the platform's network.

Calling The Platform Back

An assertion tells the service who is calling. It is never enough to act: calling the platform back always takes the app's credential too.

The credential. Create it on the app's page (Service → Create credential) or with jetstack runtime --env <env> --rotate. It starts with jsa_ and is shown once, so put it straight into the service's configuration as JETSTACK_APP_CREDENTIAL. Creating a new one retires the previous one at once, and Revoke stops the service from calling back until it gets a new one. Deleting the app deletes its credential.

Runtime tokens. The service exchanges the credential for a runtime token (jsr_…, valid for 5 minutes) with POST /api/v1/app/runtime.exchange:

  • {"credential": "jsa_…"} returns a token that acts as the app: its service principal, limited to the app's capabilities.
  • {"credential": "jsa_…", "assertion": "<X-Jetstack-Assertion>"} returns a token that acts as the calling user, with their roles and permissions. Each assertion can be exchanged once. An anonymous visitor has no identity to act as.

Send the token as Authorization: Bearer jsr_…. Either way the token acts inside the app: db:raw and the other capability gates apply, and only the secrets and environment variables the manifest declares can be read.

What a runtime token can do:

CallWhat it does
REST collections /api/v1/collection/<type>Read and write records with the token's identity
GET runtime.secret?name=…A secret the app declares in secrets
GET runtime.env?name=…An environment variable the app declares in env
POST runtime.log {level, message, context?}A line in the app's log (jetstack logs)
POST runtime.mail {to, subject?, body?, template?, vars?}An e-mail, with a body or an e-mail template
POST runtime.notify {title, text?, link?, user? | role?}A notification to a user (id) or a role (system name)
POST runtime.schedule {route, args?, options?}One of the app's own routes, later or repeatedly ("POST /jobs/digest", options at, delay, every, until)
GET runtime.storeGet?key=…, POST runtime.storeSet {key, value, options?}The tenant's key-value store (shared by the tenant's apps and automations)

Runtime tokens are refused everywhere else, including the app deployment commands, the tenant configuration commands and MCP. The runtime.* commands accept nothing but runtime tokens.

Use the user's token for anything the user reads or changes, so the platform applies their permissions. Use the app's token for the app's own bookkeeping, and decide in the service what an anonymous visitor may see.

Events, Schedules And Tools

A forwarded route can be the target of anything else in the manifest:

  • Event subscriptions send the event as the JSON body, with X-Jetstack-Via: event.
  • Schedules send {"args": …, "task": …} with X-Jetstack-Via: schedule.
  • MCP tools send the tool's input with X-Jetstack-Via: mcp.

When these run as the app (a schedule, or a subscription with runAs: app), the assertion's sub is app:<slug>. The service can require it for routes only the platform should trigger. The route itself should also be internal, so it cannot be called over HTTP at all.

The PHP SDK

The PHP SDK (jetstack/sdk, in the Jetstack repository under packages/sdk-php) verifies assertions and calls the platform back. It needs PHP 8.2 with the sodium and curl extensions, and no other packages.

use Jetstack\Sdk\AssertionVerifier;
use Jetstack\Sdk\InvalidAssertion;
use Jetstack\Sdk\PlatformClient;

$verifier = AssertionVerifier::forApp(getenv('JETSTACK_URL'), 'notes', cacheDir: sys_get_temp_dir());
$platform = PlatformClient::fromEnvironment();   // JETSTACK_URL, JETSTACK_APP_CREDENTIAL

try {
    $caller = $verifier->verifyRequest();         // or verifyPsrRequest($request)
} catch (InvalidAssertion $e) {
    http_response_code(401);
    exit;
}

$mine = $platform->forUser($caller)->records('ext_note')->list(['perPage' => 50]);   // with the user's permissions
$platform->asApp()->log('Listed notes', 'info', ['user' => $caller->userId()]);
  • AssertionVerifier checks everything listed above. It fetches the keys, keeps them for five minutes (in a file too, when given cacheDir) and refetches when the tenant rotates its key.
  • PlatformClient::asApp() and forUser($caller) return a session with records($type) (list, all, search, get, create, update, replace, delete) and the runtime.* commands (secret, env, log, mail, notify, schedule, storeGet, storeSet). asApp() renews its token before it expires.
  • A refused call throws PlatformError with the platform's status and message.

A complete example, a notes app whose routes a plain PHP service serves (php -S, Apache or nginx with PHP-FPM), is in packages/examples/external-runtime-php.

Limits And Failures

LimitValueWhat the caller gets
Connecting to the service2 seconds504
Waiting for the answertimeoutMs: 30 seconds by default, at most 120 seconds504
Request bodymaxBodyBytes: 10 MB by default, at most 50 MB413
Answer25 MB (stream larger downloads)502
Calls in flight per tenant4 by default, set by the host503 with Retry-After
Service unreachable, or its address refused502
The runtime switched off503

A failed call answers {"message": …, "errorId": …}. The app's log (jetstack logs) has the reason under the same error id, and one line for every forwarded call with its status and duration.

Where The Service May Run

The platform connects only to addresses on the public internet:

  • Loopback, private-network and link-local addresses are refused.
  • Cloud metadata endpoints are always refused.
  • Every address a host name resolves to is checked, and the connection goes to the checked address, so DNS cannot redirect it later.
  • Redirects are not followed.

On a host where apps' services run next to the platform, the host operator allows the network those services use. See For Hosting Operators.

Best Practices

  • Verify every request. Treat a request without a valid assertion as an attack, even on an internal network.
  • Act as the user for anything the user reads or changes, so the platform's permissions apply. Keep the app principal's capabilities to what the app itself needs.
  • Protect the credential. Keep it in the service's secret configuration, never in the repository. Rotate it when people leave or it may have leaked; rotation takes effect at once.
  • Declare what the service reads. Secrets and environment variables it fetches must be listed in secrets and env, which also makes deploys refuse a tenant that lacks them.
  • Make scheduled and event routes idempotent. They can run again after a failure. Use the event's or task's id to do the work once.
  • Stay within the time limit. For longer work, answer quickly and continue in the background, then report back with runtime.notify, runtime.log or a record update.

Tenant Settings

This key is not on the Application settings screen; set it in the tenant configuration.

customAppsExternalRuntime

What it controls:

  • whether apps on this tenant may run routes on their own services

Default:

  • true, where the host offers the external runtime

How to configure it:

  • set it to false to stop every app service on the tenant at once, for example while one misbehaves

What it affects:

  • while it is off, forwarded routes answer 503, credentials cannot be exchanged, runtime tokens stop working and the keys are no longer published; PHP handler routes keep working
  • deploy plans report the runtime as missing, so releases that need it are refused
  • turning it on again restores everything; credentials stay valid

For Hosting Operators

The runtime is offered where the hosting provides the network for apps' services. These environment variables of the platform configure it:

VariableMeaning
JETSTACK_APP_RUNTIMESRuntimes the host offers besides PHP handlers, such as external. Unset means none.
APP_GATEWAY_MAX_CONCURRENCYForwarded calls a tenant may have in flight at once (default 4), so services cannot tie up all of the platform's workers.
APP_GATEWAY_ACCELnginx lets nginx stream routes marked stream; nginx needs the matching internal /_jsgw/ location.
APP_GATEWAY_ALLOW_PRIVATE_UPSTREAMSDevelopment only: also allows loopback, private-network and link-local service addresses (never metadata endpoints).