Set up your hosted app
Configure the community site Domino hosts for you: its setup file, app secrets, your own domain, and what its server can do with points.
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.
- Use your own domain, such as
quests.example.com, under Settings → Domains. See 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.
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 in Console.
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:
{
"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 |
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 |
vars | Worker | Non-secret string variables |
secrets | Worker | Secret declarations, without values |
storage | Worker | KV and SQL stores |
schedules | Worker | Scheduled jobs |
- 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
appfields this table does not name.
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:
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.serverlistens onDOMINO_APP_PORT(5173 when unset) and refuses another port. WithDOMINO_PREVIEW_ORIGINset, it accepts that hostname and runs hot reload over HTTPS through it.domino.cloudflarebuilds the Worker in Vite'sserverenvironment. When serving, it passes the variablesdomino devsets 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
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'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 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
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:
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.runcarries 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 instead.
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:
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 instead.
Migrate to transparent routing
- If your app uses Domino sign-in, add the app-owned participant handler.
- Set
app.routingto"transparent"inrelay.json. - Restart
domino devwith the updated CLI, and verify sign-in, a member action, and your app routes. - 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
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 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 and DOMINO_SCHEDULE_KEY for 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.
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.
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.
{
"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": falsefor a secret the app works without. - One kind per name. A name cannot be both a
varsentry and a declared secret. Static apps cannot declare secrets. - Up to 50. Descriptions have 1 to 500 characters.
Set the values and stage
In Console, open Settings → App secrets. Or run:
openssl rand -hex 32 | pnpm exec domino secrets set APP_API_KEY
pnpm exec domino stagedomino 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.
Set live's values before promoting
In Console, select Live and open Settings → App secrets. Or run:
printf %s "$APP_API_KEY" | pnpm exec domino secrets set APP_API_KEY --environment liveWithout --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
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 stagewarns about each required declared secret that is not set, shows the command that sets it, and addsmissingSecretsto its--jsonresult. The build still stages; routes that need the secret fail until you set it and stage again. - Declarations are merged.
domino secrets listcombines the active build's declarations with your checkout'srelay.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 stageagain 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 appvarsentry of the same name, declared or not. - Not in development.
domino devdoes not receive hosted secrets. Give the local Worker its own value, for example in a git-ignored.dev.varsfile, 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 andASSETSare reserved. Values are 1 byte to 5 KiB, and each project environment holds at most 50. - Permissions. Setting or deleting needs
publishin the environment, and listing needsread. 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.
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 explains how it works.
Add the domain
In Console, open Settings → Domains, enter the hostname, and choose Add domain. Or run:
pnpm exec domino domains add quests.example.comAttach 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.
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.
Wait for HTTPS
Domino checks the records on its own. To check now, choose Re-check in Console or run:
pnpm exec domino domains recheck quests.example.comOnce 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
- 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.
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.
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, andingestpermissions in one environment of its project: test for staging and development, live for the live app.ingestsends records to custom trackers withmanagement.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 withpublish. - 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 devcreates a key that expires after 12 hours and revokes it when development stops. It needs your ownread,points, andingestpermissions 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>, orhosted: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() passes all of them. Without it, add these entries to the vars that cloudflare({ config }) receives when command === "serve":
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
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:
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, like any page. On the live app, other /__domino/ paths still reach your app.
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 for interval and cron declarations, limits, custom tracker ingestion and recovery.
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
packageManagerand commit the lockfile. - Build output. The build script must emit the compiled modules and optional public assets configured in the app section, plus
.domino-build/quests.jsonand.domino-build/catalog.json. - Resource artifacts.
domino build --hosting-artifactswrites those private resource artifacts to.domino-build/besiderelay.json. The starters' build script isvite build && domino build --hosting-artifacts; add the same step when adapting an existing app.
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.vitemetadata directory. Deployment settings come fromrelay.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, 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
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.
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. Omittingapp.routingpreserves 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.
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.
- Export the starter. From your checkout, so the command uses its project, run
domino starters export campaign --directory ../next-starter. It writes the filesdomino createwould 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. - Integrate on a branch. Start a branch from the latest
mainin your checkout, copy in the exported files, and carry over your own quests, copy, and design. Keep the exported.domino/starter.json:domino doctorreads it to report which starter the project now comes from. - Install and check. Install dependencies with the package manager version that
package.jsonpins, then rundomino doctorand the app's checks. - Stage it. Merge into
mainand stage the result, as the 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.