# Set up your hosted app (/docs/build/hosted-app)



Domino can host your community site for you. Your team builds and checks it on test data, and members visit the live app at `<project-slug>.domino.page` or at your own domain. A starter project comes with a working setup, so most of this page is for your developer.

You can do these in Console without code:

* **Set app secrets**, such as an API key for your analytics service, under **Settings → App secrets**. See [Set app secrets](#set-app-secrets).
* **Use your own domain**, such as `quests.example.com`, under **Settings → Domains**. See [Use your own domain](#use-your-own-domain).
* **Check background jobs** under **Settings → Schedules**, and the data your app stores under **Settings → Storage**.

For how previews, staging and the live app fit together, read [Hosted apps](/docs/concepts/hosting).

## What members see [#what-members-see]

Members visit the live app, on live data. Your team works on a preview and on the staging app, which use test data. A build reaches members when someone approves a [promotion](/docs/concepts/promotion) in Console.

## Configure the app section [#configure-the-app-section]

Every Domino-hosted app is described by the `app` section of `relay.json`: what kind of app it is, how to develop and build it, and what its server code receives. Starters ship with this section filled in. Edit it when you adapt an existing app or add server features.

| App                               | `kind`     | Output                             |
| --------------------------------- | ---------- | ---------------------------------- |
| Static site                       | `"static"` | A public output `directory`        |
| Server-rendered app or API server | `"worker"` | A compiled Cloudflare Worker entry |

Merge an app section like this into your existing configuration. This example fits TanStack Start with the Cloudflare Vite plugin:

```json
{
  "app": {
    "kind": "worker",
    "routing": "transparent",
    "directory": "dist/server",
    "main": "index.js",
    "assetsDirectory": "dist/client",
    "compatibilityDate": "2026-09-16",
    "compatibilityFlags": ["nodejs_compat"],
    "devScript": "dev",
    "checkScript": "typecheck",
    "buildScript": "build"
  }
}
```

| Field                                     | Applies to | Meaning                                                                                                                 |
| ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
| `kind`                                    | Both       | `"static"` or `"worker"`                                                                                                |
| `directory`                               | Both       | Build output: the public files of a static site, or a Worker's private modules                                          |
| `devScript`, `checkScript`, `buildScript` | Both       | `package.json` scripts that `domino dev`, `domino check`, and the cloud build run                                       |
| `routing`                                 | Both       | `"transparent"` or `"legacy"` (the default); see [routing](#choose-a-routing-mode)                                      |
| `main`                                    | Worker     | The compiled `.js` or `.mjs` entry, relative to `directory`                                                             |
| `assetsDirectory`                         | Worker     | Optional public files served beside the Worker; omit it for an API-only app                                             |
| `compatibilityDate`, `compatibilityFlags` | Worker     | The Workers runtime date (`YYYY-MM-DD`) and up to 50 flags                                                              |
| `runWorkerFirst`                          | Worker     | Send every request to the Worker, including ones matching public files; see [requests](#how-requests-reach-your-worker) |
| `vars`                                    | Worker     | Non-secret string [variables](#configure-worker-variables)                                                              |
| `secrets`                                 | Worker     | [Secret](#set-app-secrets) declarations, without values                                                                 |
| `storage`                                 | Worker     | [KV and SQL stores](/docs/build/storage)                                                                                |
| `schedules`                               | Worker     | [Scheduled jobs](/docs/build/schedules)                                                                                 |

* **Paths.** Paths are relative to `relay.json`.
* **Other frameworks.** They work when their adapter emits compiled Worker ES modules.
* **Node servers.** A Node server that listens on a port needs a Workers adapter before staging.
* **Unknown fields fail.** The CLI rejects `app` fields this table does not name.

## Run the dev script on the preview port [#run-the-dev-script-on-the-preview-port]

The dev script must listen on `DOMINO_APP_PORT`. For a Vite app on the Cloudflare Vite plugin, as the starters are, `dominoHosting()` from `@domino-sdk/relay/vite` supplies these settings:

```ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
import { dominoHosting, serverBoundary } from "@domino-sdk/relay/vite";

export default defineConfig(({ command }) => {
  const domino = dominoHosting({ command });

  return {
    plugins: [serverBoundary(), cloudflare(domino.cloudflare)],
    server: domino.server,
  };
});
```

* **`domino.server`** listens on `DOMINO_APP_PORT` (5173 when unset) and refuses another port. With `DOMINO_PREVIEW_ORIGIN` set, it accepts that hostname and runs hot reload over HTTPS through it.
* **`domino.cloudflare`** builds the Worker in Vite's `server` environment. When serving, it passes the variables `domino dev` sets to the local Worker, as empty strings when unset; builds get none. Spread it to add plugin options: `cloudflare({ ...domino.cloudflare, persistState: false })`.

Other Vite apps set `server.port` to `Number(process.env.DOMINO_APP_PORT ?? 3000)` and use `DOMINO_PREVIEW_ORIGIN` for the allowed host and HTTPS HMR configuration. Then run `pnpm exec domino dev`.

* **Requests arrive intact.** Worker previews preserve HTTP methods, bodies, application authorization, and cookies, including response cookies.
* **No Cloudflare cache.** Connected previews bypass Cloudflare's cache when fetching from your dev server, including static assets and error responses. The app still controls browser cache headers.

## Choose a routing mode [#choose-a-routing-mode]

Routing decides how much of each web request Domino's hosting handles before your app sees it. New starters use transparent routing, which hands almost everything to your app.

| `app.routing`        | Behavior                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Default for                                                       |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `"transparent"`      | Hosting forwards every URL to your app. There is no injected banner and no stripped request credentials. Staging reserves only its sign-in path, `/__domino/staging-access`, and the [test toolbar](/docs/use/quests#try-quests-on-your-staging-site)'s script, `/__domino/toolbar.js`, and endpoints under `/__domino/toolbar/`. It adds that script to the HTML pages it serves and removes its own session cookie. Your app owns methods, cookies, response headers, and routing. Hosting does not require Domino's member sign-in to be configured. | New starters, which select it explicitly.                         |
| `"legacy"` or absent | Keeps the implicit `/relay/` participant proxy, preview path restrictions, and credential filtering.                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Existing projects, and existing deployments and preview sessions. |

In either mode, every `domino.page` address answers `/.well-known/security.txt` with Domino's abuse contact, so your app never receives that path there. On your [custom domain](#use-your-own-domain) the path reaches your app.

The starter includes an explicit `/relay/*` handler in `server/index.ts`, which runs in both development and staging through the Cloudflare Vite plugin. That is starter code you own. Add custom handlers before it, replace it, or remove it. A custom `/relay/v1/me/pass` handler receives its requests normally.

## Add Domino participant sign-in [#add-domino-participant-sign-in]

Domino can sign members in for you, with the methods you choose in Console under **Settings → Sign-in**. To use it in a hosted app, mount this handler in your application:

```ts
import { proxyParticipant } from "@domino-sdk/relay/portal-proxy";

export function participant(request: Request) {
  return proxyParticipant(request, {
    baseUrl: "https://relay.domino.run",
    fetch,
  });
}
```

* **Participant routes only.** This helper uses the `/relay/*` convention and deliberately allows only participant API routes.
* **Scoped by origin.** It sends the app origin to the API, which checks its registered preview or staging scope.
* **Not authentication.** It does not grant authentication: sessions, project isolation, and CSRF checks still apply.
* **You mount it.** The hosting gateway does not call this helper for you.
* **Members are counted one by one.** On Domino hosting, every request your app's server sends to `https://relay.domino.run` carries the visitor's IP address, attested by Domino, so the sign-in limits per IP address count each member on their own. An app you host yourself reaches the API from its server's address, so its members share that address's limits. On your own Cloudflare Workers that address is Cloudflare's, shared with every Worker, and Domino counts those requests per project and environment instead.

Connected development supplies `DOMINO_PARTICIPANT_API` for your app's explicit proxy. An app using Domino sign-in must configure that integration in its own development server as well as its deployed server. A static-only app with transparent routing has no implicit backend; add an app-owned Worker or use your own backend for member sign-in.

To use your app's existing login, use the [identity integration adapter](/docs/build/app-login) instead.

### Identify the participant in your server routes [#identify-the-participant-in-your-server-routes]

A server route that acts for the member, such as one that hands out a prize code, asks Domino who is signed in:

```ts
import { signedInMember } from "@domino-sdk/relay/portal-proxy";

export async function memberOf(request: Request, env: Record<string, string | undefined>) {
  return signedInMember(request, env, { apiUrl: "https://relay.domino.run" });
}
```

It returns the member ID, or `null` for a visitor who is not signed in or a session that is not a member's. It sends the request's cookies to `/relay/v1/auth/session` through the same routing as `handleHostedApp`, so it works under `domino dev` and on hosting. Never trust a member ID the browser sends.

To use your app's existing login, use the [identity integration adapter](/docs/build/app-login) instead.

## Migrate to transparent routing [#migrate-to-transparent-routing]

1. If your app uses Domino sign-in, add the app-owned participant handler.
2. Set `app.routing` to `"transparent"` in `relay.json`.
3. Restart `domino dev` with the updated CLI, and verify sign-in, a member action, and your app routes.
4. When the preview works, commit and stage that version.

Staging reads the setting from the selected commit; editing local configuration does not change an existing deployment. Failed builds preserve its routing and application together.

## Configure Worker variables [#configure-worker-variables]

Worker `vars` supply non-secret string configuration to staged and live Workers. They are committed in `relay.json`, so anyone with repository access can read them; put credentials in [app secrets](#set-app-secrets) instead. Configure local values through your framework's development environment. `ASSETS` is refused as a variable name. Six `DOMINO_` names are Domino's own: the five in [the management API section](#call-the-management-api-from-your-server) and `DOMINO_SCHEDULE_KEY` for [scheduled jobs](#run-scheduled-jobs); Domino's values replace app `vars` with those names.

For KV namespaces and SQL databases, and how to browse their contents page by page, see [Store app data](/docs/build/storage).

## Set app secrets [#set-app-secrets]

App secrets are the passwords and API keys your app's server uses, such as a key for your own backend or an analytics service. Your developer declares which ones the app needs. Someone with publish permission then enters the values, in Console or the CLI. Nobody can read a value back once it is saved.

Test and live each have their own secrets: test's reach staged builds, and live's reach the live app. A promotion never copies them, and its preview warns about each secret test has and live lacks.

### 1. Declare what the app reads [#1-declare-what-the-app-reads]

List each secret in `relay.json` under `app.secrets`, with a description of what it is for. Declarations carry no value, so they are safe to commit.

```json
{
  "app": {
    "kind": "worker",
    "secrets": {
      "APP_API_KEY": {
        "description": "Opens the protected /api routes. At least 32 characters; callers send Authorization: Bearer <key>."
      },
      "ANALYTICS_TOKEN": {
        "description": "Sends usage events to the analytics service.",
        "required": false
      }
    }
  }
}
```

* **Required by default.** Set `"required": false` for a secret the app works without.
* **One kind per name.** A name cannot be both a `vars` entry and a declared secret. Static apps cannot declare secrets.
* **Up to 50.** Descriptions have 1 to 500 characters.

### 2. Set the values and stage [#2-set-the-values-and-stage]

In Console, open **Settings → App secrets**. Or run:

```sh
openssl rand -hex 32 | pnpm exec domino secrets set APP_API_KEY
pnpm exec domino stage
```

`domino secrets set` reads the value from stdin, or asks for it at a hidden prompt in a terminal. It never takes the value as an argument and never prints it.

An app login key needs no copying: when an administrator creates it, or adds a secret to it, in **Settings → App login keys**, Console can save it as the app secret `APP_IDENTITY_KEY` in the same step. See [Use your app's login](/docs/build/app-login#1-create-an-integration-key).

### 3. Set live's values before promoting [#3-set-lives-values-before-promoting]

In Console, select **Live** and open **Settings → App secrets**. Or run:

```sh
printf %s "$APP_API_KEY" | pnpm exec domino secrets set APP_API_KEY --environment live
```

Without `--environment live`, `domino secrets` uses test; it ignores `RELAY_ENVIRONMENT` and the environment in `relay.json`, and warns when either names live. Its `--json` result says which environment it used in `environment`. A live change reaches the live app with the next build promoted to live; until then the live app keeps its previous value.

### Manage secrets [#manage-secrets]

Each command takes `--environment live` for live's secrets.

| Command                      | Does                                                           |
| ---------------------------- | -------------------------------------------------------------- |
| `domino secrets set NAME`    | Creates or replaces a secret. One trailing newline is removed. |
| `domino secrets list`        | Lists set secrets, declared secrets, and declared ones missing |
| `domino secrets delete NAME` | Deletes a secret                                               |

* **Missing secrets warn, not block.** `domino stage` warns about each required declared secret that is not set, shows the command that sets it, and adds `missingSecrets` to its `--json` result. The build still stages; routes that need the secret fail until you set it and stage again.
* **Declarations are merged.** `domino secrets list` combines the active build's declarations with your checkout's `relay.json`, whose descriptions win, so it reports missing secrets before the first stage. In live, the declarations come from the live app's build, or from staging's build while nothing is live.
* **Applied by the next build.** Builds are immutable. In test, Worker builds staged after a change receive it as a secret binding; builds staged earlier keep their values. Run `domino stage` again to apply a change. In live, the next build promoted to live receives it.
* **Worker apps only.** Apps with server code (`app.kind: "worker"`) receive every secret. Static apps receive none.
* **Secrets win over `vars`.** A set secret replaces an app `vars` entry of the same name, declared or not.
* **Not in development.** `domino dev` does not receive hosted secrets. Give the local Worker its own value, for example in a git-ignored `.dev.vars` file, which the Cloudflare Vite plugin loads.
* **Write-only.** No command, Console page, or API route shows a stored value. Replace a value you have lost.
* **Limits.** Names use uppercase letters, digits, and underscores, start with a letter or underscore, and have at most 64 characters. `DOMINO_` names and `ASSETS` are reserved. Values are 1 byte to 5 KiB, and each project environment holds at most 50.
* **Permissions.** Setting or deleting needs `publish` in the environment, and listing needs `read`. The CLI acts with the signed-in person's permissions, so a coding agent signed in as a project editor or organization admin can set secrets. A CLI sign-in session can set and delete secrets in live, although it is otherwise read-only there.

> [!DEV] Management API
> Management API clients use `GET /management/v1/secrets`, which also returns the active build's `declared` secrets, `PUT /management/v1/secrets/NAME` with `{ "value": "…" }`, and `DELETE /management/v1/secrets/NAME`, or `secrets.list()`, `secrets.set(name, value)`, and `secrets.delete(name)` on `createManagementClient` from `@domino-sdk/relay/server`.

## Use your own domain [#use-your-own-domain]

A custom domain, such as `quests.example.com`, serves your live app, so members visit your community at an address you own. An organization admin sets it up, signed in to Console or the CLI; API keys cannot. Anyone with `read` can see its status. [Custom domains](/docs/concepts/hosting#custom-domains) explains how it works.

### 1. Add the domain [#1-add-the-domain]

In Console, open **Settings → Domains**, enter the hostname, and choose **Add domain**. Or run:

```sh
pnpm exec domino domains add quests.example.com
```

Attach a subdomain. A root domain such as `example.com` is refused: attach `www.example.com` and redirect the root to it at your DNS provider. A project has one custom domain. Where plans apply, the project needs the Growth plan or above.

### 2. Add the two DNS records [#2-add-the-two-dns-records]

The domain shows both records. Add them at your DNS provider:

| Type    | Name                         | Value                                     |
| ------- | ---------------------------- | ----------------------------------------- |
| `CNAME` | `quests.example.com`         | `cname.domino.page`                       |
| `TXT`   | `_domino.quests.example.com` | `domino-verify=…`, the value Domino shows |

If the domain has CAA records, they must allow `letsencrypt.org` and `pki.goog`.

### 3. Wait for HTTPS [#3-wait-for-https]

Domino checks the records on its own. To check now, choose **Re-check** in Console or run:

```sh
pnpm exec domino domains recheck quests.example.com
```

Once Domino finds the TXT record, the domain shows **Setting up HTTPS** while Cloudflare validates it and issues its certificate, usually within minutes. HTTPS to the domain fails until it shows **Active**. Then `https://quests.example.com` serves your live app, and once a build is live, page loads on `<project-slug>.domino.page` redirect there.

### Manage the domain [#manage-the-domain]

* **Needs attention.** The domain says what to fix: a record that changed, a certificate Cloudflare could not issue, or setup that did not finish within 7 days. Fix it, then re-check.
* **A proxied or flattened CNAME** can show as not found in public DNS while the domain works. Cloudflare's statuses are what decide **Active**.
* **Removing it.** Attaching it again, to this or another project, needs the new TXT record it shows. Remove the records at your DNS provider when you no longer need them.
* **Not enabled.** A Domino deployment without custom domains answers "Custom domains aren't enabled on this deployment yet" (`custom-domains-unavailable`).

| Command                         | Does                                                                                                       |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `domino domains list`           | Lists the project's domains and their state                                                                |
| `domino domains get DOMAIN`     | Shows each DNS record against what public DNS returned, Cloudflare's statuses, and when Domino checks next |
| `domino domains add HOSTNAME`   | Attaches a hostname and shows its DNS records                                                              |
| `domino domains recheck DOMAIN` | Checks now, at most every 10 seconds                                                                       |
| `domino domains remove DOMAIN`  | Detaches the domain; it stops serving within about a minute                                                |

`DOMAIN` is the hostname or the domain's ID. The commands work the same whichever environment is selected, because a custom domain always serves live. To follow a domain, use `domino domains get`: Domino checks on its own.

> [!DEV] Limits and management API
> Adding, re-checking and removing share a budget of 30 per organization per hour; past it they answer `rate-limited`.
>
> Management API clients use `GET /management/v1/domains`, `POST /management/v1/domains` with `{ "hostname": "…" }`, `GET /management/v1/domains/ID`, `POST /management/v1/domains/ID/recheck` and `DELETE /management/v1/domains/ID`, or `domains.list()`, `domains.add(hostname)`, `domains.get(id)`, `domains.recheck(id)` and `domains.remove(id)` on `createManagementClient` from `@domino-sdk/relay/server`.

## Call the management API from your server [#call-the-management-api-from-your-server]

Your app's server can award or spend points, read leaderboards, referrals, members and balances, and send records to custom trackers. Domino gives every staged and live Worker app, and the dev server `domino dev` starts, a management key for its own project and environment, so nobody pastes a credential.

| Binding                 | Value                                                    |
| ----------------------- | -------------------------------------------------------- |
| `DOMINO_MANAGEMENT_KEY` | The app's key, a secret beginning `dhk_`                 |
| `DOMINO_API_URL`        | The Domino API origin                                    |
| `DOMINO_ORGANIZATION`   | The project's organization                               |
| `DOMINO_PROJECT`        | The project                                              |
| `DOMINO_ENVIRONMENT`    | `test` for previews and staging, `live` for the live app |

Build a client from them with `hostedManagementClient` from `@domino-sdk/relay/server`. It runs in a Worker without `nodejs_compat`, and throws an error naming any binding that is missing.

```ts
import { hostedManagementClient } from "@domino-sdk/relay/server";

export async function awardSignupBonus(
  env: Record<string, string | undefined>,
  member: string,
) {
  const management = hostedManagementClient(env);

  return management.points.record({
    kind: "award",
    actionId: `signup-bonus-${member}`,
    member,
    balance: "community",
    amount: 50,
    reason: "Completed sign-up",
  });
}
```

* **Read, points, and ingest only.** The key has exactly the `read`, `points`, and `ingest` permissions in one environment of its project: test for staging and development, live for the live app. `ingest` sends records to custom trackers with `management.trackers.ingest`; it cannot enroll members. `management.access()` lists all three. The key cannot publish, edit quests, review, hand over rewards, or manage access. Qualifying referrals still needs a key with `publish`.
* **Server code only.** Keep it in your Worker. Never send it to a browser or put it in member-facing code.
* **Rotated for you.** Each staged build gets its own key. When a newer build becomes the active preview, older builds' keys stop working; a failed build's key is revoked immediately. Each live deployment gets its own live key, and the previous live build's key stops working when the new one starts serving. Staging and live keys rotate separately. The key never appears in build artifacts, logs, or build status.
* **Short-lived in development.** `domino dev` creates a key that expires after 12 hours and revokes it when development stops. It needs your own `read`, `points`, and `ingest` permissions in the test environment, which editors have. Without them, the dev server starts without the key and the CLI prints why.
* **Recorded as the app.** Point entries and audit records name the app (`hosted:<repository ID>`, or `hosted:development:<key ID>` during development), not a person.

The Cloudflare Vite plugin passes only the values your `vite.config.ts` lists to the local Worker. [`dominoHosting()`](#run-the-dev-script-on-the-preview-port) passes all of them. Without it, add these entries to the `vars` that `cloudflare({ config })` receives when `command === "serve"`:

```ts
export const developmentVars = {
  DOMINO_MANAGEMENT_KEY: process.env.DOMINO_MANAGEMENT_KEY ?? "",
  DOMINO_API_URL: process.env.DOMINO_API_URL ?? "",
  DOMINO_ORGANIZATION: process.env.DOMINO_ORGANIZATION ?? "",
  DOMINO_PROJECT: process.env.DOMINO_PROJECT ?? "",
  DOMINO_ENVIRONMENT: process.env.DOMINO_ENVIRONMENT ?? "",
};
```

Keep any variables your configuration already forwards, such as `DOMINO_PARTICIPANT_API`. Static apps have no server code and receive no key. Code that must behave differently in live can read `DOMINO_ENVIRONMENT`.

## Tell test from live [#tell-test-from-live]

Previews and staging run on test data, and the live app on live data, from the same build. If something should only ever appear while you test, such as a "skip to tomorrow" button, your app asks at runtime where it is running. Don't guess from the address: hosts have moved before.

| Where the app runs                   | Ask with                                                | Answer                             |
| ------------------------------------ | ------------------------------------------------------- | ---------------------------------- |
| In the browser, static or Worker app | `getHostEnvironment()` from `@domino-sdk/relay/browser` | `{ environment, kind }`, or `null` |
| In a Worker's server code            | The `DOMINO_ENVIRONMENT` binding                        | `test` or `live`                   |

`getHostEnvironment()` asks the page's host for `/__domino/environment` once per page load and shares the answer between calls:

| Host                                                            | `environment` | `kind`    |
| --------------------------------------------------------------- | ------------- | --------- |
| A `domino dev` preview                                          | `test`        | `preview` |
| Staging                                                         | `test`        | `staging` |
| The live app, at its `domino.page` address or its custom domain | `live`        | `live`    |

It resolves to `null` where the host does not answer, such as a local dev server or a host outside Domino, and during server rendering. Choose what `null` means for your app:

```ts
import { getHostEnvironment } from "@domino-sdk/relay/browser";

const host = await getHostEnvironment();
const showTestTools = host?.environment === "test";
```

Domino answers `/__domino/environment` itself on every hosted address, so your app never receives that path. On staging it answers only visitors with [staging access](/docs/build/staging#control-who-can-open-staging), like any page. On the live app, other `/__domino/` paths still reach your app.

## Run scheduled jobs [#run-scheduled-jobs]

Your app can run background jobs on a timer, such as refreshing data every 15 minutes. Worker apps declare them as `app.schedules` beside secrets and storage. Relay sends signed POST requests to the serving build in each environment: the staged build in test and the live app in live. Verify them with `verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY)`. Use **Settings → Schedules** or `domino schedules list` to inspect jobs and `domino schedules run NAME` to queue one; add `--environment live` for the live app's. A `domino login` session can't run or control live jobs, so use Console or an API key for those. See [Run scheduled jobs](/docs/build/schedules) for interval and cron declarations, limits, custom tracker ingestion and recovery.

## Build outputs [#build-outputs]

These are the rules a build must follow. Worker apps run their configured check and build scripts in the same isolated builder as static apps.

* **Package manager.** Use a pinned pnpm 10 version in `packageManager` and commit the lockfile.
* **Build output.** The build script must emit the compiled modules and optional public assets configured in [the app section](#configure-the-app-section), plus `.domino-build/quests.json` and `.domino-build/catalog.json`.
* **Resource artifacts.** `domino build --hosting-artifacts` writes those private resource artifacts to `.domino-build/` beside `relay.json`. The starters' build script is `vite build && domino build --hosting-artifacts`; add the same step when adapting an existing app.

### What the server output may contain [#what-the-server-output-may-contain]

* **Accepted modules.** JavaScript ES modules, Wasm, text modules (including CSS, JSON, and HTML as strings), and binary modules. Bundle package dependencies during your framework build.
* **Excluded files.** Generated `wrangler.json`, source maps, and the server's `.vite` metadata directory. Deployment settings come from `relay.json`, not generated Wrangler configuration.
* **Public assets.** The public directory honors `.assetsignore`. Server and public directories must be separate; neither may contain the other. Only public assets receive browser URLs.

| Limit                                              | Value |
| -------------------------------------------------- | ----- |
| Files per directory                                | 500   |
| Size per directory                                 | 10 MB |
| Modules and public assets, base64-encoded together | 20 MB |
| Quest and catalog code (`.domino-build`), as JSON  | 64 MB |
| Quest and catalog code, compressed                 | 10 MB |

Quest and catalog code does not count toward the 20 MB. The builder compresses it separately. When the installed SDK ships a [shared SDK runtime](/docs/concepts/quests#shared-sdk-runtime), quests and quest types import it, carried once per build, instead of each bundling the SDK. Other dependencies are bundled into each quest, tracker, leaderboard, and referral that imports them, and compress well when they repeat. A build over a limit fails in its pack step and names what to reduce.

### How requests reach your Worker [#how-requests-reach-your-worker]

The builder uploads the modules and assets to an isolated Worker in the dispatch namespace.

| Setting                    | Behavior                                                                                                                                                                                                                                  |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default                    | Matching assets are served first; other requests reach your Worker.                                                                                                                                                                       |
| `app.runWorkerFirst: true` | Your Worker handles every request, including requests matching public assets. Your handler can serve those through `env.ASSETS.fetch(request)`. The starter selects this mode and owns its participant proxy and SPA fallback explicitly. |

API-only Workers need no `index.html`. Static apps retain their SPA fallback.

Each Worker build also receives its own management key and project scope as `DOMINO_` bindings, for server-side point writes and reads. Activating a newer build revokes older builds' keys in that environment. See [Call the management API from your server](#call-the-management-api-from-your-server).

### Activation [#activation]

* **All at once.** Activation switches the deployed application as a unit. Failed builds preserve the previous staging version.
* **Routing per commit.** With `app.routing: "transparent"`, the gateway returns app responses unchanged, including cache headers, cookies, and HTML. Omitting `app.routing` preserves legacy gateway behavior. The selected commit determines routing for each deployment.
* **Live is a separate deployment.** Staging serves test. An approved promotion deploys the same build again for live, with live's key, secrets, stores, and schedule key, and the live app switches to it when that deployment succeeds. See [How test and live hosting relate](/docs/concepts/hosting#how-test-and-live-hosting-relate).

## Move to a newer starter [#move-to-a-newer-starter]

A hosted project keeps the starter it was created from. To rebuild it on the latest version of a starter, export that starter's files and integrate them into your checkout on a branch.

1. **Export the starter.** From your checkout, so the command uses its project, run `domino starters export campaign --directory ../next-starter`. It writes the files `domino create` would start a new project with today into a new or empty directory, and prints the commit they were built from. A directory that already has files is refused.
2. **Integrate on a branch.** Start a branch from the latest `main` in your checkout, copy in the exported files, and carry over your own quests, copy, and design. Keep the exported `.domino/starter.json`: `domino doctor` reads it to report which starter the project now comes from.
3. **Install and check.** Install dependencies with the package manager version that `package.json` pins, then run `domino doctor` and the app's checks.
4. **Stage it.** Merge into `main` and stage the result, as the [source and release workflow](/docs/reference/hosted#source-and-release-workflow) describes.

The export is the starter as a new project receives it: it has no project name or brief, and nothing is installed. Only the current version of each starter can be exported. Any member who can read the project can export.

## Where next [#where-next]

- [Develop and share a preview](/docs/build/development) — run the app against hosted test data with `domino dev`.
- [Stage and share](/docs/build/staging) — build a pushed commit into the staging app.
- [Store app data](/docs/build/storage) — KV namespaces and SQL databases for your Worker.
