Constraints
What the Graviton runtime refuses, which files the platform owns, and the operations that are configured outside your repository.
Each rule here is enforced: a build that fails, a lint error, a call that throws, or a file that gets overwritten.
The runtime depends on App size
| App size | Runs as | Limits |
|---|---|---|
| Standard (default) | One Cloudflare Worker, built with OpenNext | 10 MiB compressed bundle, no Node.js runtime, no rendering cache |
| Large | A Node.js server build with more memory | No Worker bundle ceiling |
Change it in Settings → General → Hosting & behavior → App size, where your organization has the setting. It applies the next time you publish. Large needs a recent version. If the app can't use it, the picker says why (usually: take an update).
Write Worker-compatible code on either size. An app can move back to Standard, and the rules below marked Standard are what break first when it does.
Standard: no Node.js runtime
nodejs_compat covers common Node APIs, not a Node server. The filesystem, native modules,
child processes, long-lived background threads and libraries that assume them are unavailable.
- Work finishes inside the request. Periodic work belongs in
scheduling-sdk. Slow work belongs behind a job the platform triggers, not asetInterval. - Local files are not storage. Use a storage-type Data Store.
Standard: bundle size is a hard ceiling
Cloudflare rejects a Worker whose compressed script exceeds 10 MiB. Stardeck warns at
8 MiB. Every route and dependency adds to the same bundle, so check a heavy dependency's size
and Worker compatibility before adding it. Run npm run build:check in apps/web for a build that
does not disturb your dev server.
Standard: rendered output is not cached
open-next.config.ts is defineCloudflareConfig({}), so OpenNext uses its dummy cache: nothing
is stored. revalidate, revalidateTag and the Next data cache persist nothing. Treat rendering as
per-request on either size and cache deliberately (Cache-Control on a route response, or a value
in a Data Store).
Both sizes: keep middleware.ts
Next.js 16 deprecates middleware.ts in favour of proxy.ts and prints a warning. Do not rename
it. Proxy always runs on the Node.js runtime, which the Cloudflare adapter does not support, so the
rename produces a build that cannot deploy at Standard size. The warning is expected.
Data access
Data lives in Data Stores: Postgres databases owned by your organization,
connected to the app by the platform. Your app reads and writes them through
data-store-sdk, which hands you a Kysely instance backed by
the Neon HTTP driver.
No interactive transactions. The HTTP driver cannot hold a transaction open across round
trips. db.transaction() and db.startTransaction() throw at runtime, and the Stardeck ESLint
plugin fails the build before you get there.
// Rejected by lint, throws at runtime
await db.transaction().execute(async (trx) => {
await trx.insertInto("orders").values(order).execute();
await trx.updateTable("inventory").set({ count }).execute();
});
// Fold the work into one statement
await db
.with("new_order", (qb) => qb.insertInto("orders").values(order).returning("id"))
.updateTable("inventory")
.set({ count })
.execute();
// Or run the statements in sequence and make the second one idempotent
await db.insertInto("orders").values(order).execute();
await db.updateTable("inventory").set({ count }).execute();Design for this rather than around it: prefer a single statement, make repeated writes idempotent, and reconcile instead of assuming atomicity.
Schema changes are not app code
Creating tables and columns, changing types, and seeding are platform operations, not migrations you commit. They are applied to the same Data Store branch your app reads, and they are versioned by the platform. Do them from the dashboard's Data Stores editor, or from your editor with your agent connected. A production deploy lists the schema changes still pending and applies them with the code.
Module tables are the exception. Each Module's tables are defined in the repository, in
src/modules/<name>/datastore/schema.sql, with one ledger entry per change in
src/modules/<name>/migrations/. Stardeck applies those entries to the app's Data Store when the
app is created and when you take an update, and the ledger refuses an
entry that drops a table or column. The app's AGENTS.md section Applying schema changes is the
loop your agent follows.
Legacy: apps/web/migrations/ and npm run db:migrate. Older apps have a per-app database
reached through DATABASE_URL with Kysely migration files. It still runs, and apps already using
it keep working, but if your app is not already using it, do not start. New data belongs in a
Data Store.
Files the platform owns
Some files are generated or maintained by Stardeck. Edits to them are overwritten, conflict with the next update, or are refused by the editing tools.
| Path | Owner | Why |
|---|---|---|
apps/web/next.config.ts | Stardeck | Changed by updates. See changing it below |
apps/web/open-next.config.ts | platform | Deployment adapter configuration |
apps/web/src/app/api/*/[[...path]]/route.ts | SDKs | Passthrough routes for auth, payments, and storage |
AGENTS.md | Stardeck | Changed by updates; put project notes in a separate file |
Build outputs, not source. stardeck-compose regenerates apps/web/src/*.gen.ts
(modules.gen.ts, module-contributions.gen.ts, module-i18n.gen.ts,
module-init.server.gen.ts, module-datastores.gen.ts, module-server.gen.ts) and the generated
page.tsx / route.ts adapters carrying a @stardeck-module-rail-generated marker from Module
source, through the pre* hooks on every npm run dev | build | lint | typecheck | test. They
are gitignored and never committed, so an edit to one is overwritten by the next run. Change the
Module's source or its module.json instead.
Everything else under apps/web/src is yours.
Changing next.config.ts
Updates edit this file, so a local change can conflict with or be reverted by a later
update. The common case is adding an image host to images.remotePatterns.
Ask Stardeck to add it rather than editing the file. There is no supported per-app override yet, so an edit here may need redoing after an update.
Deploying
apps/web has preview and deploy scripts, but they are the commands the
platform runs. They need Stardeck's Cloudflare credentials, so running
npm run deploy from your machine fails. Shipping works like this:
flowchart LR
L["Your commit"] --> M["push a branch"] --> V["Branch preview deploy"]
M --> R["Pull request into the default branch"] --> P["Production deploy"] --> W["Production"]
- Pushing doesn't update the hosted sandbox. Load a commit into it from Git history, or deploy the branch as a preview. Each tracked branch has its own sandbox.
- The default branch changes only through a pull request. GitHub refuses a direct push.
- Publishing to production is a deliberate action, from Settings → Deployments →
Production or your agent's
deploy_app, which asks you to confirm first. Production deploys from the default branch. See Publishing and deployment. - Production and sandbox are different data. See Environments.
Authentication and permissions
Auth is project-auth, and the visibility boundary is enforced one hop before
your code. The two things that trip people up:
A permission gate cannot be verified in the sandbox. When no real session exists,
getSession() returns a mock user with role: "admin" but no permissions at all, so
await requireAuth("<key>") redirects to /unauthorized and hasPermission() returns false
however the gate is written. A gate that denies in preview proves nothing about whether it is
correct. Mint a real persona session with the e2e helpers in
@stardeck-customer-apps/project-auth/e2e instead.
- Declaring a permission key does not grant it. A team member holds an app permission only through what their organization role was granted for that app. Until the grant exists, a correct gate denies everyone.
- API routes are never gated at the edge. A
layout.tsxdoes not wrap route handlers. Gate/api/*inside each handler. access: "internal"on a Surface is authentication, not authorization. It admits every team member. Anything narrower gates itself by permission.
See User authentication and Members and roles.
Environment variables
Values come from the platform, not from a committed .env. Pull the sandbox set
with npm run env:pull (see Run your app locally),
and configure them in the dashboard: Environment variables.
- Client exposure is Next.js's rule: only
NEXT_PUBLIC_-prefixed variables reach the browser. (The dashboard also mirrors non-secret variables with aVITE_prefix for legacy Vite apps; a Next.js app ignores those.) - Secrets are runtime-only. They are not available during the build.
- Do not construct platform URLs by hand. Use
process.env.BASE_URLon the server orprocess.env.NEXT_PUBLIC_BASE_URLin the browser, plus a path. - Never hand-roll a deployment secret. SDK calls sign themselves; if you find
yourself building an
X-Stardeck-Authheader, you are using the wrong entry point.
Cross-surface links are paths, never hostnames
A Surface gets its own hostname in production, but app code must never write that hostname.
// Bad: breaks in dev sandboxes and branch previews
<Link href="https://acme-admin.example.com/admin/orders/123">
// Good: the platform canonicalizes it to the owning surface host
<Link href="/admin/orders/123">A cross-surface link is a full navigation, not client-side routing: client state
does not survive it. Cross-surface form POSTs do not canonicalize. Post to a
same-surface route or /api/*.
Modules have one public surface
A Module lives at src/modules/<name>/ and exposes
index.ts. Deep imports into another Module's internals, including type imports
and dynamic import(), are a lint error, because install and update swap those
files underneath you.
// Bad
import { list } from "@/modules/booking/server/reservations";
// Good
import { listReservations } from "@/modules/booking";Nothing inbound reaches your laptop
Webhooks, scheduled jobs, cross-app calls and integration events are delivered by the platform to a deployed URL. A local dev server has none, and this is a deliberate decision rather than a gap: use the sandbox to exercise inbound traffic. Details in Run your app locally.
Operations that do not live in code
These are configured through the platform. Each has a place you do it by hand.
| Operation | Where you do it |
|---|---|
| Create tables, add columns, change types, seed data | Dashboard → Data Stores, or MCP tools; Module tables through their ledger |
| Declare an app permission key | Agent tooling or MCP tools |
| Grant a permission to an organization role | Dashboard app settings, or MCP tools |
| Connect a Data Store to the app | Dashboard → Data Stores → Connecting |
| Turn a Module on or off | Settings → Modules |
| Take an update | Update available in the project toolbar, or locally |
| Set environment variables | Dashboard → Environment variables |
| Add a custom domain | Dashboard → Custom domains |
| Publish to production | Settings → Deployments → Production, see Publishing and deployment |
| Capture or refresh Guide screenshots | Agent tooling in the app's repository |
If you need one of these and have repository access but no Stardeck account with the right organization role, you are blocked on a grant, not on code. Ask an organization admin.