Skip to content

Seed and ship

Local dev: you create events by hand in the dashboard. Production and the live demo at show-pony.kumiko.rocks need content on first boot — a seed — and a container image.

Seeded events visible after first boot — same as doc screenshots
Public invite for the seeded Rooftop Launch Party
Acme Offsite RSVP on the acme subdomain — second tenant

The demo tenant is created by admin bootstrap (tenantKey: "demo"). A second tenant acme proves subdomain isolation. The seed adds Rooftop Launch Party (four guests on demo) and Acme Offsite RSVP on acme — the same content Playwright uses for screenshots. It also writes invite branding per tenant (Mira Events / immersive hero on demo, Acme Studios / split on acme). On the free tier (one event per tenant), Winter Warmup on demo is skipped in production; use the acme invite for a second public page.

Writes go through real handlers via systemWriteAs — no raw SQL:

const event = await ctx.systemWriteAs(
"showpony:write:event:create",
{ title: "Rooftop Launch Party", slug: "rooftop-launch", /* … */ },
DEMO_TENANT_ID,
);
— from seeds/2026-06-28-demo-event-rsvps.ts

Each seed filename runs once — recorded in kumiko_es_operations.

await runProdApp({
features: APP_FEATURES,
staticDir: "./dist",
seedsDir: "./seeds",
});
— from bin/main.ts

Demo ops — learning sample, not prod archaeology

Section titled “Demo ops — learning sample, not prod archaeology”

Show Pony’s cloud demo is resettable. Prod uses Pulumi resetDbOnDeploy: true (infra #251) — on each real deploy the showpony database is wiped and reseeded from scratch.

When demo copy or guests change:

  1. Edit 2026-06-28-demo-event-rsvps.ts (one file).
  2. Merge to main — the next deploy resets the DB automatically.
  3. Locally: docker compose up -d + run bin/main.ts against Postgres, or create events in the UI via bun dev (dev skips seedsDir).

Do not stack dated “repair” seeds for drift (duplicate slugs, copy patches, membership fixes). That pattern lengthens boot, hides bugs until deploy, and has crash-looped prod when a seed threw on startup.

Boot seeds must not throwseedsDir runs on every pod start. A failed write aborts the whole boot (503 until fixed). The guard is seed-boot-safety.test.ts.

bun dev skips seedsDir — localhost only gets inline boot seeds (accounts, legal). Demo events appear after you create them in the UI, or when you run the prod bootstrap against Docker Postgres. Always reproduce seed failures locally before touching deploy pipelines.

Surface Local Cloud
Host login (apex) http://show-pony.localhost:4180/login https://show-pony.kumiko.rocks/login
Demo invite (rooftop) http://demo.show-pony.localhost:4180/e/rooftop-launch https://demo.show-pony.kumiko.rocks/e/rooftop-launch
Acme invite (offsite) http://acme.show-pony.localhost:4180/e/acme-offsite https://acme.show-pony.kumiko.rocks/e/acme-offsite

bin/main.ts is runProdApp: secrets from env, https origins, built HTML from ./dist:

hostDispatch: ({ host }) => {
const h = hostnameOf(host);
if (h === BASE_DOMAIN || h === `www.${BASE_DOMAIN}`) {
return { kind: "html", file: "admin.html", injectSchema: true };
}
return { kind: "html", file: "index.html", injectSchema: false };
},
— from bin/main.ts

file is relative to staticDir (./dist) — not ./dist/admin.html.

Production sets DEMO_READ_ONLY=true (via Pulumi demoReadOnly: true). That:

  • Blocks POST /api/write and /api/batch — browse and login still work.
  • Exposes host + sysadmin credentials on the login screen (GET /demo-mode).
  • Hides the public RSVP form; invite pages remain viewable.
  • Shows a read-only banner in the admin shell after login.

Local dev omits the flag — full interactivity on localhost.

const fetch = withDemoReadOnlyFetch(handle.fetch);
// autoListen: false + Bun.serve({ fetch }) so the guard wraps all traffic
— from bin/main.ts and src/demo-mode.ts
Terminal window
bun run build
# dist/ — client bundles + admin.html + index.html
# dist-server/ — server.js + kumiko.js (schema CLI)

Two-stage Dockerfile: build in Bun alpine, runtime copies dist-server, dist/, kumiko/migrations, seeds/.

ci.yml calls shared infra workflows: build → ghcr.io/.../show-pony:latest → deploy show-pony-app namespace on merge to main.

Seed:

// Demo content for the deployed public page: Rooftop Launch + Winter Warmup +
// sample RSVPs on the "demo" tenant. Matches tutorial screenshots.
//
// This is the **only** dated seed for demo content — show-pony is a learning
// sample with a resettable prod DB (see README "Demo ops"). Do not add repair
// migrations; edit this file and reset the database.
//
// Tenants + accounts come from boot seeds in bin/main.ts / bin/server.ts.
// Seeds run once per filename (kumiko_es_operations). Dispatcher writes commit
// outside the marker tx — retries MUST be idempotent (slug guard before create).
//
// RUNTIME: seeds/ is copied into the Docker image WITHOUT src/ and WITHOUT
// @cosmicdrift/* in node_modules — only type-only framework imports + inline QNs.
import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
import {
ACME_TENANT_ID,
DEMO_EVENT_ID,
DEMO_TENANT_ID,
findEventBySlug,
toRawSqlRunner,
} from "./_demo-event-db";
const INVITE_HERO_IMAGE_URL = "showpony:config:invite-hero-image-url";
const INVITE_HERO_STYLE = "showpony:config:invite-hero-style";
const BRANDING_TITLE = "managed-pages:config:branding-title";
const BRANDING_DESCRIPTION = "managed-pages:config:branding-description";
const BRANDING_ACCENT_COLOR = "managed-pages:config:branding-accent-color";
function seedWarn(label: string, err: unknown): void {
console.warn(`show-pony seed: ${label} skipped — ${err instanceof Error ? err.message : String(err)}`);
}
async function seedInviteBranding(
ctx: Parameters<SeedMigration["run"]>[0],
tenantId: TenantId,
entries: ReadonlyArray<readonly [string, string]>,
): Promise<void> {
for (const [key, value] of entries) {
try {
await ctx.systemWriteAs("config:write:set", { key, value }, tenantId);
} catch (err) {
seedWarn(`branding ${key}`, err);
}
}
}
const ROOFTOP_DESC = `✨ You're on the list for something special.
Join us on the 24th floor — cocktails, live DJ, and the Show Pony 2.0 launch at midnight 🎉
Dress code: rooftop-ready 👠
Bring someone you'd proudly introduce to the team 💜`;
const ACME_DESC = `🎨 Team offsite season is here!
Workshops in the morning, pizza on the studio floor, and zero mandatory fun runs 😅
RSVP so we know how many chairs (and how much coffee) to order ☕️`;
const GUESTS = [
{ name: "Ava Chen", status: "yes", plusN: 2 },
{ name: "Marcus Bell", status: "yes", plusN: 0 },
{ name: "Priya Raman", status: "maybe", plusN: 1 },
{ name: "Diego Santos", status: "no", plusN: 0 },
] as const;
export default {
description:
"demo tenant content: Rooftop Launch Party + sample RSVPs (demo) + seeded event (acme)",
run: async (ctx) => {
const raw = toRawSqlRunner(ctx.db);
let rooftop = await findEventBySlug(raw, DEMO_TENANT_ID, "rooftop-launch");
if (rooftop) {
try {
await ctx.systemWriteAs(
"showpony:write:event:update",
{
id: rooftop.id,
version: rooftop.version,
changes: { description: ROOFTOP_DESC },
},
DEMO_TENANT_ID,
);
} catch (err) {
seedWarn("rooftop description patch", err);
}
} else {
const event = await ctx.systemWriteAs(
"showpony:write:event:create",
{
title: "Rooftop Launch Party",
slug: "rooftop-launch",
startsAt: "2026-09-12T19:00:00.000Z",
location: "Sky Lounge, 24th floor",
description: ROOFTOP_DESC,
guestLimit: 80,
},
DEMO_TENANT_ID,
);
if (!event.isSuccess) {
throw new Error(
`show-pony seed: event:create failed — ${event.error.code}: ${event.error.message}`,
);
}
rooftop = await findEventBySlug(raw, DEMO_TENANT_ID, "rooftop-launch");
}
const warmup = await findEventBySlug(raw, DEMO_TENANT_ID, "warmup-drinks");
if (!warmup) {
try {
await ctx.systemWriteAs(
"showpony:write:event:create",
{
title: "Winter Warmup Drinks",
slug: "warmup-drinks",
startsAt: "2026-11-28T18:00:00.000Z",
location: "Ground-floor bar",
description:
"Low-key pre-holiday drinks for the team and friends. No agenda — just show up.",
guestLimit: 40,
},
DEMO_TENANT_ID,
);
} catch (err) {
seedWarn("warmup event", err);
}
}
const eventId = rooftop?.id ?? DEMO_EVENT_ID;
for (const guest of GUESTS) {
try {
await ctx.systemWriteAs(
"showpony:write:rsvp:submit",
{ eventId, ...guest },
DEMO_TENANT_ID,
);
} catch (err) {
seedWarn(`rsvp:submit for ${guest.name}`, err);
}
}
const acme = await findEventBySlug(raw, ACME_TENANT_ID, "acme-offsite");
if (acme) {
try {
await ctx.systemWriteAs(
"showpony:write:event:update",
{
id: acme.id,
version: acme.version,
changes: { description: ACME_DESC },
},
ACME_TENANT_ID,
);
} catch (err) {
seedWarn("acme description patch", err);
}
} else {
try {
await ctx.systemWriteAs(
"showpony:write:event:create",
{
title: "Acme Offsite RSVP",
slug: "acme-offsite",
startsAt: "2026-10-03T18:00:00.000Z",
location: "Acme HQ — Studio floor",
description: ACME_DESC,
guestLimit: 60,
},
ACME_TENANT_ID,
);
} catch (err) {
seedWarn("acme event", err);
}
}
await seedInviteBranding(ctx, DEMO_TENANT_ID, [
[BRANDING_TITLE, "Mira Events"],
[BRANDING_DESCRIPTION, "✨ Rooftop invites with sparkle ✨"],
[BRANDING_ACCENT_COLOR, "#7c3aed"],
[INVITE_HERO_IMAGE_URL, "/heroes/demo-rooftop.webp"],
[INVITE_HERO_STYLE, "immersive"],
]);
await seedInviteBranding(ctx, ACME_TENANT_ID, [
[BRANDING_TITLE, "Acme Studios"],
[BRANDING_DESCRIPTION, "Clean design. Loud ideas. 🎨"],
[BRANDING_ACCENT_COLOR, "#0d9488"],
[INVITE_HERO_IMAGE_URL, "/heroes/acme-studio.webp"],
[INVITE_HERO_STYLE, "split"],
]);
},
} satisfies SeedMigration;

Production image:

1.23
#
# Production image for show-pony.
#
# Server-bundle variant — the runtime image doesn't know the repo, only:
#
# dist/ ← client build (kumiko-build): admin.html + index.html
# dist-server/ ← server bundle (server.js + kumiko.js) + minimal package.json
# kumiko/migrations/ ← checked-in SQL migrations (schema apply)
# seeds/ ← demo content seeds (run once at boot)
#
# Build context: repository root (standalone repo).
ARG BUN_VERSION=1.3.14
ARG NPM_AUTH_TOKEN=
ARG BUILD_VERSION=dev
ARG BUILD_TIME=unknown
# ----- build: produces dist/ + dist-server/ ---------------------------------
FROM oven/bun:${BUN_VERSION}-alpine AS build
WORKDIR /app
# All @cosmicdrift/* deps are public NPM. @cosmicdriftgamestudio/kumiko-guards
# is a restricted org package — NPM_AUTH_TOKEN (GH_PACKAGES_PAT in CI) required.
ARG NPM_AUTH_TOKEN
ENV GITHUB_TOKEN=${NPM_AUTH_TOKEN}
COPY . .
RUN bun install --frozen-lockfile
RUN bun run build
# ----- runtime: bun-alpine, knows only the bundle artifact ------------------
FROM oven/bun:${BUN_VERSION}-alpine AS runtime
# Global ARGs are only visible in a stage after re-declaration — without these
# lines ENV BUILD_VERSION stayed empty and /api/version always showed "dev".
ARG BUILD_VERSION=dev
ARG BUILD_TIME=unknown
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist-server ./
RUN bun install --production
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/kumiko ./kumiko
COPY --from=build --chown=app:app /app/seeds ./seeds
USER app
ENV NODE_ENV=production
ENV PORT=3000
ENV KUMIKO_REPO_ROOT=/app
ENV INIT_CWD=/app
ENV BUILD_VERSION=$BUILD_VERSION
ENV BUILD_TIME=$BUILD_TIME
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget --quiet --spider "http://127.0.0.1:${PORT}/health" || exit 1
CMD ["sh", "-c", "exec bun run server.js"]

📄 On GitHub: seeds/ · deploy/Dockerfile

That’s the whole app — from install to an image behind a wildcard domain, with the same anonymous RSVP round trip you started with.