Container demos
The build contract, Dockerfiles that keep to it, and demo routes.
On this page
- How a container demo runs
- The build contract
- Each rule, and why
- A Dockerfile at the root
- The last stage builds in /app
- Node 24
- A CMD that starts the server
- It listens on PORT and HOST
- A health route
- Nothing installs when it starts
- Mock data only, and no secrets
- It runs as the node user
- What pitch validate checks
- A Node server
- A Next.js app
- A Vite app and its API
- Several apps on one origin
- Mock data and seeding
- Health checks
- Demo routes
- Reset
- Scenarios
- Story calls
- What the room shows
- The rehearsal
- The secret scan
- A viewer's copy
- Starting
- Staying awake
- When it is full
- When containers are stopped
- Limits
- Common failures
A container demo is a server: an API and the apps it serves, like Bullring's four apps behind one origin. Each viewer link gets a copy of its own, started when the viewer opens the room and fresh each time it starts, so nobody breaks the demo for anyone else and a scenario can change anything.
This page covers the build contract and why each line of it exists, Dockerfiles that keep to it for the common shapes of demo, how mock data is seeded, the routes the room can call, the rehearsal, and what a viewer's copy does from the moment they arrive to the moment it stops. If the demo is a folder of built files with no server behind it, a static demo is simpler and lighter.
How a container demo runs
The platform does not run your image. It runs one runner image of its own,
Node 24 and a small supervisor, and hands it your build: the /app folder of
the image your Dockerfile makes, and the command that starts it.
pitch deploybuilds your Dockerfile forlinux/amd64, reads the finished image'sCMD,ENTRYPOINTandENV, and copies its/appfolder out as a gzipped tar: the bundle.pitch deploy --commituploads the bundle. The platform checks it is gzip and stores it; it never unpacks it.- When a viewer opens the room, their link's copy starts a container of the
runner, streams the bundle into it, unpacks it into
/app, runs the start command, and waits for the health route to answer. - The room shows the demo once it answers, and every request the viewer's browser makes to the demo goes to that copy and no other.
That is why the contract below is short, and why each line matters: anything
your image has outside /app, and anything your server expects to fetch or
install when it starts, is not there when it runs.
The build contract
A container build keeps to all of these. pitch deploy refuses a build that
breaks the ones it can see, and the rehearsal runs the build exactly as a
viewer's copy will, so the ones it cannot see fail there instead of in front
of a prospect.
- A Dockerfile at the root of the project.
pitch deploybuilds it forlinux/amd64, which is what the platform runs, whatever your machine is. - The last stage builds in
WORKDIR /app, and everything the demo needs is inside/appwhen it finishes: the built apps and the productionnode_modules. Only/appis taken; nothing else in the image comes along, so a system package installed withapt-getis not there when it runs. - It runs on Node 24. The runner is Node 24 whatever your base image is. A native module built against another Node may not load.
- A
CMDsays how it starts, in exec form:CMD ["node", "server.js"]. Without one, a Node image's ownCMDstarts a Node prompt, not your server.pitch deployreads theCMD(and anyENTRYPOINT) and theENVfrom the image and records them asrunin the manifest. - It listens on
PORT, on the hostHOST. The runner sets both. Serve every app and the API from that one port, each app under its own path. - A health route answers 200 once it is up:
/healthzunlesshealthinpitch.jsonsays otherwise. The runner waits up to 90 seconds for it before the viewer sees anything, and the rehearsal fails a build that has not answered by then. - Nothing installs when it starts. A demo's container has no internet. A
CMDthat runsnpm installornpxfails; install in the Dockerfile. - Mock data only, and no secrets. The project, the build and its
ENVare scanned for secrets before anything leaves your machine, and a finding stops the deploy. A key the demo seems to need is a mock the demo should have. - It runs as the
nodeuser, and owns/appand nothing else.
Each rule, and why
pitch init copies the list above into the PITCH.md it writes, so the agent
building your demo keeps to the same rules (Build with your AI
agent). Here is what each one means in practice.
A Dockerfile at the root
The Dockerfile sits beside pitch.json. pitch deploy builds it with
docker build --platform linux/amd64, whatever your machine is, because that
is what the platform runs. A native binary in node_modules (esbuild, a
SQLite driver) must match the machine that runs it, not the one that built it.
The last stage builds in /app
The last stage of the Dockerfile has WORKDIR /app, and everything the demo
needs is inside /app when it finishes: the built apps, the server, and the
production node_modules. Only /app is taken. Nothing else in the image
comes along, so a package installed with apt-get, a global npm install -g,
or a file copied to /opt is not there when the demo runs.
pitch deploy stops if the image's working directory is anything else:
FAIL the image's WORKDIR is /usr/src/app. The runner unpacks a build into /app, so the Dockerfile's last stage needs WORKDIR /app.Node 24
The runner is built on node:24.21.0-slim, a Debian image, and runs every
build on its own Node 24 whatever your base image is. Build on node:24-slim
(or node:24) so that anything compiled during the build is compiled for the
Node and the C library it will run on. A native module built against another
Node, or against Alpine's C library, may not load.
A CMD that starts the server
The last stage has a CMD, in exec form:
CMD ["node", "server.js"]pitch deploy reads the ENTRYPOINT and CMD from the image and records them
as run.start in the manifest it uploads. The official Node images'
docker-entrypoint.sh is dropped, since it only runs its arguments and the
runner has its own Node. The command runs in /app: a program named with a
path (server/index.js) is found inside /app, and a bare name (node) on
the runner's PATH.
Without a CMD of your own, a Node image's CMD starts a Node prompt, not
your server. Use the exec form, as above: the shell form (CMD node server.js) is recorded as /bin/sh -c "node server.js", one more process
between the runner and your server.
The image's ENV lines are recorded too, as run.env, except PATH, HOME,
PORT, HOST, NODE_VERSION and YARN_VERSION, which the runner sets or
which only describe your base image. A run you write in pitch.json
yourself is used as it is instead (run).
It listens on PORT and HOST
Besides PATH and HOME, the runner gives the build two variables of its
own, PORT and HOST: today PORT is 8080 and HOST is 0.0.0.0. The
build's environment is those and the run.env recorded from your image,
nothing more. Read PORT and HOST rather than hard-coding either. Listen on
HOST: a server bound to 127.0.0.1 answers inside its container and nowhere
else.
Serve every app and the API from that one port, each app under its own path (Several apps on one origin).
A health route
A path that answers with a 2xx status once the server can serve the demo:
/healthz, unless health in pitch.json names another
(health). The runner asks it every 50
milliseconds after starting your command and waits up to 90 seconds. Until it
answers, the viewer sees "Starting the demo…".
Nothing installs when it starts
A demo's container has no internet. A CMD that runs npm install or npx
fails, and so does a server that fetches anything from the internet as it
starts or while it runs: a font, a model, a remote image, a live API. Install
and download everything in the Dockerfile.
Mock data only, and no secrets
Every viewer gets a copy of whatever is in /app, so nothing in it may be
real. The project, the bundle and its ENV are scanned for secrets before
anything leaves your machine, and a finding stops the deploy (The secret
scan). A key the demo seems to need is a mock the demo
should have.
It runs as the node user
The runner runs your command as the node user, never root. node owns
/app and nothing else, so the demo can write files inside /app (a SQLite
file, an upload folder) and nowhere it would need root for.
What pitch validate checks
pitch validate reads the Dockerfile without building it and notes what looks
wrong in its last stage:
- it has no
FROM; - its
WORKDIRis not/app; - it has no
CMDorENTRYPOINT; - its
CMDinstalls or fetches packages (npm install,npm ci,pnpm add,yarn install,bun install,npxand the like); - its base is
node:<major>for a major other than 24.
Each of these is a note, not a refusal: the build itself is what decides. A
missing Dockerfile is a problem, and stops both pitch validate and pitch deploy:
ok manifest: pitch.json validates (container, 1 app)
note Dockerfile: the last stage has no CMD. A node image’s own CMD starts a Node prompt, not your server: say how it starts
https://getroomi.com/docs/containersA Node server
The smallest shape: one server, no dependencies, its page and its API on one port. A kitchen display for a restaurant, with a reset and one scenario.
// server.js
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
// The seed: what every copy of the demo starts from.
const seed = () => ({
orders: [{ id: 1, table: 4, dish: 'Tonkotsu ramen', status: 'cooking' }],
})
let db = seed()
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
createServer(async (req, res) => {
const { pathname } = new URL(req.url, 'http://demo')
if (pathname === '/healthz') return res.end('ok')
if (req.method === 'GET' && pathname === '/api/orders') return json(res, 200, db.orders)
if (req.method === 'POST' && pathname === '/api/demo/reset') {
db = seed()
return json(res, 200, { message: 'Back to one order, cooking.' })
}
if (req.method === 'POST' && pathname === '/api/demo/rush') {
for (let table = 5; table < 10; table++)
db.orders.push({ id: db.orders.length + 1, table, dish: 'Gyoza', status: 'new' })
return json(res, 200, { message: 'Five orders have come in.' })
}
if (req.method === 'GET' && pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
return res.end(await readFile(new URL('./index.html', import.meta.url)))
}
res.writeHead(404).end()
}).listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? '0.0.0.0')With "type": "module" in its package.json, the Dockerfile is four lines,
because there is nothing to install:
FROM node:24-slim
WORKDIR /app
COPY package.json server.js index.html ./
CMD ["node", "server.js"]And its pitch.json:
{
"$schema": "https://getroomi.com/schema/pitch.json",
"name": "Pass the Ramen",
"kind": "container",
"health": "/healthz",
"apps": [{ "id": "kitchen", "name": "Kitchen display", "path": "/", "device": "screen" }],
"controls": {
"reset": "POST /api/demo/reset",
"scenarios": [
{
"id": "rush",
"label": "Friday rush",
"description": "Five orders arrive at once.",
"call": "POST /api/demo/rush"
}
]
}
}A server with dependencies installs them in the Dockerfile and keeps only the production ones:
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
CMD ["node", "server/index.js"]Add a .dockerignore that keeps node_modules, .git and any .env file out
of COPY . .. Your machine's node_modules were built for your machine, and
a .env copied into /app ships in the bundle, where the secret scan stops
the deploy.
A Next.js app
A Next app that needs its server (API routes, server components rendered on request, server actions) is a container demo. A Next app that can be exported as files is simpler as a static demo.
Build it with standalone output, which writes a self-contained server and only the dependencies it uses:
// next.config.mjs
export default { output: 'standalone' }Standalone output leaves the static assets and public for you to copy beside
the server, so the last stage puts all three in /app:
FROM node:24-slim AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
COPY --from=build /src/.next/standalone ./
COPY --from=build /src/.next/static ./.next/static
COPY --from=build /src/public ./public
CMD ["node", "server.js"]The standalone server reads PORT, which the runner sets, and HOSTNAME
rather than HOST, so the Dockerfile sets HOSTNAME itself; it is recorded
in run.env and passed on. Telemetry is turned off because the container
cannot reach the internet to send it.
Give it a health route, as a route handler:
// app/healthz/route.ts
export function GET() {
return new Response('ok')
}{
"$schema": "https://getroomi.com/schema/pitch.json",
"name": "Harbour Books",
"kind": "container",
"health": "/healthz",
"apps": [{ "id": "app", "name": "Harbour Books", "path": "/", "device": "laptop" }]
}next start works too, if the last stage keeps the whole .next folder and
production node_modules with next in dependencies. Start it through Node
rather than through npm:
CMD ["node", "node_modules/next/dist/bin/next", "start"]Anything the app fetches at request time from the internet (a remote image
through the image optimiser, a live API) fails in the room. Fonts from
next/font are downloaded at build time and are fine.
A Vite app and its API
A single-page app built with Vite, and a small API beside it. In development
Vite serves the app and proxies /api to the server; in the container the
server serves both, from one port.
// server/index.js
import express from 'express'
import { join } from 'node:path'
import { seed } from './seed.js'
const dist = join(import.meta.dirname, '..', 'dist')
let db = seed()
const app = express()
app.use(express.json())
app.get('/healthz', (_req, res) => res.send('ok'))
app.get('/api/invoices', (_req, res) => res.json(db.invoices))
app.post('/api/demo/reset', (_req, res) => {
db = seed()
res.json({ message: 'Back to the start of the month.' })
})
// The app's built files, then its own deep links answered with its index.html.
app.use(express.static(dist))
app.use((req, res, next) => (req.method === 'GET' ? res.sendFile(join(dist, 'index.html')) : next()))
app.listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? '0.0.0.0')The app calls its API by path (fetch('/api/invoices')), never by a host and
port, so the same code works under Vite's proxy and in the room.
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
CMD ["node", "server/index.js"]express is in dependencies and vite in devDependencies, so npm prune --omit=dev keeps the one the server needs and drops the one it does not.
Several apps on one origin
A room loads one origin, so a demo whose apps each run on their own dev port is not deployable as it is. The change is the one Bullring made:
- One registry of the apps and their paths, read by everything: the
server, each app's build, and
pitch init. - Each app built with its base path, so its assets resolve under it.
- The server serving each app's built files under its path, beside its
own
/apiroutes, with onebuildscript that builds every app. - A Dockerfile that builds the apps and runs that server on
PORT, with a health route.
pitch init spots the separate ports, prints this change, and writes nothing;
it never edits your code. Run it again once the apps share a server.
The registry, as Bullring keeps it in packages/shared/src/apps.ts, where
pitch init reads it:
export const APPS = {
client: { base: '/', name: 'Bullring' },
coach: { base: '/coach/', name: 'Bullring Coach' },
hq: { base: '/hq/', name: 'Corner HQ' },
arena: { base: '/arena/', name: 'Arena' },
} as constEach app's Vite config sets base to its path:
// apps/coach/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ base: '/coach/', plugins: [react()] })The server mounts each app's dist under its path, the app at / last so it
does not answer for the others:
// server/index.js
import express from 'express'
import { join } from 'node:path'
import { seed } from './seed.js'
const APPS = [
{ id: 'coach', base: '/coach/' },
{ id: 'hq', base: '/hq/' },
{ id: 'arena', base: '/arena/' },
{ id: 'client', base: '/' },
]
let db = seed()
const app = express()
app.use(express.json())
app.get('/healthz', (_req, res) => res.send('ok'))
app.get('/api/members', (_req, res) => res.json(db.members))
app.post('/api/demo/reset', (_req, res) => {
db = seed()
res.json({ message: 'Back to Tuesday, 06:00. Nobody has checked in.' })
})
for (const { id, base } of APPS) {
const dist = join(import.meta.dirname, '..', 'apps', id, 'dist')
app.use(base, express.static(dist))
app.use(base, (req, res, next) => (req.method === 'GET' ? res.sendFile(join(dist, 'index.html')) : next()))
}
app.listen(Number(process.env.PORT ?? 8080), process.env.HOST ?? '0.0.0.0')The Dockerfile builds every workspace and keeps the production dependencies:
FROM node:24-slim AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build && npm prune --omit=dev
FROM node:24-slim
WORKDIR /app
COPY --from=build /app /app
ENV NODE_ENV=production
CMD ["node", "server/index.js"]And pitch.json names each app, its path and the device the room frames it
in:
{
"$schema": "https://getroomi.com/schema/pitch.json",
"name": "Corner for Bullring",
"kind": "container",
"health": "/healthz",
"apps": [
{ "id": "client", "name": "Bullring", "path": "/", "device": "phone", "default": true },
{ "id": "coach", "name": "Bullring Coach", "path": "/coach/", "device": "tablet" },
{ "id": "hq", "name": "Corner HQ", "path": "/hq/", "device": "laptop" },
{ "id": "arena", "name": "Arena", "path": "/arena/", "device": "screen" }
],
"controls": { "reset": "POST /api/demo/reset" },
"pages": ["docs/why-corner.md", "docs/corner-architecture.html"]
}The room opens each app at its path on the demo's origin. Up to 12 apps can
be declared.
A demo with its own password gate needs a way to run without it: the room is the gate, and a copy that answers its health route only after a sign-in never becomes ready. Bullring's server refuses to start ungated in production, so it needs a switch for running behind a room.
Mock data and seeding
A copy starts from the bundle every time it starts, so the simplest seed is the one most demos already have: data built in memory when the server starts, and built again by the reset route. The examples above keep it in a variable and replace it on reset.
A copy starts:
- when a viewer first opens their link, and again for each new build that goes live (a copy belongs to one build and one link);
- when a copy that stopped is resumed ("Resume the demo", or opening the room again later).
Within a visit the state carries on: every tab, app and phone the viewer opens from their link reaches the same copy, which is how an order placed on the phone shows up on the kitchen screen. A viewer who reloads the room a minute later finds what they left. A viewer who comes back the next day finds a fresh copy, because theirs stopped when they left (A viewer's copy).
Because the copy is the viewer's alone, the demo does not need accounts or sessions to keep viewers apart. A demo that signs a user in can sign every visitor in as the same mock user.
Files work too: the demo can write inside /app, and a SQLite file created
when the server starts is new in every copy. A native SQLite driver has to be
built in the Dockerfile, for linux/amd64 and Node 24, as the build contract
already arranges.
A demo that depends on the time of day (a morning check-in, a match at 19:30)
is easier to tell with a clock of its own that a route can set, as Bullring's
POST /api/demo/clock does, than with the real time, which differs for every
viewer.
Health checks
The health route decides when the viewer sees the demo. The runner starts your command, then asks the health path every 50 milliseconds until it answers with a 2xx status, for up to 90 seconds. Make it answer only once the server can serve a page and the seed is in place: a route that answers before the data is loaded shows the viewer an empty demo.
- It is a plain
GETfrom inside the container, with no cookies. - It must not be behind a sign-in.
- The rehearsal checks it twice: through the runner, and again through the container's port as a viewer's request would reach it.
If it never answers, the rehearsal stops with the runner's last lines of output:
FAIL the runner could not start the build: /healthz did not answer 200 within 90000 ms. Its last words:
Listening on http://localhost:3000Here the server listened on its own port, 3000, rather than PORT.
For a viewer, a copy that is not ready 90 seconds after it started has failed to start: it is stopped, and the stage says "The demo could not start." with a "Try again" button.
Demo routes
The room can call routes on the viewer's copy: a reset (controls.reset), the
launcher's buttons (controls.scenarios), and each step's run in the
story. They are ordinary routes on your server, declared as
"METHOD /path", and called on the build's own origin.
"controls": {
"reset": "POST /api/demo/reset",
"scenarios": [
{ "id": "rush", "label": "Friday rush", "description": "Five orders arrive at once.", "call": "POST /api/demo/rush" },
{ "id": "evening", "label": "Evening service", "call": "POST /api/demo/clock", "body": { "at": "19:30" } }
]
}The calls are made by the room's server, not the viewer's browser. So:
- only calls the live build declares are ever made: the room sends the id of a control or a step, never a path;
- each call carries
content-type: application/jsonand a JSON body: the declaredbody, or{}when there is none. AGETcall has no body; - the viewer's cookies are not sent. The route acts on the copy, which is the viewer's own, so it needs no session to know whose demo to change;
- there is no CORS to configure, because no browser makes the call.
Reset
controls.reset puts the copy back to its seeded start. The room shows it as
Reset the demo at the foot of the rail. When it succeeds, every app the
viewer has open reloads fresh on its home screen, and the rail's steps are
cleared.
Scenarios
controls.scenarios are the launcher's buttons, lifted into the room: up to
20, each one call, with a label of up to 60 characters and an optional line
under it of up to 200. With a story, the room lists them under More to try;
without one, under Drive the story. A story is the better
way to tell the demo; scenarios are still honoured.
Story calls
Each step of the story can make up to 8 calls, in order, before it shows. The calls stop at the first one the demo refuses, and the step says why. A viewer can start at any step, so a step sets up what it needs itself. Writing the tour covers writing one; here is a container's, with the calls that make it work.
Bullring is a gym's coaching product: a phone app for the member, an iPad for the coach on the gym floor, a console for head office (HQ) and a wall screen for competition nights, all one server, with demo routes that set the demo's clock and play a scenario. Its story tells one member's week in four steps:
"story": [
{
"id": "morning",
"title": "Tom checks in",
"say": "Tuesday, 06:41. Before his session Tom tells Bullring how he slept and what is on his mind: a board meeting on Thursday, shared with his team.",
"app": "client",
"run": [
{ "call": "POST /api/demo/clock", "body": { "preset": "morning" } },
"POST /api/demo/scenario/morning"
],
"point": ".cl-checked"
},
{
"id": "coach",
"title": "Callum already knows",
"say": "On the gym-floor iPad, Callum's day opens on Tom's session, with his check-in in it before Tom walks in.",
"app": "coach",
"point": ".co-page .br-panel"
},
{
"id": "flag",
"title": "Tom flags his shoulder",
"say": "07:18, mid-session: a pinch at the top of the press. Callum flags it, and before the set is over HQ's copilot has Tom near his threshold.",
"app": "hq",
"run": [
{ "call": "POST /api/demo/clock", "body": { "preset": "session" } },
"POST /api/demo/scenario/flag"
],
"point": ".hq-copilot"
},
{
"id": "arena",
"title": "Arena night: heat 2 goes live",
"say": "Thursday evening at the gym. The judges' splits come in and the wall moves with every station.",
"app": "arena",
"run": [
{ "call": "POST /api/demo/clock", "body": { "preset": "arena" } },
"POST /api/demo/scenario/arena_live"
],
"point": ".ar-body"
}
]- Every step that runs anything sets the clock first. The flag scenario
refuses unless Tom's session has started, which it has only at the
sessionpreset. A viewer who jumps straight to step 3 from the outline still sees it work, because the step sets its own time. - Step 2 runs nothing. Tom's check-in from step 1 is already on the coach's iPad; the step only moves the stage there and says what to notice.
- Each step is seen where it lands. The flag is raised by the coach, but
the step is in
hq, because what matters is that head office sees it. pointnames classes Bullring's own code gives (.hq-copilot), never a generated one.
When a step's calls have run, its app reopens at once on what they made, and every other app the viewer has open reopens fresh the next time it is shown.
What the room shows
The room shows each call as running, then done in your words, or why not. A
route that answers JSON with a message has it shown to the viewer as done,
and one that fails (any status that is not 2xx) with an error has that shown
instead:
{ "message": "Five orders have come in." }{ "error": "Tom's session has not started yet. Set the clock to the session first." }| The demo answers | The viewer reads |
|---|---|
2xx with JSON message |
Done: Five orders have come in. |
| 2xx with anything else | Done. The demo has moved on. |
Not 2xx, JSON error (or message) |
Didn't work: Tom's session has not started yet. … |
| Not 2xx, anything else | Didn't work: the demo answered 409 |
| No answer from the copy | Didn't work: the demo answered 502 |
| The room itself did not answer | Didn't work: the room could not reach the demo |
A successful reset reads its message as it is, or "The demo is back at the
start." The words are read only from a response whose content-type contains
json, trimmed, and cut at 200 characters. They are shown as plain text, never
as markup, so answer in a sentence a viewer would say.
A route that needs a body gets it from the declaration: { "call": "POST /api/demo/clock", "body": { "preset": "session" } } in a story step, or
"body" beside "call" in a scenario.
The rehearsal
pitch deploy without --commit does everything but publish, on your
machine:
- It checks
pitch.jsonaspitch validatedoes. - It scans the project for secrets.
- It builds your Dockerfile for
linux/amd64and copies/appout as the bundle, reading how it starts. - It scans the bundle, and the environment it will run with, for secrets.
- It builds the platform's runner image on your machine and starts it on loopback, hands it the bundle through the same control port a viewer's copy uses, and requires the health route to answer, through the runner and then through the container's own port.
- It runs every step of the story on that copy, in order, asks it for the
page each step opens, and prints
okorFAILfor each. Any failed step stops the deploy. - It removes the container, and prints what would go live.
Docker has to be running. The rehearsal may talk to the container it started on loopback and to nothing else: a call it would make anywhere else is refused.
pitch deploy ok manifest: pitch.json validates (container, 4 apps, a 3-step story, 2 pages)
ok secrets: gitleaks scanned the project and found nothing
ok build: docker build --platform linux/amd64 -t pp-build/corner-for-bullring:latest
ok bundle: /app and how it starts: node server/index.js
ok secrets: gitleaks scanned the bundle and found nothing
ok run: the platform's runner answered /healthz (unpacked in 640 ms, healthy 1450 ms after starting)
ok story 1/3 "Tom checks in": POST /api/demo/clock, POST /api/demo/scenario/morning — "Tom checked in at 06:41."
ok story 2/3 "The coach sees it": shows coach, runs nothing
ok story 3/3 "Tom flags his shoulder": POST /api/demo/clock, POST /api/demo/scenario/flag — "Callum flagged Tom's shoulder."
What would go live: project "corner-for-bullring" at …
Corner for Bullring — container, health /healthz
artefact 38.2 MB (40054784 bytes)
apps:
client / phone Bullring (opens first)
coach /coach/ tablet Bullring Coach
hq /hq/ laptop Corner HQ
arena /arena/ screen Arena
starts with: node server/index.js
controls:
reset POST /api/demo/reset
story:
1 morning in client, 2 calls "Tom checks in"
2 coach in coach "The coach sees it"
3 flag in hq, 2 calls "Tom flags his shoulder"
pages:
docs/why-corner.md
docs/corner-architecture.html
Rehearsal only: nothing has left this machine. `pitch deploy --commit` publishes exactly this.pitch deploy --commit runs the same rehearsal, then uploads exactly the
bundle it checked, registers the build, uploads the pages pitch.json lists,
and makes the build live. It signs you in first, so a long build is not
wasted on a missing sign-in. --note <text> keeps a note of up to 200
characters with the build, shown in the app's Builds tab. --project <slug>
deploys to a project other than the one the name in pitch.json makes.
The secret scan
The rehearsal runs gitleaks, with its default rules, over:
- the project folder, before anything is built;
- the bundle, unpacked, beside a file holding the
runit will start with, so a secret in anENVline is caught too.
Installed dependencies (node_modules) and git's own store (.git) are not
scanned: they are not your authored files. The project scan also leaves out
the folders tools generate while they build and cache (.next and a distDir of its own such as .next-build, .turbo, .vercel/output, .nuxt, .output, .svelte-kit, .astro, .parcel-cache, .cache, .wrangler, .angular and .expo),
where Next, for one, writes keys of its own; the bundle scan does not, so
whatever of them your image copies into /app is scanned as it ships. Your
own .gitleaksignore is not read, so a finding cannot be waved through from
inside the build.
Only a clean scan passes. A finding stops the deploy and names the rule, the file and the line, with the value redacted:
FAIL secrets: gitleaks found secret(s) in the bundle:
stripe-access-token in server/billing.js:12
Values redacted. Remove it, then rotate it: it has been on disk in plain text.A scanner that could not run is not a clean scan either:
FAIL secrets: gitleaks could not be started (spawn gitleaks ENOENT). That is not a clean scan, so the deploy stops here.
Install it (brew install gitleaks), or set PP_GITLEAKS to its path.A viewer's copy
Each private link has its own copy of the live build, shared by everyone who opens that link: the person it was made for, and anyone it was forwarded to. A listed project's public address has one copy that everyone there shares, and the workspace's "Preview as viewer" has one of its own.
Starting
The copy starts as the room page is drawn, so its cold start passes while the viewer reads the tour's first card. Meanwhile the stage says "Starting the demo…", and a tour step with calls says "Waiting for the demo to start…" and runs them once the copy is ready.
A copy is ready when its health route answers. One that is not ready within 90 seconds of starting has failed: the stage says "The demo could not start." and offers "Try again".
An app opened in its own tab, or on a phone from the QR code, before the copy is ready, shows a plain page that says what is happening, and refreshes itself while the copy starts.
Staying awake
While the room's tab is visible and the viewer has used it in the last ten minutes, the room sends a heartbeat every minute. A copy with no heartbeat and no request for three minutes stops. So a viewer who leaves the tab, or leaves it untouched for ten minutes, lets their copy go about three minutes later. An open stream (server-sent events for a live screen) does not keep a copy awake on its own: the heartbeat decides.
Whatever happens, one run lasts at most two hours.
When a copy has stopped, the stage says "The demo paused while you were away." with a Resume the demo button, which starts a fresh copy. Anything the viewer changed in the old one is gone.
When it is full
Two limits are checked before any copy starts:
- Your project's plan cap: how many copies of one project can run at once (Plans and limits). A viewer past it sees "This demo is full right now" and "Try again in a few minutes". It counts as a capacity bounce in your app.
- The platform's own ceiling on containers running at once, across every project. A viewer past it sees "The demo is starting — high demand, one moment."; the room asks again every five seconds for a minute, then says "The demo is still in high demand. Try again in a moment." with a Try again button.
When containers are stopped
Roomi can stop every demo container at once, and stops new ones starting past a monthly spend limit. Neither is a setting of yours. While either is on, a viewer's stage says "The live demo is paused right now. The notes are still here." with Try again: the tour's words, the pages and the next steps still work.
Limits
| What | Limit |
|---|---|
| The bundle, as uploaded (gzipped) | 512 MiB (536,870,912 bytes) |
| Memory and disk of a running copy | 1 GiB and 4 GB (the basic instance) |
| From start to healthy | 90 seconds |
| One run | 2 hours |
| Idle before a copy stops | 3 minutes after the last heartbeat or request |
| Copies of one project at once | Pro 5, Team 10, Organisation 50; none on Free |
| Deploys | 2 in any 24 hours on Free; no limit on paid plans |
run.start |
1 to 64 words, each up to 1,000 characters |
run.env |
up to 100 variables, each value up to 4,000 characters |
| Apps, scenarios, story steps | 12, 20 and 20; up to 8 calls a step |
Common failures
FAIL build: Docker is not running. The rehearsal builds and runs the
demo in Docker. Start Docker and run pitch deploy again.
The WORKDIR is not /app. Put WORKDIR /app in the last stage, and build
or copy everything the demo needs into it.
The rehearsal stops with only fetch failed. The server most likely
exited as it started: the runner goes down with it, before it can say why.
Run your image with docker run -e PORT=8080 -e HOST=0.0.0.0 to see the
error.
It works in docker run but not in the rehearsal. Something the server
needs is outside /app (a system package, a global install, a file in
another folder), or the server fetches something from the internet as it
starts. The runner's last words, printed with the failure, usually name it.
The health route never answers. The server listens on a fixed port rather
than PORT, binds 127.0.0.1 rather than HOST, waits for a database or
service that is not in the container, or puts the health route behind a
sign-in or a password gate of its own.
A native module will not load. It was built for another Node, another
architecture or another C library. Build it in the Dockerfile on
node:24-slim; pitch deploy already builds for linux/amd64.
A story step fails in the rehearsal. The rehearsal runs the steps in order on one copy, so each step meets whatever the steps before it left: a clock already moved on, data already changed. The failing step needs a state its own calls do not set. Make it set that state first. The reverse holds too: a step that passes only because of the step before it fails for a viewer who starts there from the outline, and the rehearsal cannot see that.
A step or scenario says "Done. The demo has moved on." instead of your
words. The route did not answer JSON with a message. Set the
content-type to application/json.
The tour does not ring what it points at, or comments do not know the
screen. The room adds one script to each HTML page the demo serves, from the
demo's own origin (/__pp/where.js). A Content-Security-Policy of your own
that does not allow scripts from 'self' blocks it. The room removes any
frame-ancestors and X-Frame-Options of yours, since only your own room may
frame the demo, along with the demo itself (a showcase page may frame the demo's
other apps); the rest of your policy stays.
a container build needs its start command. The platform refuses a
container build without run. Deploy with pitch deploy, which records it
from the image.