Host and deploy an app

Download all docs

Choose the extension-only or self-hosted path, then serve a full app with pinned assets.

ModeWho serves the UIWhat you deployExample
Extension onlyOctonode stores and serves the published bundleNothing after app publishWorkspace notice or an app.page without a backend
Self-hostedYour HTTPS origin serves the page, backend, and extension assetsThe entire dist/web/<id> directoryInventory labels with a server
Development tunnelCloudflare Quick Tunnel points to your local serverNothing permanentoctonodes app dev preview

There is no managed Octonode backend hosting in this release. A block-only app does not need a page or backend. A self-hosted app can use its own external page without an app.page extension; if it declares extensions, those bundle URLs must remain available for pinned installs.

Choose a hosting provider

To use a self-hosted app after development, deploy its page, backend, and assets on a permanent public HTTPS host. Publishing in Octonode registers the app; it does not host your application. Extension-only apps do not need separate hosting.

For a simple start, use Cloudflare Workers or Vercel. Both offer free plans within usage limits: Cloudflare Workers Free can serve small apps, while Vercel Hobby is for personal, non-commercial use. Choose a plan that fits your app's usage.

Use the provider's supported runtime and deployment entry point for your backend; the generated start.cjs is a Node server, not a ready-made Worker or Vercel Function. Set web.applicationUrl to the permanent deployment origin and follow the session verification, embedding headers, and pinned-asset requirements below.

Build a full app

octonodes app create inventory-labels --template full
cd inventory-labels
npm install

Edit src/server.ts and src/web/App.tsx. Contributions can be added separately. The generated server has a Fetch API handler, a sample page, and a /api/context endpoint using connectAppServer. Set web.applicationUrl in octonode.app.json to your permanent HTTPS origin, for example https://labels.example.com. Add only the required web.requestedActions. Run:

npm test
npm run build
octonodes app validate dist/apps/<source-id>

The generated server already demonstrates the session handoff. To serve a project-specific route, add logic like this inside its Fetch API handler:

import { connectAppServer, AppRequestError } from "@octonodes/ui-extensions/app/server";

if (new URL(request.url).pathname === "/api/project-name") {
  const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
  try {
    const app = await connectAppServer(bearer, {
      appId: process.env.OCTONODE_APP_ID!,
      baseUrl: process.env.OCTONODE_API_URL!,
    });
    const project = app.project;
    if (!project) return new Response("Select a granted project", { status: 400 });
    return Response.json(await project.get());
  } catch (error) {
    const status = error instanceof AppRequestError ? error.status : 502;
    return new Response(status === 401 ? "Reopen the app" : "Project access failed", { status });
  }
}

This route needs projects:read in web.requestedActions and an installer-approved project. It uses the verified project from the session, not an ID in the URL. A barcode or formatter app can add its own logic around that verified context.

Read <source-id> from id in octonode.app.json; the generated ID may be a UUID, not the directory name. npm test builds against a development URL, so run npm run build again with the saved production web.applicationUrl before deployment.

The build produces an immutable registration artifact under dist/apps/<source-id> and standalone web output under dist/web/<source-id>. The latter includes start.cjs, server.cjs, octonode-web.json, and content-hashed files in extensions/. start.cjs checks the output inventory before serving. Do not edit generated files.

Launch on your host

  1. Run octonodes login, then publish the first release with octonodes app publish --workspace user:<your-user-id> (or a team/org workspace). Save the returned registered app ID. Publication stores release metadata; it does not deploy or install the web output.
  2. Copy the complete dist/web/<source-id> directory to a Node 20.19+ or 22.12+ host. Configure the public origin for HTTPS and forward traffic to PORT. Set OCTONODE_APP_ID to the returned app ID and OCTONODE_API_URL to the Octonode API used by your Studio deployment. Start node start.cjs from that directory (or use its absolute path). Do not expose PORT directly as the permanent public URL.
  3. Check that the public page and every published /extensions/<hash>.js asset respond from the configured origin. Only then invite installation from Studio → Apps. The CLI can also serve a built app locally with octonodes app serve, but a local server is not a production deployment.

The registered origin must equal the served public origin. Your hosted backend needs to verify the short-lived octo_app_ session on each Octonode data call. The browser fragment is cleared immediately by the generated starter. If you replace that page, keep the same handoff behavior. Never use the temporary trycloudflare.com development URL as web.applicationUrl for a release.

Client and server integration

The following deployment recipes use the default full Vite template. Keep web.platform: "vite" and web.entry: "src/server.ts" in octonode.app.json. These settings belong in the app project, not the Octonode monorepo.

PartSourceBuild outputRuns on
React clientsrc/web/App.tsx, src/web/main.tsxweb-dist/, copied into dist/web/<source-id>/site/Browser
API handlersrc/server.ts default exportdist/web/<source-id>/server.cjsYour backend
Node launcherGenerated by the CLIdist/web/<source-id>/start.cjsNode 20.19+ / 22.12+ host
Optional contributionssrc/extensions/*dist/web/<source-id>/extensions/Installed extension sandbox
Release metadataoctonode.app.jsondist/apps/<source-id>/Octonode publication

Generated Node bundles target Node 20.19. Your backend code and external dependencies must also support the host runtime; the build does not provide newer Node APIs on older versions.

For Workers and Vercel, bundle the source API handler through the provider's adapter below. Deploy only the static client and contributions publicly. Never set the whole dist/web/<source-id> directory as a static output directory: it also contains backend code and build records.

What to write on the client

Keep the generated OctonodeAppProvider from @octonodes/ui-extensions/app/react around your app. It performs the Studio handshake and verifies /api/context before showing workspace content. Inside its children, use useOctonodeApp() to access bridge, session, and workspace. Call bridge.fetch("/api/projects"), or your own same-origin API path, rather than calling the Octonode API directly. The bridge attaches the current bearer and supports session renewal. Include session.token in the request effect's dependencies and cancel obsolete requests on cleanup, as the generated src/web/App.tsx does.

Register client routes through the provider's navigation prop. Your host must serve the app shell for a refresh of /projects; missing API or JavaScript assets must return an error rather than the shell. Do not put server credentials in VITE_*, browser storage, or client code. See routing for the bridge contract and a non-React client.

What to write on the server

Keep src/server.ts as a default-exported (request: Request) => Promise<Response> handler. The generated routes already implement:

  • GET /api/context: read the bearer from Authorization, call connectAppServer, and return { workspace: app.workspace }.
  • GET /api/projects: verify the bearer again and load only projects granted for projects:read. No grants means an empty list, not access to all projects.

For every new route reading or writing Octonode data, call connectAppServer on that request. Use the verified context's project methods, validate your route's input, and enforce its HTTP method. Return authentication and permission failures with their status codes; do not turn them into a successful response. Keep workspace responses out of shared caches with Cache-Control: no-store. The SDK guide shows the verification code.

Server settingValue
OCTONODE_APP_IDRegistered app ID returned by publication; not an installation ID or an assumed source name
OCTONODE_API_URLHTTPS origin of the Octonode API for your Studio deployment, e.g. https://octonodes.com
PORTOnly for the standalone Node launcher; Workers and Vercel do not use it

These first two values identify the app and API; they are not a personal API token. Customer authorization comes from the short-lived installation bearer. Store any unrelated backend secrets in the provider's server secret settings. Keep the client, /api/context, and other app API routes on the same origin; the hosted bridge refuses to send its bearer to another origin.

Prepare the public output

Add scripts/prepare-hosting.mjs to the app project. It reads the real source ID and copies only deployable public files, including retained pinned contributions:

import { cpSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";

const source = JSON.parse(readFileSync("octonode.app.json", "utf8"));
const web = join("dist", "web", source.id);
rmSync("public-deploy", { recursive: true, force: true });
mkdirSync("public-deploy", { recursive: true });
cpSync(join(web, "site"), "public-deploy", { recursive: true });
cpSync(join(web, "extensions"), "public-deploy/extensions", { recursive: true });

Add this script alongside the existing scripts in package.json, and add public-deploy/, .wrangler/, .vercel/, and .dev.vars* to .gitignore:

{
  "scripts": {
    "build:hosting": "npm run build && node scripts/prepare-hosting.mjs"
  }
}

Set the permanent web.applicationUrl, then run npm run build:hosting. public-deploy/index.html and its assets must exist. Keep npm run build as the CLI's combined client/server/registration build; vite build alone omits the backend and release metadata. Use a committed lockfile and npm ci in CI.

Deploy to Cloudflare Workers

This recipe serves the Vite client and API from one Worker origin. It does not run start.cjs. The Vite template's server uses Web APIs and connectAppServer; replace any added filesystem, process-spawning, or other unsupported backend code before using Workers. The plain template's filesystem-served HTML is not covered by this recipe.

  1. Install the deployment tool in the app project:

    npm install --save-dev wrangler
  2. Add src/worker.ts:

    import handle from "./server";
    
    type Env = { ASSETS: { fetch(request: Request): Promise<Response> } };
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const path = new URL(request.url).pathname;
        let response: Response;
        if (path === "/api" || path.startsWith("/api/")) {
          response = await handle(request);
        } else {
          response = await env.ASSETS.fetch(request);
          const navigation =
            ["GET", "HEAD"].includes(request.method) && request.headers.get("accept")?.includes("text/html");
          if (
            response.status === 404 &&
            navigation &&
            !path.startsWith("/extensions/") &&
            !path.startsWith("/assets/") &&
            !path.split("/").pop()?.includes(".")
          ) {
            response = await env.ASSETS.fetch(new Request(new URL("/index.html", request.url), request));
          }
        }
        const headers = new Headers(response.headers);
        headers.set("Content-Security-Policy", "frame-ancestors https://octonodes.com");
        headers.set("X-Content-Type-Options", "nosniff");
        if (path === "/api" || path.startsWith("/api/")) headers.set("Cache-Control", "no-store");
        if (path.startsWith("/extensions/")) headers.set("Access-Control-Allow-Origin", "*");
        return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
      },
    };

    Replace the allowed Studio origin in frame-ancestors if you use a custom deployment. The wildcard CORS header applies only to public extension assets, not customer API responses. Missing API and asset paths retain their 404s.

  3. Add wrangler.jsonc at the app root:

    {
      "$schema": "./node_modules/wrangler/config-schema.json",
      "name": "inventory-labels",
      "main": "src/worker.ts",
      "compatibility_date": "2026-09-01",
      "compatibility_flags": ["nodejs_compat"],
      "assets": {
        "directory": "./public-deploy",
        "binding": "ASSETS",
        "run_worker_first": true,
        "html_handling": "none",
        "not_found_handling": "none",
      },
      "vars": {
        "OCTONODE_APP_ID": "<registered-app-id>",
        "OCTONODE_API_URL": "https://octonodes.com",
      },
    }

    Use a recent compatibility date when creating your app. With nodejs_compat and this date, Worker vars are available as process.env, matching the generated server. See Cloudflare environment variables. The Worker runs first to keep API routing, headers, and missing-asset behavior explicit. These requests consume Worker usage; review the free-plan limits. See static asset configuration.

  4. Set web.applicationUrl to your permanent Worker URL, for example https://inventory-labels.<your-account-subdomain>.workers.dev, or your configured custom domain. Keep that same URL for every release. Publish the first release to obtain the registered ID, then replace <registered-app-id> in the Worker vars before allowing installations.

  5. Build and check locally, then deploy:

    npm test
    npm run build:hosting
    npx wrangler types
    npx wrangler deploy --dry-run
    npx wrangler dev

    Check the public shell, API routing, and assets locally. Stop the local server, then run:

    npx wrangler login
    npx wrangler deploy

    For Git-connected Workers Builds, use root directory the app project, build command npm run build:hosting, and deploy command npx wrangler deploy. The public asset directory is public-deploy, configured in wrangler.jsonc; it is not dist/apps or the whole dist/web directory. Put runtime vars in wrangler.jsonc or the selected Worker environment, not only in build-time variables. Add actual secrets with npx wrangler secret put SECRET_NAME and use .dev.vars locally; never commit secret values.

Deploy to Vercel

Use the same Vite template and public-deploy staging script. Vercel serves the static client and packages files under the project-root api/ directory as functions. See Vite on Vercel and the Functions API.

  1. Add both api/context.ts and api/projects.ts, each with this content:

    import handle from "../src/server";
    
    export async function GET(request: Request): Promise<Response> {
      const response = await handle(request);
      const headers = new Headers(response.headers);
      headers.set("Cache-Control", "no-store");
      return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
    }

    The function preserves the request path and authorization header. For each additional backend route, create its corresponding api/ file and export the HTTP methods your handler accepts, such as POST. Do not call listen() or run start.cjs from a function. Include api/**/*.ts in tsconfig.json so local typechecking covers these adapters.

  2. Add vercel.json at the app root:

    {
      "$schema": "https://openapi.vercel.sh/vercel.json",
      "framework": null,
      "installCommand": "npm ci",
      "buildCommand": "npm run build:hosting",
      "outputDirectory": "public-deploy",
      "routes": [
        {
          "src": "/(.*)",
          "headers": {
            "Content-Security-Policy": "frame-ancestors https://octonodes.com",
            "X-Content-Type-Options": "nosniff"
          },
          "continue": true
        },
        {
          "src": "/extensions/(.*)",
          "headers": { "Access-Control-Allow-Origin": "*" },
          "continue": true
        },
        { "handle": "filesystem" },
        { "src": "/api(?:/.*)?", "status": 404 },
        { "src": "/extensions/(.*)", "status": 404 },
        { "src": "/assets/(.*)", "status": 404 },
        { "src": "/.*\\.[^/]+$", "status": 404 },
        { "src": "/(.*)", "dest": "/index.html" }
      ]
    }

    Existing static files and functions resolve before the SPA fallback. Missing API and asset URLs return 404. Customize the Studio frame-ancestors origin as needed. This uses the advanced routes configuration; do not combine it with a separate headers, rewrites, or redirects array. See Vercel configuration.

  3. Import the repository into Vercel and enter these project settings:

    SettingValue
    Root DirectoryDirectory containing the app's package.json and octonode.app.json
    Framework PresetOther; the config disables automatic framework output selection
    Node.js Version24.x; set engines.node to 24.x in this app's package.json
    Install Commandnpm ci
    Build Commandnpm run build:hosting
    Output Directorypublic-deploy
    Start CommandNone; API functions handle requests
  4. Choose a permanent production domain and save it as web.applicationUrl, for example https://inventory-labels.vercel.app. Publish the first release to obtain its registered app ID. In Project Settings → Environment Variables, add OCTONODE_APP_ID and OCTONODE_API_URL for Production. Add them separately to Preview only if needed. Redeploy after changing values. Ensure the production page can load in Studio without Vercel deployment protection or a provider login challenge.

  5. Run npm test and npm run build:hosting locally. Push the configured app project to trigger deployment, or run npx vercel to link/preview and npx vercel --prod for production. Register only the stable production origin, never a changing preview URL. Vercel Hobby is limited to personal, non-commercial use; choose an eligible plan for commercial apps.

The generated --platform next template uses output: "export" and serves its API through the separate Octonode handler; it does not generate Next Route Handlers. These Vite settings are not a native Next.js deployment recipe. Keep the Node hosting path for that output, or deliberately migrate the backend to Next Route Handlers and update the build before selecting Vercel's Next preset.

Verify the integration before inviting users

  1. Open the permanent app origin directly. The static shell should load, but workspace content must remain hidden without a Studio session.
  2. Request /api/context and /api/projects without a bearer. They must return 401 after configuration, not HTML or workspace data. A 503 means the server identity or API URL is missing. Verify /api/missing and /extensions/missing.js return 404 rather than index.html.
  3. Publish/install in the intended Studio workspace and open the app from Apps → Installed apps. Confirm the handshake, a 200 JSON response from /api/context, and the correct verified workspace. Check the API response's Cache-Control: no-store header.
  4. Approve projects:read for a selected project to test /api/projects. Confirm only granted projects appear. Test no grants and revoked access too.
  5. Navigate to /projects, refresh, and use back/forward. The client shell and mirrored Studio path should survive. Check the browser console for frame policy failures, provider login pages, API redirects, or failed asset requests.
  6. If contributions are declared, check each manifest URL returns JavaScript, its SHA-256 still matches, and older pinned hashes survive your next deploy.

Do not log or copy live customer bearers into deployment settings. Debug with status codes, routing, and non-secret configuration. Publication and installation are separate from deployment; do all three before considering the app ready.

Preserve older versions

Each published version and installation is pinned. Extension files are named by SHA-256. An incremental build copies verified older assets forward. For a clean CI build, restore the previous dist/web/<id> artifact before building, or configure your hosting service to retain old hash-named files. Deploy the complete directory atomically. Deleting an asset still referenced by a pinned installation breaks that installation. Keep your backend compatible with old pinned versions; publication does not update them automatically.

If the tunnel or page fails

app dev automatically downloads a verified tunnel helper and requires network access to GitHub releases and Cloudflare. Use --use-localhost for offline UI work or --tunnel-url to supply your own public HTTPS tunnel. A workspace preview also requires octonodes login, publisher access, and deployed preview APIs. A 401 from a hosted app means the bearer expired, was revoked, or lacks access: reopen the app from Studio. See the app SDK and publishing for the relevant checks.

Embed in Studio

Serve the page on an origin different from Studio. Configure Content-Security-Policy: frame-ancestors https://octonodes.com and any explicitly supported Studio origins. Do not send conflicting X-Frame-Options: DENY or SAMEORIGIN. Custom Studio domains must be explicitly allowed by your host.

Studio's frame policy permits same-origin content, HTTPS and blob frames. The hosted-app component additionally requires a registered HTTPS origin different from Studio before sending a session. Studio uses a sandbox permitting scripts, the app's own origin and forms. Top navigation and additional browser privileges stay restricted. Authenticate calls to your own backend using the hosted SDK; do not depend on third-party cookies.

Initialize the hosted routing and session bridge. Studio waits for its handshake and offers a retry if the page cannot connect. An iframe load event alone does not establish readiness. The generated development server does not add a conflicting framing header; configure these headers at your production HTTPS host. The static app shell is public so the iframe can load; the React provider and backend bearer checks keep workspace content private.