Container demos

The build contract, Dockerfiles that keep to it, and demo routes.

On this page

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.

  1. pitch deploy builds your Dockerfile for linux/amd64, reads the finished image's CMD, ENTRYPOINT and ENV, and copies its /app folder out as a gzipped tar: the bundle.
  2. pitch deploy --commit uploads the bundle. The platform checks it is gzip and stores it; it never unpacks it.
  3. 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.
  4. 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 deploy builds it for linux/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 /app when it finishes: the built apps and the production node_modules. Only /app is taken; nothing else in the image comes along, so a system package installed with apt-get is 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 CMD says how it starts, in exec form: CMD ["node", "server.js"]. Without one, a Node image's own CMD starts a Node prompt, not your server. pitch deploy reads the CMD (and any ENTRYPOINT) and the ENV from the image and records them as run in the manifest.
  • It listens on PORT, on the host HOST. 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: /healthz unless health in pitch.json says 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 CMD that runs npm install or npx fails; install in the Dockerfile.
  • Mock data only, and no secrets. The project, the build and its ENV are 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 node user, and owns /app and 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 WORKDIR is not /app;
  • it has no CMD or ENTRYPOINT;
  • its CMD installs or fetches packages (npm install, npm ci, pnpm add, yarn install, bun install, npx and 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/containers

A 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:

  1. One registry of the apps and their paths, read by everything: the server, each app's build, and pitch init.
  2. Each app built with its base path, so its assets resolve under it.
  3. The server serving each app's built files under its path, beside its own /api routes, with one build script that builds every app.
  4. 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 const

Each 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 GET from 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:3000

Here 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/json and a JSON body: the declared body, or {} when there is none. A GET call 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 session preset. 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.
  • point names 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:

  1. It checks pitch.json as pitch validate does.
  2. It scans the project for secrets.
  3. It builds your Dockerfile for linux/amd64 and copies /app out as the bundle, reading how it starts.
  4. It scans the bundle, and the environment it will run with, for secrets.
  5. 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.
  6. It runs every step of the story on that copy, in order, asks it for the page each step opens, and prints ok or FAIL for each. Any failed step stops the deploy.
  7. 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 run it will start with, so a secret in an ENV line 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.