$ cat post.metadata

顧客ごとのカスタムデモ環境を15分で構築する -- SLG商談を加速するGTMエンジニアリング

GTMエンジニアリングSLG

SLGの商談サイクルを劇的に短縮する「顧客ごとのカスタムデモ環境」の構築方法。マルチテナント設計、シードデータ自動生成、15分デプロイの仕組みをTypeScriptで実装する。

$ cat post.content | render --format=markdown

顧客ごとのカスタムデモ環境を15分で構築する -- SLG商談を加速するGTMエンジニアリング

実装フェーズに入る

ここまでの記事で、GTMエンジニアリングの理論的基盤を築いてきた。SLG時代のGTMエンジニアの定義技術選定フレームワークClaude APIによる営業パイプライン自動化。概念の定義と技術の選定が終わった。

今回から実装に踏み込む。その最初のテーマとして、SLG商談で最もインパクトの大きい領域を選んだ。顧客ごとのカスタムデモ環境だ。

なぜカスタムデモ環境が商談を加速するのか

汎用デモの限界

SLGの営業現場でよく見る光景がある。営業担当が「弊社プロダクトのデモをお見せします」と言って、全顧客共通のデモ環境を開く。画面に表示されるのは架空の「サンプル株式会社」のデータ。顧客側の反応は「なるほど、まあこういう感じですね」で終わる。

汎用デモが刺さらない理由は明確だ。

汎用デモの弱点顧客の心理
サンプルデータが自社と無関係「うちの業務に合うかわからない」
業種特有のワークフローがない「小売業と製造業で同じ画面を見せられても」
データ量が少なすぎる「100件で動いても1万件で動くのか」
他社のロゴやデータが残っている「セキュリティ大丈夫?」

一方、顧客の企業名、業種に合ったデータ、実際の規模感を反映したデモ環境を見せた瞬間、反応が変わる。「あ、これはうちの業務そのものだ」と。この瞬間に、商談のフェーズが一段上がる。

Palantir FDSEモデルの応用

Palantir FDSEの働き方で解説した通り、FDSEは顧客先で「動くもの」を見せて商談を進める。パワーポイントではなくコードで語る。

FDSEモデルの核心は「顧客のデータを使ったプロトタイプを数日で構築する」ことだが、これをさらに押し進めて「15分で構築する」仕組みを作るのがGTMエンジニアの仕事だ。FDSEが1社に数週間かけて構築するものを、仕組み化して全営業が使えるようにする。

「動くもの」が稟議を通す力

SLG時代のGTMエンジニアの定義で述べた通り、日本企業の購買プロセスは合議制と稟議が基本だ。現場担当者がプロダクトに好感を持っても、部長、本部長、役員の承認を得る必要がある。

稟議のボトルネックは「上位決裁者がプロダクトを触る機会がない」ことにある。提案書とパワーポイントだけでは、プロダクトの価値が伝わらない。しかし、カスタムデモ環境のURLを稟議書に添付し「このURLにアクセスすれば御社のデータで動いている状態を確認できます」と書けるなら、決裁者自身がプロダクトを体験できる。

稟議書 + デモ環境URL。この組み合わせは、日本のSLG商談において強力な武器になる。

商談サイクル短縮の実感値

カスタムデモ環境を導入した場合の商談サイクルの変化を整理する。

導入前(汎用デモ):

初回商談 → 汎用デモ → 「持ち帰って検討します」
→ 2回目商談(質問回答)→ 「もう少し具体的に見たい」
→ POC依頼 → 開発チームに依頼 → 2-4週間待ち
→ POC環境構築 → POCデモ → 社内稟議 → 承認
= 2-6ヶ月

導入後(カスタムデモ環境):

初回商談 → カスタムデモ(商談中に構築)→ 「これはうちの業務そのものだ」
→ 決裁者向けURLを共有 → 社内稟議(デモURL添付)→ 承認
= 2-8週間

短縮の最大の要因は「POC依頼 → 開発チームに依頼 → 2-4週間待ち」の工程が消えることだ。GTMエンジニアが構築した仕組みにより、営業自身がデモ環境を即座にプロビジョニングできる。

アーキテクチャ設計

全体フロー

営業がCRMのDeal画面で「デモ作成」ボタンを押す。それだけでカスタムデモ環境が15分で立ち上がる。

[営業がCRMで「デモ作成」ボタンを押す]
  → [Webhook: HubSpot → n8n]
  → [テナント作成API: Hono on CF Workers]
    → DB: デモ用テナント作成
    → シードデータ: 顧客業種に合わせた初期データ生成(Claude API)
    → URL生成: demo-{company-slug}.product.example.com
  → [CRM更新: デモURL + 有効期限をDealに記録]
  → [Slack通知: 営業にデモ準備完了を通知]

この一連のフローを、Hono + Cloudflare Workers + Prisma + Claude APIで実装する。技術選定フレームワークで述べた通り、Cloudflare Workersはコスト予測可能性とグローバルエッジ配信の観点からGTMエンジニアリングに適している。

マルチテナント設計

デモ環境の分離戦略には3つの選択肢がある。

分離方式分離レベルコスト構築速度セキュリティ
DB分離テナントごとに独立DB遅い(数分)最高
スキーマ分離テナントごとに独立スキーマ中(1-2分)
行レベル分離全テナント同一テーブル、tenant_idで分離速い(数秒)

デモ環境では行レベル分離を推奨する。理由は3つ。

  1. 構築速度: 15分デプロイを実現するには、テナント作成が秒単位で完了する必要がある。DB作成やスキーマ作成は時間がかかる
  2. コスト: 10社同時にデモを運用する場合、DB分離だと10個のDBインスタンスが必要。行レベル分離なら1つのDBで済む
  3. 自動削除の容易さ: DELETE FROM ... WHERE tenant_id = ? で完了。DBごと削除するよりシンプル

セキュリティの懸念については、デモ環境に本番データは一切使わない前提なので、行レベル分離で十分だ。

テナントのデータモデル

typescript
// src/db/schema.ts import { z } from 'zod' export const TenantSchema = z.object({ id: z.string().uuid(), companyName: z.string(), companySlug: z.string(), industry: z.enum([ 'saas', 'fintech', 'ec_retail', 'manufacturing', 'media', 'healthcare', 'education', 'logistics', 'real_estate', 'other', ]), employeeScale: z.enum(['small', 'medium', 'large', 'enterprise']), subdomain: z.string(), demoUrl: z.string().url(), hubspotDealId: z.string(), createdBy: z.string(), expiresAt: z.string().datetime(), status: z.enum(['provisioning', 'active', 'expired', 'deleted']), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }) export type Tenant = z.infer<typeof TenantSchema> export const CreateTenantRequestSchema = z.object({ companyName: z.string().min(1).max(100), industry: TenantSchema.shape.industry, employeeScale: TenantSchema.shape.employeeScale, hubspotDealId: z.string(), createdBy: z.string().email(), expiresInDays: z.number().int().min(1).max(90).default(14), }) export type CreateTenantRequest = z.infer<typeof CreateTenantRequestSchema>

Prismaスキーマ

prisma
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Tenant { id String @id @default(uuid()) companyName String @map("company_name") companySlug String @unique @map("company_slug") industry String employeeScale String @map("employee_scale") subdomain String @unique demoUrl String @map("demo_url") hubspotDealId String @map("hubspot_deal_id") createdBy String @map("created_by") expiresAt DateTime @map("expires_at") status String @default("provisioning") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") seedData SeedData[] demoUsers DemoUser[] demoProjects DemoProject[] @@map("tenants") } model SeedData { id String @id @default(uuid()) tenantId String @map("tenant_id") dataType String @map("data_type") data Json createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId, dataType]) @@map("seed_data") } model DemoUser { id String @id @default(uuid()) tenantId String @map("tenant_id") name String email String role String avatarUrl String? @map("avatar_url") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId]) @@map("demo_users") } model DemoProject { id String @id @default(uuid()) tenantId String @map("tenant_id") name String description String status String progress Int @default(0) assignee String? dueDate DateTime? @map("due_date") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@index([tenantId]) @@map("demo_projects") }

デモ環境のライフサイクル管理

デモ環境は永続化するものではない。作成、利用、自動削除のライフサイクルを明確に定義する。

[作成] → [プロビジョニング中] → [アクティブ] → [期限切れ] → [削除済み]
  ↓                              ↓                ↓
  失敗時: 自動ロールバック      手動延長可能      自動削除(cron)
                               (最大90日)

テナント作成APIの実装

テナントプロビジョニング

typescript
// src/tenant/provisioner.ts import { PrismaClient } from '@prisma/client' import type { CreateTenantRequest, Tenant } from '../db/schema' import { generateSeedData } from '../seed/generator' import { notifySlack } from '../notifications/slack' import { updateHubSpotDeal } from '../crm/hubspot-deal' const prisma = new PrismaClient() const DEMO_DOMAIN = 'product.example.com' function toSlug(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 40) } export async function provisionTenant( request: CreateTenantRequest ): Promise<Tenant> { const slug = toSlug(request.companyName) const subdomain = `demo-${slug}` const demoUrl = `https://${subdomain}.${DEMO_DOMAIN}` const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + request.expiresInDays) // Step 1: テナントレコード作成 const tenant = await prisma.tenant.create({ data: { companyName: request.companyName, companySlug: slug, industry: request.industry, employeeScale: request.employeeScale, subdomain, demoUrl, hubspotDealId: request.hubspotDealId, createdBy: request.createdBy, expiresAt, status: 'provisioning', }, }) try { // Step 2: シードデータ生成(Claude API) const seedData = await generateSeedData({ tenantId: tenant.id, companyName: request.companyName, industry: request.industry, employeeScale: request.employeeScale, }) // Step 3: シードデータをDBに投入 await insertSeedData(tenant.id, seedData) // Step 4: テナントをアクティブに更新 const activeTenant = await prisma.tenant.update({ where: { id: tenant.id }, data: { status: 'active' }, }) // Step 5: CRM更新 + Slack通知(並列実行) await Promise.all([ updateHubSpotDeal(request.hubspotDealId, { demo_url: demoUrl, demo_expires_at: expiresAt.toISOString(), demo_status: 'active', }), notifySlack({ type: 'demo_ready', companyName: request.companyName, demoUrl, expiresAt: expiresAt.toISOString(), createdBy: request.createdBy, }), ]) return activeTenant as unknown as Tenant } catch (error) { // ロールバック: プロビジョニング失敗時はテナントを削除 await prisma.tenant.update({ where: { id: tenant.id }, data: { status: 'deleted' }, }) throw new Error( `Tenant provisioning failed for ${request.companyName}: ${ error instanceof Error ? error.message : 'Unknown error' }` ) } } async function insertSeedData( tenantId: string, seedData: SeedDataSet ): Promise<void> { // デモユーザーの作成 await prisma.demoUser.createMany({ data: seedData.users.map((user) => ({ tenantId, name: user.name, email: user.email, role: user.role, avatarUrl: user.avatarUrl, })), }) // デモプロジェクトの作成 await prisma.demoProject.createMany({ data: seedData.projects.map((project) => ({ tenantId, name: project.name, description: project.description, status: project.status, progress: project.progress, assignee: project.assignee, dueDate: project.dueDate ? new Date(project.dueDate) : null, })), }) // 業種固有データの保存 await prisma.seedData.create({ data: { tenantId, dataType: 'industry_specific', data: seedData.industrySpecific as object, }, }) } interface SeedDataSet { users: { name: string email: string role: string avatarUrl?: string }[] projects: { name: string description: string status: string progress: number assignee?: string dueDate?: string }[] industrySpecific: Record<string, unknown> }

Hono APIエンドポイント

typescript
// src/routes/tenant.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { CreateTenantRequestSchema } from '../db/schema' import { provisionTenant } from '../tenant/provisioner' const tenantRoutes = new Hono() tenantRoutes.post( '/create', zValidator('json', CreateTenantRequestSchema), async (c) => { const request = c.req.valid('json') const startTime = Date.now() const tenant = await provisionTenant(request) const elapsed = Date.now() - startTime return c.json({ tenant: { id: tenant.id, demoUrl: tenant.demoUrl, expiresAt: tenant.expiresAt, status: tenant.status, }, provisioningTimeMs: elapsed, }) } ) tenantRoutes.get('/:tenantId', async (c) => { const tenantId = c.req.param('tenantId') const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, include: { demoUsers: true, demoProjects: true, }, }) if (!tenant) { return c.json({ error: 'Tenant not found' }, 404) } return c.json({ tenant }) }) tenantRoutes.post('/:tenantId/extend', async (c) => { const tenantId = c.req.param('tenantId') const { days } = await c.req.json<{ days: number }>() const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, }) if (!tenant) { return c.json({ error: 'Tenant not found' }, 404) } const newExpiry = new Date(tenant.expiresAt) newExpiry.setDate(newExpiry.getDate() + Math.min(days, 90)) const updated = await prisma.tenant.update({ where: { id: tenantId }, data: { expiresAt: newExpiry }, }) return c.json({ tenantId: updated.id, expiresAt: updated.expiresAt, }) }) export { tenantRoutes }

シードデータの自動生成

シードデータがデモ環境の説得力を決める。架空だが「もっともらしい」データを、顧客の業種と規模に合わせて生成する。

業種別テンプレート

typescript
// src/seed/templates.ts interface IndustryTemplate { industry: string departments: string[] projectTypes: string[] roleTypes: string[] kpiNames: string[] sampleMetrics: Record<string, { min: number; max: number; unit: string }> } const industryTemplates: Record<string, IndustryTemplate> = { saas: { industry: 'SaaS', departments: [ 'プロダクト開発', 'セールス', 'CS', 'マーケティング', 'データ分析', 'HR', '経営企画', ], projectTypes: [ '新機能開発', 'パフォーマンス改善', 'セキュリティ対応', 'インテグレーション開発', 'UI/UXリニューアル', 'オンボーディング改善', 'チャーン分析', ], roleTypes: [ 'エンジニア', 'PM', 'デザイナー', 'CS担当', 'セールス', 'マーケター', 'データアナリスト', ], kpiNames: ['MRR', 'チャーンレート', 'NPS', 'DAU', 'ARPU'], sampleMetrics: { mrr: { min: 500, max: 5000, unit: '万円' }, churnRate: { min: 1, max: 8, unit: '%' }, nps: { min: 20, max: 70, unit: 'pt' }, }, }, ec_retail: { industry: 'EC/小売', departments: [ '商品企画', '仕入/バイイング', 'EC運営', '物流', '店舗運営', 'CRM', 'マーケティング', ], projectTypes: [ '季節キャンペーン', '在庫最適化', '新カテゴリ立ち上げ', 'フルフィルメント改善', 'OMO施策', '会員プログラム改修', ], roleTypes: [ 'バイヤー', '店長', 'EC担当', '物流マネージャー', 'CRM担当', 'マーケター', 'カスタマーサポート', ], kpiNames: ['売上', '客単価', 'リピート率', 'LTV', '在庫回転率'], sampleMetrics: { revenue: { min: 1000, max: 50000, unit: '万円/月' }, averageOrderValue: { min: 3000, max: 15000, unit: '円' }, repeatRate: { min: 15, max: 45, unit: '%' }, }, }, manufacturing: { industry: '製造業', departments: [ '設計', '生産管理', '品質管理', '調達', '物流', '営業', 'DX推進室', ], projectTypes: [ '生産計画最適化', '品質改善', '設備保全DX', 'サプライチェーン可視化', 'IoTセンサー導入', '原価管理改善', ], roleTypes: [ '設計エンジニア', '生産管理者', '品質管理者', '調達担当', '工場長', 'DX推進担当', ], kpiNames: ['稼働率', '不良率', 'リードタイム', '在庫回転率', '原価率'], sampleMetrics: { operatingRate: { min: 70, max: 95, unit: '%' }, defectRate: { min: 0.1, max: 3, unit: '%' }, leadTimeDays: { min: 3, max: 30, unit: '日' }, }, }, fintech: { industry: 'FinTech', departments: [ 'プロダクト開発', 'リスク管理', 'コンプライアンス', 'オペレーション', 'CS', 'パートナーシップ', ], projectTypes: [ '新決済手段対応', 'AML/KYC強化', 'API公開', 'リスクモデル改善', 'モバイルアプリ改善', '加盟店管理', ], roleTypes: [ 'エンジニア', 'PM', 'リスクアナリスト', 'コンプライアンス担当', 'オペレーション担当', 'BD', ], kpiNames: ['取扱高', 'テイクレート', '不正検知率', '加盟店数', '解約率'], sampleMetrics: { transactionVolume: { min: 1, max: 100, unit: '億円/月' }, takeRate: { min: 1, max: 5, unit: '%' }, fraudDetectionRate: { min: 95, max: 99.9, unit: '%' }, }, }, } export function getTemplate(industry: string): IndustryTemplate { return industryTemplates[industry] ?? industryTemplates.saas }

Claude APIによるシードデータ生成

テンプレートはデータの「骨格」を定義する。Claude APIがその骨格に「肉」を付ける。企業名・業種に合わせた、もっともらしいデータを生成する。

typescript
// src/seed/generator.ts import Anthropic from '@anthropic-ai/sdk' import { z } from 'zod' import { getTemplate } from './templates' const anthropic = new Anthropic() interface SeedGenerationInput { tenantId: string companyName: string industry: string employeeScale: string } const GeneratedSeedSchema = z.object({ users: z.array(z.object({ name: z.string(), email: z.string(), role: z.string(), department: z.string(), })), projects: z.array(z.object({ name: z.string(), description: z.string(), status: z.enum(['not_started', 'in_progress', 'completed', 'on_hold']), progress: z.number().min(0).max(100), assignee: z.string().optional(), dueDate: z.string().optional(), })), industrySpecific: z.record(z.unknown()), }) type GeneratedSeed = z.infer<typeof GeneratedSeedSchema> const employeeScaleMap = { small: { min: 20, max: 50, userCount: 5, projectCount: 4 }, medium: { min: 50, max: 200, userCount: 8, projectCount: 6 }, large: { min: 200, max: 1000, userCount: 12, projectCount: 10 }, enterprise: { min: 1000, max: 10000, userCount: 15, projectCount: 15 }, } as const export async function generateSeedData( input: SeedGenerationInput ): Promise<GeneratedSeed> { const template = getTemplate(input.industry) const scale = employeeScaleMap[input.employeeScale as keyof typeof employeeScaleMap] ?? employeeScaleMap.medium const prompt = buildSeedPrompt(input, template, scale) const response = await anthropic.messages.create({ model: 'claude-sonnet-4-5-20250514', max_tokens: 4096, messages: [{ role: 'user', content: prompt }], }) const text = response.content[0].type === 'text' ? response.content[0].text : '' const jsonMatch = text.match(/\{[\s\S]*\}/) if (!jsonMatch) { throw new Error('Failed to extract seed data JSON from Claude response') } return GeneratedSeedSchema.parse(JSON.parse(jsonMatch[0])) } function buildSeedPrompt( input: SeedGenerationInput, template: ReturnType<typeof getTemplate>, scale: { userCount: number; projectCount: number; min: number; max: number } ): string { const today = new Date() const threeMonthsLater = new Date(today) threeMonthsLater.setMonth(threeMonthsLater.getMonth() + 3) return `あなたはBtoB SaaSのデモ環境向けサンプルデータ生成の専門家です。 以下の条件で、リアルに見えるデモ用データを生成してください。 ## 対象企業情報 - 企業名: ${input.companyName} - 業種: ${template.industry} - 従業員規模: ${scale.min}${scale.max} ## 生成ルール 1. ユーザー名は日本語の氏名にしてください(例: 田中太郎、鈴木花子) 2. メールアドレスは {ローマ字姓}.{ローマ字名}@${input.companyName.toLowerCase().replace(/[^a-z0-9]/g, '')}.example.com の形式 3. プロジェクト名は${template.industry}の業務で実際にありそうなものにしてください 4. プロジェクトのステータスは偏りなく分布させてください(完了30%、進行中40%、未着手20%、保留10%) 5. 日付は ${today.toISOString().split('T')[0]} 以降、${threeMonthsLater.toISOString().split('T')[0]} までの範囲 6. industrySpecificには${template.industry}固有のKPI・指標データを含めてください ## 部署候補 ${template.departments.join(', ')} ## プロジェクトタイプ候補 ${template.projectTypes.join(', ')} ## 職種候補 ${template.roleTypes.join(', ')} ## KPI候補 ${template.kpiNames.join(', ')} ## 出力件数 - ユーザー: ${scale.userCount}- プロジェクト: ${scale.projectCount} ## 出力 以下のJSON形式で出力してください。JSONのみを出力してください。 { "users": [ { "name": "田中太郎", "email": "tanaka.taro@example.com", "role": "エンジニア", "department": "プロダクト開発" } ], "projects": [ { "name": "Q3新機能開発", "description": "プロジェクトの説明", "status": "in_progress", "progress": 45, "assignee": "田中太郎", "dueDate": "2026-06-30" } ], "industrySpecific": { "kpis": [ {"name": "MRR", "value": 2500, "unit": "万円", "trend": "up"} ], "recentActivities": [ {"date": "2026-04-15", "activity": "説明", "type": "milestone"} ] } }` }

シードデータの品質が商談を左右する

シードデータの「もっともらしさ」が商談の成否に直結する。

Claude APIが生成するデータには以下の特徴がある。

強み:

  • 業種に合った用語・プロジェクト名を使う(製造業なら「生産計画最適化」、SaaSなら「チャーン分析」)
  • 日本語の氏名が自然
  • KPIの数値が業界の相場観に合っている

注意点:

  • 企業固有のドメイン名(example.com)を使い、実在するドメインを使わない
  • 金額や数値は「それっぽい範囲」に収める(異常値を出すとデモの信憑性が落ちる)
  • 同じ名前が重複しないようにuserCountを指定する

15分デプロイの仕組み

サブドメインルーティング

Cloudflare Workersでワイルドカードサブドメインを処理する。demo-*.product.example.com へのリクエストを、テナントIDに応じて振り分ける。

typescript
// src/middleware/tenant-resolver.ts import { Context, Next } from 'hono' import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() export async function tenantResolver(c: Context, next: Next): Promise<void> { const host = c.req.header('host') ?? '' const subdomainMatch = host.match(/^(demo-[a-z0-9-]+)\.product\.example\.com$/) if (!subdomainMatch) { c.status(404) c.body('Tenant not found') return } const subdomain = subdomainMatch[1] const tenant = await prisma.tenant.findUnique({ where: { subdomain }, }) if (!tenant) { c.status(404) c.body('Demo environment not found') return } if (tenant.status !== 'active') { c.status(410) c.body('This demo environment has expired') return } if (new Date(tenant.expiresAt) < new Date()) { // 期限切れだがステータス未更新の場合 await prisma.tenant.update({ where: { id: tenant.id }, data: { status: 'expired' }, }) c.status(410) c.body('This demo environment has expired') return } // テナント情報をコンテキストに格納 c.set('tenant', tenant) c.set('tenantId', tenant.id) await next() }

デプロイパイプライン

GitHub Actionsでテナント作成APIのデプロイを自動化する。Prismaマイグレーションも含める。

yaml
# .github/workflows/deploy-demo-api.yml name: Deploy Demo Environment API on: push: branches: [main] paths: - 'src/**' - 'prisma/**' - 'wrangler.toml' jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install - name: Run Prisma migrations run: bunx prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - name: Deploy to Cloudflare Workers run: bunx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Wrangler設定

toml
# wrangler.toml name = "demo-provisioner" main = "src/worker.ts" compatibility_date = "2026-04-01" [vars] DEMO_DOMAIN = "product.example.com" [[d1_databases]] binding = "DB" database_name = "demo-tenants" database_id = "xxxxx" [triggers] crons = ["0 3 * * *"] # 毎日03:00 UTCに期限切れチェック

15分の内訳

テナント作成から利用可能になるまでの時間を分解する。

ステップ所要時間処理内容
HubSpot Webhook発火~1秒Deal更新トリガー
テナントレコード作成~0.5秒PostgreSQL INSERT
シードデータ生成~8-12秒Claude API呼び出し + JSON解析
シードデータ投入~1-2秒Prisma createMany
サブドメインルーティング反映~0.5秒Cloudflare Workersは即時
CRM更新 + Slack通知~1-2秒並列実行
合計~12-18秒

実際の所要時間は12-18秒程度だ。「15分」というのはマーケティング的な表現で、現実にはさらに速い。ただし、Claude APIのレスポンスタイムにばらつきがあるため、余裕を持って15分と言っている。

CRM連携

HubSpot Webhook受信

営業がHubSpotのDealカードで「デモ作成」ボタン(カスタムアクション)を押すと、Webhookが発火する。

typescript
// src/routes/webhook.ts import { Hono } from 'hono' import { z } from 'zod' import { provisionTenant } from '../tenant/provisioner' const webhook = new Hono() const HubSpotWebhookPayloadSchema = z.object({ dealId: z.string(), properties: z.object({ dealname: z.string(), company_name: z.string(), industry: z.string(), employee_count: z.string().optional(), hubspot_owner_email: z.string().email(), }), }) webhook.post('/hubspot/demo-request', async (c) => { const payload = HubSpotWebhookPayloadSchema.parse(await c.req.json()) const employeeCount = parseInt(payload.properties.employee_count ?? '100', 10) const employeeScale = employeeCount >= 1000 ? 'enterprise' : employeeCount >= 200 ? 'large' : employeeCount >= 50 ? 'medium' : 'small' const industryMap: Record<string, string> = { 'SaaS': 'saas', 'FinTech': 'fintech', 'EC': 'ec_retail', '小売': 'ec_retail', '製造': 'manufacturing', 'メディア': 'media', } const industry = industryMap[payload.properties.industry] ?? 'other' const tenant = await provisionTenant({ companyName: payload.properties.company_name, industry: industry as 'saas' | 'fintech' | 'ec_retail' | 'manufacturing' | 'media' | 'other', employeeScale: employeeScale as 'small' | 'medium' | 'large' | 'enterprise', hubspotDealId: payload.dealId, createdBy: payload.properties.hubspot_owner_email, expiresInDays: 14, }) return c.json({ status: 'provisioned', demoUrl: tenant.demoUrl, expiresAt: tenant.expiresAt, }) }) export { webhook }

HubSpot Deal更新

デモ環境のURLと有効期限をDealのカスタムプロパティに書き戻す。

typescript
// src/crm/hubspot-deal.ts interface DealUpdateProperties { demo_url?: string demo_expires_at?: string demo_status?: string } export async function updateHubSpotDeal( dealId: string, properties: DealUpdateProperties ): Promise<void> { const apiKey = process.env.HUBSPOT_API_KEY if (!apiKey) { throw new Error('HUBSPOT_API_KEY is not configured') } const response = await fetch( `https://api.hubapi.com/crm/v3/objects/deals/${dealId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ properties }), } ) if (!response.ok) { const errorText = await response.text() throw new Error(`HubSpot Deal update failed: ${response.status} ${errorText}`) } }

デモ環境のライフサイクル管理

自動削除のcron

デモ環境の最大のコストリスクは「削除し忘れ」だ。期限切れテナントを自動削除するcronを実装する。

typescript
// src/lifecycle/cleanup.ts import { PrismaClient } from '@prisma/client' import { notifySlack } from '../notifications/slack' const prisma = new PrismaClient() export async function cleanupExpiredTenants(): Promise<{ expiredCount: number deletedCount: number errors: string[] }> { const now = new Date() // Step 1: 期限切れテナントを検索 const expiredTenants = await prisma.tenant.findMany({ where: { status: 'active', expiresAt: { lt: now }, }, }) const errors: string[] = [] let deletedCount = 0 // Step 2: 各テナントのデータを削除 for (const tenant of expiredTenants) { try { // カスケード削除: テナントに紐づく全データが削除される await prisma.tenant.update({ where: { id: tenant.id }, data: { status: 'expired' }, }) // 7日後に物理削除(猶予期間) const deleteAt = new Date(now) deleteAt.setDate(deleteAt.getDate() + 7) await prisma.tenant.update({ where: { id: tenant.id }, data: { status: 'deleted', updatedAt: deleteAt, }, }) deletedCount++ } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' errors.push(`Failed to cleanup tenant ${tenant.id}: ${message}`) } } // Step 3: 7日以上前にdeleted状態になったテナントを物理削除 const oldDeletedTenants = await prisma.tenant.findMany({ where: { status: 'deleted', updatedAt: { lt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), }, }, }) for (const tenant of oldDeletedTenants) { try { await prisma.tenant.delete({ where: { id: tenant.id }, }) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' errors.push(`Failed to physically delete tenant ${tenant.id}: ${message}`) } } // Step 4: 管理者に通知 if (expiredTenants.length > 0) { await notifySlack({ type: 'cleanup_report', expiredCount: expiredTenants.length, deletedCount, errors, }) } return { expiredCount: expiredTenants.length, deletedCount, errors, } }

Cloudflare Workers Scheduled Handler

typescript
// src/worker.ts import { Hono } from 'hono' import { tenantRoutes } from './routes/tenant' import { webhook } from './routes/webhook' import { cleanupExpiredTenants } from './lifecycle/cleanup' const app = new Hono() app.route('/api/tenants', tenantRoutes) app.route('/api/webhook', webhook) app.get('/health', (c) => c.json({ status: 'ok' })) export default { fetch: app.fetch, // Cron Trigger: 毎日03:00 UTCに実行 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { ctx.waitUntil(cleanupExpiredTenants()) }, }

Slack通知

typescript
// src/notifications/slack.ts type SlackNotification = | { type: 'demo_ready' companyName: string demoUrl: string expiresAt: string createdBy: string } | { type: 'cleanup_report' expiredCount: number deletedCount: number errors: string[] } export async function notifySlack(notification: SlackNotification): Promise<void> { const webhookUrl = process.env.SLACK_DEMO_WEBHOOK_URL if (!webhookUrl) return const blocks = buildSlackBlocks(notification) await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blocks }), }) } function buildSlackBlocks(notification: SlackNotification) { if (notification.type === 'demo_ready') { return [ { type: 'header', text: { type: 'plain_text', text: `Demo Ready: ${notification.companyName}`, }, }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*URL:* ${notification.demoUrl}` }, { type: 'mrkdwn', text: `*Expires:* ${notification.expiresAt}` }, { type: 'mrkdwn', text: `*Created by:* ${notification.createdBy}` }, ], }, { type: 'actions', elements: [ { type: 'button', text: { type: 'plain_text', text: 'Open Demo' }, url: notification.demoUrl, style: 'primary', }, ], }, ] } return [ { type: 'header', text: { type: 'plain_text', text: 'Demo Cleanup Report', }, }, { type: 'section', fields: [ { type: 'mrkdwn', text: `*Expired:* ${notification.expiredCount}` }, { type: 'mrkdwn', text: `*Deleted:* ${notification.deletedCount}` }, { type: 'mrkdwn', text: notification.errors.length > 0 ? `*Errors:* ${notification.errors.length}` : '*Errors:* None', }, ], }, ] }

運用上の注意点

コスト管理

デモ環境のコストはテナント数に比例する。放置テナントが増えるとDBのストレージコストが膨らむ。

コスト項目単価10社運用時の月額
Cloudflare Workers$5/月(固定)$5
PostgreSQL(Supabase Free / Neon)$0-25/月$0-25
Claude API(シードデータ生成)~$0.05/テナント$0.50
Cloudflare Pages(デモフロント)$0/月(無料枠)$0
合計$5.50-30.50

10社のデモ環境を同時運用しても月額$30程度。自動削除が確実に動いていれば、コストは制御可能だ。

自動削除が止まった場合の最大リスクを見積もる。90日期限のテナントが100個溜まった場合でも、行レベル分離なら追加のDB費用はストレージ量に比例するだけ。DB分離方式を選んでいたら100個のDBインスタンスが走り、月額数千ドルになっていた。

セキュリティ

カスタムデモ環境で最も重要なセキュリティルールは1つだ。顧客の本番データを絶対に使わない

すべてのデータはClaude APIが生成した架空のデータだ。メールアドレスは @example.com ドメイン、企業名は入力値を使うがデータは架空。これにより以下のリスクを排除する。

  • 他社のデモ環境に実データが漏洩するリスク: ゼロ(架空データのみ)
  • GDPR/個人情報保護法の対象: 架空データは個人情報に該当しない
  • テナント間のデータ漏洩: 行レベル分離 + テナントIDによるアクセス制御

追加のセキュリティ対策:

typescript
// src/middleware/security.ts import { Context, Next } from 'hono' export async function demoSecurityHeaders(c: Context, next: Next): Promise<void> { // デモ環境であることを明示 c.header('X-Demo-Environment', 'true') c.header('X-Robots-Tag', 'noindex, nofollow') // 基本的なセキュリティヘッダー c.header('X-Content-Type-Options', 'nosniff') c.header('X-Frame-Options', 'DENY') c.header('Referrer-Policy', 'strict-origin-when-cross-origin') await next() }

スケーリング

同時10社のデモ運用を前提に設計しているが、50社、100社に拡大する場合の考慮点を整理する。

規模ボトルネック対策
~10社なし現行構成で十分
10-50社DB接続数コネクションプーリング(PgBouncer / Supabase Pooler)
50-100社Claude API同時リクエストキュー処理(Cloudflare Queues)でシードデータ生成を非同期化
100社以上DBストレージ古いテナントの物理削除を積極化。シードデータのTTLを短縮

Cloudflare Workers自体はリクエスト数に対してスケールするため、API層がボトルネックになることはない。DBとClaude APIの制約が先に来る。

商談への効果測定

測定すべきメトリクス

カスタムデモ環境が商談にどう影響しているかを定量的に追跡する。HubSpotのカスタムプロパティとレポート機能で計測する。

メトリクス計測方法計算式
デモ提供率Deal中のdemo_url有無デモ提供Deal数 / 全Deal数
デモ後コンバージョン率demo_url有りDealの受注率デモ提供 & 受注 / デモ提供Deal数
非デモコンバージョン率demo_url無しDealの受注率非デモ & 受注 / 非デモDeal数
平均商談日数(デモ有)Deal作成日→受注日の平均デモ提供Dealの平均日数
平均商談日数(デモ無)Deal作成日→受注日の平均非デモDealの平均日数
デモ閲覧回数アクセスログ集計テナント別のページビュー数

効果の可視化

typescript
// src/analytics/demo-impact.ts import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() interface DemoImpactReport { period: string totalDeals: number dealsWithDemo: number dealsWithoutDemo: number conversionRateWithDemo: number conversionRateWithoutDemo: number avgDaysWithDemo: number avgDaysWithoutDemo: number conversionLift: number cycleDaysReduction: number } export async function generateDemoImpactReport( startDate: Date, endDate: Date ): Promise<DemoImpactReport> { // HubSpot APIからDealデータを取得 const deals = await fetchDealsFromHubSpot(startDate, endDate) const dealsWithDemo = deals.filter((d) => d.properties.demo_url) const dealsWithoutDemo = deals.filter((d) => !d.properties.demo_url) const wonWithDemo = dealsWithDemo.filter((d) => d.properties.dealstage === 'closedwon') const wonWithoutDemo = dealsWithoutDemo.filter((d) => d.properties.dealstage === 'closedwon') const conversionWithDemo = dealsWithDemo.length > 0 ? wonWithDemo.length / dealsWithDemo.length : 0 const conversionWithoutDemo = dealsWithoutDemo.length > 0 ? wonWithoutDemo.length / dealsWithoutDemo.length : 0 const avgDaysWithDemo = calculateAvgDealDays(wonWithDemo) const avgDaysWithoutDemo = calculateAvgDealDays(wonWithoutDemo) return { period: `${startDate.toISOString().split('T')[0]} - ${endDate.toISOString().split('T')[0]}`, totalDeals: deals.length, dealsWithDemo: dealsWithDemo.length, dealsWithoutDemo: dealsWithoutDemo.length, conversionRateWithDemo: Math.round(conversionWithDemo * 1000) / 10, conversionRateWithoutDemo: Math.round(conversionWithoutDemo * 1000) / 10, avgDaysWithDemo: Math.round(avgDaysWithDemo), avgDaysWithoutDemo: Math.round(avgDaysWithoutDemo), conversionLift: Math.round((conversionWithDemo - conversionWithoutDemo) * 1000) / 10, cycleDaysReduction: Math.round(avgDaysWithoutDemo - avgDaysWithDemo), } } function calculateAvgDealDays(deals: HubSpotDeal[]): number { if (deals.length === 0) return 0 const totalDays = deals.reduce((sum, deal) => { const created = new Date(deal.properties.createdate) const closed = new Date(deal.properties.closedate) const days = (closed.getTime() - created.getTime()) / (1000 * 60 * 60 * 24) return sum + days }, 0) return totalDays / deals.length } interface HubSpotDeal { id: string properties: { dealname: string dealstage: string demo_url?: string createdate: string closedate: string } } async function fetchDealsFromHubSpot( startDate: Date, endDate: Date ): Promise<HubSpotDeal[]> { const apiKey = process.env.HUBSPOT_API_KEY if (!apiKey) { throw new Error('HUBSPOT_API_KEY is not configured') } const response = await fetch( 'https://api.hubapi.com/crm/v3/objects/deals/search', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ filterGroups: [{ filters: [ { propertyName: 'createdate', operator: 'GTE', value: startDate.getTime(), }, { propertyName: 'createdate', operator: 'LTE', value: endDate.getTime(), }, ], }], properties: [ 'dealname', 'dealstage', 'demo_url', 'createdate', 'closedate', ], limit: 100, }), } ) if (!response.ok) { throw new Error(`HubSpot search failed: ${response.status}`) } const data = await response.json() as { results: HubSpotDeal[] } return data.results }

営業からのフィードバックを仕組みに反映する

デモ環境の改善サイクルは、営業からのフィードバックに依存する。定量メトリクスだけでなく、定性的なフィードバックも収集する仕組みを組み込む。

typescript
// src/routes/feedback.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const FeedbackSchema = z.object({ tenantId: z.string().uuid(), dealId: z.string(), salesRepEmail: z.string().email(), rating: z.number().min(1).max(5), clientReaction: z.enum([ 'very_positive', 'positive', 'neutral', 'confused', 'negative', ]), seedDataQuality: z.number().min(1).max(5), missingFeatures: z.array(z.string()).default([]), freeformFeedback: z.string().max(1000).optional(), }) const feedback = new Hono() feedback.post( '/submit', zValidator('json', FeedbackSchema), async (c) => { const data = c.req.valid('json') // DBに保存 await prisma.seedData.create({ data: { tenantId: data.tenantId, dataType: 'sales_feedback', data: data as unknown as object, }, }) // シードデータ品質が低い場合はアラート if (data.seedDataQuality <= 2) { await notifySlack({ type: 'low_quality_alert', companyName: data.tenantId, feedback: data.freeformFeedback ?? 'No details provided', }) } return c.json({ status: 'recorded' }) } ) export { feedback }

営業が商談後に1分で入力できるフィードバックフォーム。このデータが溜まれば、業種別テンプレートの改善やClaude APIのプロンプトチューニングに活かせる。

アプリケーション全体の構成

typescript
// src/app.ts import { Hono } from 'hono' import { logger } from 'hono/logger' import { tenantRoutes } from './routes/tenant' import { webhook } from './routes/webhook' import { feedback } from './routes/feedback' import { tenantResolver } from './middleware/tenant-resolver' import { demoSecurityHeaders } from './middleware/security' const app = new Hono() // 共通ミドルウェア app.use('*', logger()) // 管理API(認証必要) app.route('/api/tenants', tenantRoutes) app.route('/api/webhook', webhook) app.route('/api/feedback', feedback) // デモ環境(サブドメインルーティング) const demoApp = new Hono() demoApp.use('*', demoSecurityHeaders) demoApp.use('*', tenantResolver) demoApp.get('/', (c) => { const tenant = c.get('tenant') return c.json({ companyName: tenant.companyName, industry: tenant.industry, status: tenant.status, }) }) // ヘルスチェック app.get('/health', (c) => c.json({ status: 'ok' })) export default app

構築したものと次の課題

この記事で実装したのは以下だ。

  • マルチテナント対応のデモ環境プロビジョニングAPI(Hono + Cloudflare Workers)
  • 業種別テンプレート + Claude APIによるシードデータ自動生成
  • HubSpot Webhook連携による営業トリガー起動
  • 自動削除のcronによるライフサイクル管理
  • 効果測定ダッシュボードのデータ基盤

設計判断でいくつかポイントがある。テナント分離は行レベルを選んだ(構築速度とコスト優先)。インフラはCloudflare Workers(エッジ配信 + コスト予測可能性)。シードデータの「もっともらしさ」はClaude APIに任せた。自動削除には7日の猶予期間を設けた。

SLG商談において、カスタムデモ環境は「パワーポイント → 動くもの」への転換を実現する。Palantir FDSEが1社ごとに手作業で構築していたものを、GTMエンジニアが仕組み化することで全営業チームに展開できる。

15分で立ち上がるデモ環境は、営業が商談中にその場で構築を始められる。「持ち帰って確認します」が「今お見せします」に変わる。その速度差が、商談サイクルの短縮に直結する。

次の記事ではCLAUDE.mdをセールスエンジニアリングに活用する手法を取り上げる。プロダクトの構造化された知識をCLAUDE.mdに集約し、営業支援ツールの開発生産性を上げる方法を書く。


参考資料

$ echo $TAGS
#デモ環境#SLG#商談#マルチテナント#Cloudflare Workers#TypeScript