[{"data":1,"prerenderedAt":1212},["ShallowReactive",2],{"repo-tree":3,"repo-\u002Finfra\u002Fopenreplay\u002Freadme":283},[4,7,10,13,16,19,22,25,28,31,34,37,40,43,46,49,52,55,58,61,64,67,69,72,75,78,81,84,86,88,90,93,96,99,102,105,108,111,114,117,120,123,125,127,129,131,133,135,138,141,143,146,149,152,155,158,161,164,167,169,172,175,178,180,183,186,189,192,195,198,201,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254,257,260,263,266,269,272,275,278,281],{"path":5,"title":6},"\u002Fagents\u002Fbackend-code-style","Backend Conventions",{"path":8,"title":9},"\u002Fagents\u002Fdatabase","Database",{"path":11,"title":12},"\u002Fagents\u002Fportal-code-style","Portal Conventions",{"path":14,"title":15},"\u002Fagents\u002Ftranslation","Translation",{"path":17,"title":18},"\u002Fconventions\u002Fbackend-coding","Backend coding conventions",{"path":20,"title":21},"\u002Fconventions\u002Ffrontend-coding","Frontend coding conventions",{"path":23,"title":24},"\u002Fdevelopment-process","Development process",{"path":26,"title":27},"\u002Flearning-api-preview-hetzner-setup","Learning API Preview on Hetzner + Cloudflare",{"path":29,"title":30},"\u002Flearning-api-preview-vm-plan","Learning API Preview VM Plan",{"path":32,"title":33},"\u002Fmonorepo-structure","Monorepo structure",{"path":35,"title":36},"\u002Foperations","Operations — bugs and support",{"path":38,"title":39},"\u002Fpostmortems\u002F2026-03-16_onboarding-currency-regression","Onboarding Zod transform silently broken — web signups assigned wrong checkout currency",{"path":41,"title":42},"\u002Fpostmortems\u002Freadme","Postmortems",{"path":44,"title":45},"\u002Fpostmortems\u002F_template","TEMPLATE",{"path":47,"title":48},"\u002Fpostmortems\u002Fposthog-comparison","Postmortem practice — comparison with PostHog",{"path":50,"title":51},"\u002Fpreview-environment-plan","Preview Environment Plan",{"path":53,"title":54},"\u002Fprinciples","Engineering principles",{"path":56,"title":57},"\u002Fworking-with-ai","Working with AI",{"path":59,"title":60},"\u002F.claude\u002Fskills\u002Feval-playground\u002Fskill","Eval Playground — Co-development Skill",{"path":62,"title":63},"\u002F.claude\u002Fskills\u002Ffigma-diff-section\u002Fskill","Figma Diff Section Pipeline",{"path":65,"title":66},"\u002Fagents","AGENTS.md",{"path":68,"title":66},"\u002Fclaude",{"path":70,"title":71},"\u002Freadme","Studyflash",{"path":73,"title":74},"\u002Fapps\u002Fcore-api\u002Fagents","Core API (apps\u002Fcore-api)",{"path":76,"title":77},"\u002Fapps\u002Fcore-api\u002Freadme","README",{"path":79,"title":80},"\u002Fapps\u002Femail-previews\u002Fagents","Email Previews (apps\u002Femail-previews)",{"path":82,"title":83},"\u002Fapps\u002Flanding-page\u002Fagents","Landing Page (apps\u002Flanding-page)",{"path":85,"title":83},"\u002Fapps\u002Flanding-page\u002Fclaude",{"path":87,"title":66},"\u002Fapps\u002Flearning-api\u002Fagents",{"path":89,"title":77},"\u002Fapps\u002Flearning-api\u002Freadme",{"path":91,"title":92},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Feval_metrics_design","Surface-Specific Eval Metrics Design",{"path":94,"title":95},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Ftest_set","Quiz Eval Test Set",{"path":97,"title":98},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Ffrontend\u002Freadme","React + TypeScript + Vite",{"path":100,"title":101},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fcontent-pillar-shallow-coverage\u002Freadme","Content pillar misses subtopics in dense documents",{"path":103,"title":104},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fdocling-empty-section-headers\u002Freadme","Empty section headers dropped by docling chunker",{"path":106,"title":107},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fknown-issues\u002Fdocling-table-reading-order\u002Freadme","Table\u002Fbox layout causes wrong reading order",{"path":109,"title":110},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Fmetrics\u002Freadme","Quiz eval metrics — canonical rubrics",{"path":112,"title":113},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-04-12-quiz-summary-feedback-current-state","Quiz and Summary Feedback Current State",{"path":115,"title":116},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-04-24-quiz-eval-metrics","Quiz Evaluation Metrics",{"path":118,"title":119},"\u002Fapps\u002Flearning-api\u002Fevals-playground\u002Freports\u002F2026-05-01-quiz-eval-current-state","Quiz Eval Current State",{"path":121,"title":122},"\u002Fapps\u002Flearning-api\u002Fmonitoring\u002Freadme","Monitoring Stack",{"path":124,"title":77},"\u002Fapps\u002Flearning-api\u002Fshared\u002Freadme",{"path":126,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fflashcard_agent\u002Freadme",{"path":128,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fingestion_agent\u002Freadme",{"path":130,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fquiz_agent\u002Freadme",{"path":132,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Flearning_agents\u002Fsummary_agent\u002Freadme",{"path":134,"title":77},"\u002Fapps\u002Flearning-api\u002Fworkers\u002Fparser\u002Freadme",{"path":136,"title":137},"\u002Fapps\u002Fmarketing-emails-preview\u002Fagents","Marketing Emails Preview (apps\u002Fmarketing-emails-preview)",{"path":139,"title":140},"\u002Fapps\u002Fmobile-app\u002Fagents","StudyFlash Mobile App - Claude Code Configuration",{"path":142,"title":140},"\u002Fapps\u002Fmobile-app\u002Fclaude",{"path":144,"title":145},"\u002Fapps\u002Fmountain-max\u002Fagents","Mountain Max (apps\u002Fmountain-max)",{"path":147,"title":148},"\u002Fapps\u002Fmountain-max\u002Fgame\u002Freadme","Mountain Max Game",{"path":150,"title":151},"\u002Fapps\u002Fportal\u002Fagents","Portal (apps\u002Fportal)",{"path":153,"title":154},"\u002Fapps\u002Fportal\u002Freadme","Nuxt Minimal Starter",{"path":156,"title":157},"\u002Fapps\u002Fportal\u002Fapp\u002Fcomposables\u002Ffiles\u002Freadme","File Upload Composables",{"path":159,"title":160},"\u002Fapps\u002Fportal\u002Fdocs\u002Flibrary-routing","Library Routing Documentation",{"path":162,"title":163},"\u002Fapps\u002Fsupabase\u002Fagents","Supabase (apps\u002Fsupabase)",{"path":165,"title":166},"\u002Fapps\u002Fwrapped\u002Fagents","Wrapped (apps\u002Fwrapped)",{"path":168,"title":98},"\u002Fapps\u002Fwrapped\u002Freadme",{"path":170,"title":171},"\u002Finfra\u002Freadme","infra\u002F",{"path":173,"title":174},"\u002Finfra\u002Fdns\u002Freadme","DNS Infrastructure",{"path":176,"title":177},"\u002Finfra\u002Fdokploy\u002Freadme","studyflash-dokploy",{"path":179,"title":77},"\u002Finfra\u002Fdokploy\u002Fsdk\u002Fnodejs\u002Freadme",{"path":181,"title":182},"\u002Finfra\u002Finfisical\u002Freadme","Infisical Infrastructure",{"path":184,"title":185},"\u002Finfra\u002Flearning-api\u002Freadme","Pulumi GCP TypeScript Template",{"path":187,"title":188},"\u002Finfra\u002Fopenreplay\u002Freadme","OpenReplay on Hetzner",{"path":190,"title":191},"\u002Finfra\u002Fscripts\u002Freadme","infra\u002Fscripts\u002F",{"path":193,"title":194},"\u002Finfra\u002Fturborepo-cache\u002Freadme","Turborepo Remote Cache Infrastructure",{"path":196,"title":197},"\u002Finternal\u002Fchatwoot\u002Freadme","Chatwoot Infrastructure",{"path":199,"title":200},"\u002Finternal\u002Fchatwoot\u002Fprovider\u002Freadme","studyflash-chatwoot-provider",{"path":202,"title":77},"\u002Finternal\u002Fchatwoot\u002Fprovider\u002Fsdk\u002Fnodejs\u002Freadme",{"path":204,"title":205},"\u002Finternal\u002Fdocs\u002Freadme","internal\u002Fdocs",{"path":207,"title":208},"\u002Finternal\u002Fsupport-bot\u002Fclaude","Support Bot (Maximilian)",{"path":210,"title":211},"\u002Finternal\u002Fsupport-bot\u002Freadme","Studyflash Customer Support Bot (Maximilian)",{"path":213,"title":214},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Faccount_issues","Account Issues",{"path":216,"title":217},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fbilling_invoice","Billing Invoice",{"path":219,"title":220},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fcontent_upload","Content Upload",{"path":222,"title":223},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fdata_loss","Data Loss",{"path":225,"title":226},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fflashcard_issues","Flashcard Issues",{"path":228,"title":229},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fgarbage","Garbage",{"path":231,"title":232},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fgeneral_how_to","General How To",{"path":234,"title":235},"\u002Finternal\u002Fsupport-bot\u002Fkb","Knowledge Base Index",{"path":237,"title":238},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Flanguage_issues","Language Issues",{"path":240,"title":241},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmindmap_issues","Mindmap Issues",{"path":243,"title":244},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmisunderstanding","Misunderstanding",{"path":246,"title":247},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fmock_exam_issues","Mock Exam Issues",{"path":249,"title":250},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fpodcast_issues","Podcast Issues",{"path":252,"title":253},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fquiz_issues","Quiz Issues",{"path":255,"title":256},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Frefund_request","Refund Request",{"path":258,"title":259},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsubscription_cancellation","Subscription Cancellation",{"path":261,"title":262},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsubscription_info","Subscription Info",{"path":264,"title":265},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fsummary_issues","Summary Issues",{"path":267,"title":268},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Ftechnical_errors","Technical Errors",{"path":270,"title":271},"\u002Finternal\u002Fsupport-bot\u002Fkb\u002Fvideo_issues","Video Issues",{"path":273,"title":274},"\u002Fpackages\u002Fcommon\u002Fdocs\u002Fearly-access-features","Declarative Early Access Features",{"path":276,"title":277},"\u002Fpackages\u002Fcommon\u002Fscripts\u002Freadme","Common Package Scripts",{"path":279,"title":280},"\u002Fpackages\u002Fdevtools\u002Ffigma-plugins\u002Freadme","Figma plugins",{"path":282,"title":77},"\u002Fpackages\u002Fpulumi-infisical\u002Freadme",{"id":284,"title":188,"body":285,"description":1205,"extension":1206,"lastReviewed":1207,"meta":1208,"navigation":757,"owner":1207,"path":187,"seo":1209,"status":1207,"stem":1210,"tags":1207,"__hash__":1211},"repo\u002Finfra\u002Fopenreplay\u002FREADME.md",{"type":286,"value":287,"toc":1192},"minimark",[288,292,313,318,324,377,382,427,432,445,450,506,514,628,632,639,681,692,696,711,725,729,912,916,927,933,937,964,967,1105,1108,1112,1142,1146,1158,1169,1173,1188],[289,290,188],"h1",{"id":291},"openreplay-on-hetzner",[293,294,295,296,303,304,308,309,312],"p",{},"Self-hosted ",[297,298,302],"a",{"href":299,"rel":300},"https:\u002F\u002Fdocs.openreplay.com\u002Fen\u002Fdeployment\u002Fdeploy-ubuntu\u002F",[301],"nofollow","OpenReplay","\nrunning on a single Hetzner Cloud VM. The VM ",[305,306,307],"code",{},"userData"," runs the documented\n",[305,310,311],{},"openreplay -i $DOMAIN"," installer (which sets up K3s with embedded containerd,\nplus helm\u002Ftemplater\u002Fkubectl as standalone binaries — no Docker on the host).",[314,315,317],"h2",{"id":316},"architecture","Architecture",[293,319,320],{},[321,322,323],"strong",{},"Two-domain split:",[325,326,327,340],"ul",{},[328,329,330,335,336,339],"li",{},[321,331,332],{},[305,333,334],{},"openreplay.studyflash.dev"," — admin dashboard. CF-proxied A record at the\nVM's public IP. Inherits the existing ",[305,337,338],{},"*.studyflash.dev"," Cloudflare Access\nwildcard, so the dashboard requires SSO.",[328,341,342,347,348,351,352,355,356,359,360,363,364,368,369,372,373,376],{},[321,343,344],{},[305,345,346],{},"or.studyflash.com"," — tracker ingest endpoint. Bound via\n",[305,349,350],{},"WorkersCustomDomain"," to a Cloudflare Worker (",[305,353,354],{},"openreplay-ingest",") whose\nsource (",[305,357,358],{},"scripts\u002Fingest-worker.js",") reverse-proxies every path to\n",[305,361,362],{},"https:\u002F\u002Fopenreplay.studyflash.dev\u002F\u003Csame-path>",". A path-scoped CF Access\n",[365,366,367],"em",{},"bypass"," on ",[305,370,371],{},"openreplay.studyflash.dev\u002Fingest\u002F*"," lets the Worker's tracker\npayloads through without an Access challenge. Neutral host so ad-blocker\nrules pattern-matching ",[305,374,375],{},"openreplay.*"," don't break the tracker.",[293,378,379],{},[321,380,381],{},"TLS:",[325,383,384,391,402,409],{},[328,385,386,387,390],{},"User TLS terminates at the Cloudflare edge with CF's Universal SSL cert\n(browsers see ",[305,388,389],{},"studyflash.dev",").",[328,392,393,394,397,398,401],{},"CF→origin uses ",[321,395,396],{},"Full (Strict)"," mode and validates the ",[321,399,400],{},"Cloudflare\nOrigin CA cert"," the VM presents (15-year validity).",[328,403,404,405,408],{},"Origin CA cert + key are issued out-of-band (see TLS section below) and\nstored base64-encoded in Infisical. Pulumi reads them at ",[305,406,407],{},"pulumi up"," time,\ndecodes, marks as secret, and bakes the PEMs into the VM's userData\n(encrypted in Pulumi state under GCP KMS).",[328,410,411,414,415,418,419,422,423,426],{},[305,412,413],{},"bootstrap.sh"," writes them into the ",[305,416,417],{},"openreplay-ssl"," Secret in the ",[305,420,421],{},"app","\nnamespace before ",[305,424,425],{},"openreplay -i"," runs, so the OpenReplay Ingress finds the\ncert immediately on first install. No cert-manager, no Let's Encrypt, no\nDNS-01.",[293,428,429],{},[321,430,431],{},"Network lockdown:",[325,433,434],{},[328,435,436,437,440,441,444],{},"Hetzner firewall: 22 (SSH) and ICMP open to the world; ",[321,438,439],{},"443 locked to\nCloudflare's published IP ranges"," (",[305,442,443],{},"cloudflare.getIpRangesOutput()","); port\n80 closed entirely. Origin is invisible to direct IP probes from non-CF IPs.",[293,446,447],{},[321,448,449],{},"Tracker init in client apps:",[451,452,457],"pre",{"className":453,"code":454,"language":455,"meta":456,"style":456},"language-js shiki shiki-themes github-light github-dark","new OpenReplay({\n  projectKey: \"...\",\n  ingestPoint: \"https:\u002F\u002For.studyflash.com\u002Fingest\",\n});\n","js","",[305,458,459,476,489,500],{"__ignoreMap":456},[460,461,464,468,472],"span",{"class":462,"line":463},"line",1,[460,465,467],{"class":466},"szBVR","new",[460,469,471],{"class":470},"sScJk"," OpenReplay",[460,473,475],{"class":474},"sVt8B","({\n",[460,477,479,482,486],{"class":462,"line":478},2,[460,480,481],{"class":474},"  projectKey: ",[460,483,485],{"class":484},"sZZnC","\"...\"",[460,487,488],{"class":474},",\n",[460,490,492,495,498],{"class":462,"line":491},3,[460,493,494],{"class":474},"  ingestPoint: ",[460,496,497],{"class":484},"\"https:\u002F\u002For.studyflash.com\u002Fingest\"",[460,499,488],{"class":474},[460,501,503],{"class":462,"line":502},4,[460,504,505],{"class":474},"});\n",[314,507,509,510,513],{"id":508},"stack-config-pulumistackyaml","Stack config (",[305,511,512],{},"Pulumi.\u003Cstack>.yaml",")",[515,516,517,533],"table",{},[518,519,520],"thead",{},[521,522,523,527,530],"tr",{},[524,525,526],"th",{},"Key",[524,528,529],{},"Default",[524,531,532],{},"Notes",[534,535,536,551,575,590,605],"tbody",{},[521,537,538,544,548],{},[539,540,541],"td",{},[305,542,543],{},"studyflash-openreplay:domain",[539,545,546],{},[305,547,334],{},[539,549,550],{},"Dashboard hostname. CF-proxied A record, Origin CA cert on the VM.",[521,552,553,558,562],{},[539,554,555],{},[305,556,557],{},"studyflash-openreplay:ingestDomain",[539,559,560],{},[305,561,346],{},[539,563,564,565,567,568,570,571,574],{},"Tracker ingest hostname. Bound to the ",[305,566,354],{}," Worker via ",[305,569,350],{}," on the ",[305,572,573],{},"studyflash.com"," zone.",[521,576,577,582,587],{},[539,578,579],{},[305,580,581],{},"studyflash-openreplay:serverType",[539,583,584],{},[305,585,586],{},"ccx23",[539,588,589],{},"4 vCPU dedicated \u002F 16 GB \u002F 160 GB. Bundled disk is below the 240 GB hard minimum, so a Volume is attached (see below).",[521,591,592,597,602],{},[539,593,594],{},[305,595,596],{},"studyflash-openreplay:location",[539,598,599],{},[305,600,601],{},"nbg1",[539,603,604],{},"Hetzner DC. Volume is created in the same location.",[521,606,607,612,617],{},[539,608,609],{},[305,610,611],{},"studyflash-openreplay:dataVolumeSize",[539,613,614],{},[305,615,616],{},"100",[539,618,619,620,623,624,627],{},"GB. Attached as ext4 and bind-mounted at ",[305,621,622],{},"\u002Fvar\u002Flib\u002Francher"," + ",[305,625,626],{},"\u002Fvar\u002Flib\u002Fopenreplay"," so K3s PVCs (replays, MinIO, ClickHouse) land on the volume. 160 + 100 = 260 GB total, comfortably above 240.",[314,629,631],{"id":630},"secrets-infisical","Secrets \u002F Infisical",[293,633,634,635,638],{},"Read from Infisical at ",[305,636,637],{},"\u002Finfra\u002Fopenreplay\u002F",":",[325,640,641,647,653,659,669,675],{},[328,642,643,646],{},[305,644,645],{},"HCLOUD_TOKEN"," — Hetzner Cloud API token",[328,648,649,652],{},[305,650,651],{},"CLOUDFLARE_API_TOKEN"," — Cloudflare API token (DNS, Workers, Access apps,\nRulesets, Worker custom domains, all on the studyflash account)",[328,654,655,658],{},[305,656,657],{},"PULUMI_BACKEND_URL"," — R2 backend URL (s3-compatible)",[328,660,661,664,665,668],{},[305,662,663],{},"AWS_ACCESS_KEY_ID"," \u002F ",[305,666,667],{},"AWS_SECRET_ACCESS_KEY"," — R2 credentials for the\nPulumi backend",[328,670,671,674],{},[305,672,673],{},"OPENREPLAY_ORIGIN_CERT_B64"," — base64-encoded Origin CA cert PEM",[328,676,677,680],{},[305,678,679],{},"OPENREPLAY_ORIGIN_KEY_B64"," — base64-encoded private key PEM matching the cert",[293,682,683,684,687,688,691],{},"GCP credentials for the KMS secrets provider come from the operator's local\n",[305,685,686],{},"gcloud auth application-default login"," — not Infisical. Pulumi state lives on\nthe shared R2 backend; secrets are encrypted with the same ",[305,689,690],{},"pulumi-state"," GCP\nKMS key used by the other infra stacks.",[314,693,695],{"id":694},"volume-protection","Volume protection",[293,697,698,699,702,703,706,707,710],{},"The data volume has both ",[305,700,701],{},"protect: true"," (Pulumi-side) and ",[305,704,705],{},"deleteProtection: true"," (Hetzner API). Replacing only the Server (",[305,708,709],{},"pulumi up --replace \u003Cserver-urn>",") is fine — new VM mounts the volume, K3s resumes from the\nexisting data dir. Replacing the Volume requires lifting both protections\nmanually first; doing so destroys all OpenReplay data (Postgres, ClickHouse,\nMinIO, replays).",[293,712,713,714,488,717,720,721,724],{},"Hetzner does not have automatic volume backups (unlike AWS EBS); for real\ndurability we should layer on app-level backups (",[305,715,716],{},"pg_dump → R2",[305,718,719],{},"mc mirror → R2",", ",[305,722,723],{},"clickhouse-backup",") as a follow-up.",[314,726,728],{"id":727},"first-run","First run",[451,730,734],{"className":731,"code":732,"language":733,"meta":456,"style":456},"language-sh shiki shiki-themes github-light github-dark","cd infra\u002Fopenreplay\npnpm install\n\n# Log in to the R2 Pulumi backend (Infisical injects PULUMI_BACKEND_URL)\ninfisical run --env prod --path \u002Finfra\u002Fopenreplay\u002F -- pulumi login \"$PULUMI_BACKEND_URL\"\n\n# Initialize the prod stack against the project's GCP KMS secrets provider.\n# This populates the `encryptedkey` field in Pulumi.prod.yaml automatically.\ninfisical run --env prod --path \u002Finfra\u002Fopenreplay\u002F -- \\\n  pulumi stack init prod \\\n    --secrets-provider=\"gcpkms:\u002F\u002Fprojects\u002Fstudyflash-security\u002Flocations\u002Feurope-west6\u002FkeyRings\u002Fpulumi-state\u002FcryptoKeys\u002Fpulumi-state\"\n\n# Verify types compile\npnpm typecheck\n\n# Provision (will fail loudly if OPENREPLAY_ORIGIN_CERT_B64\u002FKEY_B64 aren't in\n# Infisical — see \"TLS\" section for how to issue + populate them).\npnpm run pulumi:up\n","sh",[305,735,736,745,753,759,765,804,809,815,821,841,857,866,871,877,885,890,896,902],{"__ignoreMap":456},[460,737,738,742],{"class":462,"line":463},[460,739,741],{"class":740},"sj4cs","cd",[460,743,744],{"class":484}," infra\u002Fopenreplay\n",[460,746,747,750],{"class":462,"line":478},[460,748,749],{"class":470},"pnpm",[460,751,752],{"class":484}," install\n",[460,754,755],{"class":462,"line":491},[460,756,758],{"emptyLinePlaceholder":757},true,"\n",[460,760,761],{"class":462,"line":502},[460,762,764],{"class":763},"sJ8bj","# Log in to the R2 Pulumi backend (Infisical injects PULUMI_BACKEND_URL)\n",[460,766,768,771,774,777,780,783,786,789,792,795,798,801],{"class":462,"line":767},5,[460,769,770],{"class":470},"infisical",[460,772,773],{"class":484}," run",[460,775,776],{"class":740}," --env",[460,778,779],{"class":484}," prod",[460,781,782],{"class":740}," --path",[460,784,785],{"class":484}," \u002Finfra\u002Fopenreplay\u002F",[460,787,788],{"class":740}," --",[460,790,791],{"class":484}," pulumi",[460,793,794],{"class":484}," login",[460,796,797],{"class":484}," \"",[460,799,800],{"class":474},"$PULUMI_BACKEND_URL",[460,802,803],{"class":484},"\"\n",[460,805,807],{"class":462,"line":806},6,[460,808,758],{"emptyLinePlaceholder":757},[460,810,812],{"class":462,"line":811},7,[460,813,814],{"class":763},"# Initialize the prod stack against the project's GCP KMS secrets provider.\n",[460,816,818],{"class":462,"line":817},8,[460,819,820],{"class":763},"# This populates the `encryptedkey` field in Pulumi.prod.yaml automatically.\n",[460,822,824,826,828,830,832,834,836,838],{"class":462,"line":823},9,[460,825,770],{"class":470},[460,827,773],{"class":484},[460,829,776],{"class":740},[460,831,779],{"class":484},[460,833,782],{"class":740},[460,835,785],{"class":484},[460,837,788],{"class":740},[460,839,840],{"class":740}," \\\n",[460,842,844,847,850,853,855],{"class":462,"line":843},10,[460,845,846],{"class":484},"  pulumi",[460,848,849],{"class":484}," stack",[460,851,852],{"class":484}," init",[460,854,779],{"class":484},[460,856,840],{"class":740},[460,858,860,863],{"class":462,"line":859},11,[460,861,862],{"class":740},"    --secrets-provider=",[460,864,865],{"class":484},"\"gcpkms:\u002F\u002Fprojects\u002Fstudyflash-security\u002Flocations\u002Feurope-west6\u002FkeyRings\u002Fpulumi-state\u002FcryptoKeys\u002Fpulumi-state\"\n",[460,867,869],{"class":462,"line":868},12,[460,870,758],{"emptyLinePlaceholder":757},[460,872,874],{"class":462,"line":873},13,[460,875,876],{"class":763},"# Verify types compile\n",[460,878,880,882],{"class":462,"line":879},14,[460,881,749],{"class":470},[460,883,884],{"class":484}," typecheck\n",[460,886,888],{"class":462,"line":887},15,[460,889,758],{"emptyLinePlaceholder":757},[460,891,893],{"class":462,"line":892},16,[460,894,895],{"class":763},"# Provision (will fail loudly if OPENREPLAY_ORIGIN_CERT_B64\u002FKEY_B64 aren't in\n",[460,897,899],{"class":462,"line":898},17,[460,900,901],{"class":763},"# Infisical — see \"TLS\" section for how to issue + populate them).\n",[460,903,905,907,909],{"class":462,"line":904},18,[460,906,749],{"class":470},[460,908,773],{"class":484},[460,910,911],{"class":484}," pulumi:up\n",[314,913,915],{"id":914},"ssh-access","SSH access",[293,917,918,919,922,923,926],{},"The VM ships with Ubuntu's defaults: port 22 open, password auth on, no\nstatic SSH keys baked in. For first login, regenerate the root password\nwith ",[305,920,921],{},"hcloud server reset-password openreplay"," (Hetzner returns it\ninline) or grab it from the Hetzner Cloud panel. After logging in, add\nyour own pubkey to ",[305,924,925],{},"\u002Froot\u002F.ssh\u002Fauthorized_keys"," so subsequent sessions\nuse key auth.",[293,928,929,930,932],{},"A short-lived-cert replacement (Infisical SSH or similar) is on the\nroadmap but not in place — ",[305,931,413],{}," does not configure any SSH CA\nor restrict password auth.",[314,934,936],{"id":935},"tls","TLS",[293,938,939,940,943,944,951,952,955,956,959,960,963],{},"The Origin CA cert + key are issued ",[321,941,942],{},"once"," out-of-band, stored in Infisical,\nand read into Pulumi state as secrets. ",[321,945,946,947,950],{},"Pulumi-side issuance via\n",[305,948,949],{},"cloudflare.OriginCaCertificate"," isn't viable",": the CF ",[305,953,954],{},"\u002Fcertificates","\nendpoint doesn't accept modern API tokens (you get 1016 regardless of perms),\nand the Pulumi cloudflare provider's ",[305,957,958],{},"apiKey"," field rejects CF's current\n",[305,961,962],{},"cfk_…"," key format because it validates against the legacy 37-hex-char schema.\nUntil both sides catch up, we stay out-of-band.",[293,965,966],{},"To issue (one-time bootstrap, then never again):",[451,968,970],{"className":731,"code":969,"language":733,"meta":456,"style":456},"# 1) In Cloudflare dashboard: SSL\u002FTLS → Origin Server → Create Certificate.\n#    Pick RSA, list \"openreplay.studyflash.dev\", validity 15 years.\n#    Copy BOTH the cert PEM and the private key PEM (the key is shown once).\n\n# 2) Base64-encode both (single-line, no quoting issues for Infisical):\nCERT_B64=$(base64 -w0 \u003C cert.pem)\nKEY_B64=$(base64 -w0  \u003C key.pem)\n\n# 3) Store in Infisical at \u002Finfra\u002Fopenreplay\u002F:\ninfisical secrets set \\\n  --projectId 0cfec798-5081-4028-b142-a46080728d1f --env prod --path \u002Finfra\u002Fopenreplay\u002F \\\n  \"OPENREPLAY_ORIGIN_CERT_B64=$CERT_B64\" \\\n  \"OPENREPLAY_ORIGIN_KEY_B64=$KEY_B64\"\n",[305,971,972,977,982,987,991,996,1022,1043,1047,1052,1064,1082,1095],{"__ignoreMap":456},[460,973,974],{"class":462,"line":463},[460,975,976],{"class":763},"# 1) In Cloudflare dashboard: SSL\u002FTLS → Origin Server → Create Certificate.\n",[460,978,979],{"class":462,"line":478},[460,980,981],{"class":763},"#    Pick RSA, list \"openreplay.studyflash.dev\", validity 15 years.\n",[460,983,984],{"class":462,"line":491},[460,985,986],{"class":763},"#    Copy BOTH the cert PEM and the private key PEM (the key is shown once).\n",[460,988,989],{"class":462,"line":502},[460,990,758],{"emptyLinePlaceholder":757},[460,992,993],{"class":462,"line":767},[460,994,995],{"class":763},"# 2) Base64-encode both (single-line, no quoting issues for Infisical):\n",[460,997,998,1001,1004,1007,1010,1013,1016,1019],{"class":462,"line":806},[460,999,1000],{"class":474},"CERT_B64",[460,1002,1003],{"class":466},"=",[460,1005,1006],{"class":474},"$(",[460,1008,1009],{"class":470},"base64",[460,1011,1012],{"class":740}," -w0",[460,1014,1015],{"class":466}," \u003C",[460,1017,1018],{"class":484}," cert.pem",[460,1020,1021],{"class":474},")\n",[460,1023,1024,1027,1029,1031,1033,1035,1038,1041],{"class":462,"line":811},[460,1025,1026],{"class":474},"KEY_B64",[460,1028,1003],{"class":466},[460,1030,1006],{"class":474},[460,1032,1009],{"class":470},[460,1034,1012],{"class":740},[460,1036,1037],{"class":466},"  \u003C",[460,1039,1040],{"class":484}," key.pem",[460,1042,1021],{"class":474},[460,1044,1045],{"class":462,"line":817},[460,1046,758],{"emptyLinePlaceholder":757},[460,1048,1049],{"class":462,"line":823},[460,1050,1051],{"class":763},"# 3) Store in Infisical at \u002Finfra\u002Fopenreplay\u002F:\n",[460,1053,1054,1056,1059,1062],{"class":462,"line":843},[460,1055,770],{"class":470},[460,1057,1058],{"class":484}," secrets",[460,1060,1061],{"class":484}," set",[460,1063,840],{"class":740},[460,1065,1066,1069,1072,1074,1076,1078,1080],{"class":462,"line":859},[460,1067,1068],{"class":740},"  --projectId",[460,1070,1071],{"class":484}," 0cfec798-5081-4028-b142-a46080728d1f",[460,1073,776],{"class":740},[460,1075,779],{"class":484},[460,1077,782],{"class":740},[460,1079,785],{"class":484},[460,1081,840],{"class":740},[460,1083,1084,1087,1090,1093],{"class":462,"line":868},[460,1085,1086],{"class":484},"  \"OPENREPLAY_ORIGIN_CERT_B64=",[460,1088,1089],{"class":474},"$CERT_B64",[460,1091,1092],{"class":484},"\"",[460,1094,840],{"class":740},[460,1096,1097,1100,1103],{"class":462,"line":873},[460,1098,1099],{"class":484},"  \"OPENREPLAY_ORIGIN_KEY_B64=",[460,1101,1102],{"class":474},"$KEY_B64",[460,1104,803],{"class":484},[293,1106,1107],{},"Renewal: not really a concern — Origin CA certs are valid for 15 years.",[314,1109,1111],{"id":1110},"outputs","Outputs",[325,1113,1114,1124,1136],{},[328,1115,1116,1119,1120,1123],{},[305,1117,1118],{},"openreplayUrl"," — ",[305,1121,1122],{},"https:\u002F\u002F\u003Cdomain>"," (dashboard, behind CF Access)",[328,1125,1126,1119,1129,1132,1133,513],{},[305,1127,1128],{},"ingestUrl",[305,1130,1131],{},"https:\u002F\u002F\u003CingestDomain>"," (tracker ",[305,1134,1135],{},"ingestPoint",[328,1137,1138,1141],{},[305,1139,1140],{},"vmIp"," — Hetzner public IPv4 (informational; firewalled to CF only)",[314,1143,1145],{"id":1144},"resource-sizing","Resource sizing",[293,1147,1148,1149,1151,1152,1154,1155,1157],{},"OpenReplay's docs list a 2 vCPU \u002F 8 GB \u002F 50 GB minimum for low-to-moderate\ntraffic. We default to ",[305,1150,586],{}," (4 vCPU dedicated \u002F 16 GB \u002F 160 GB) plus a\n100 GB data volume — total 260 GB, comfortably above the project's 240 GB\nhard minimum. The volume is bind-mounted under ",[305,1153,622],{}," and\n",[305,1156,626],{}," before the installer runs, so K3s persistent volumes\n(session replays, MinIO, ClickHouse, Postgres) live on the volume rather\nthan the bundled root disk.",[293,1159,1160,1161,1164,1165,1168],{},"If you want a single-disk setup, switching ",[305,1162,1163],{},"serverType"," to ",[305,1166,1167],{},"cpx41","\n(8 vCPU shared \u002F 16 GB \u002F 240 GB) and dropping the volume also works.",[314,1170,1172],{"id":1171},"ci","CI",[293,1174,1175,1176,1179,1180,1183,1184,1187],{},"Intentionally ",[321,1177,1178],{},"not"," wired into ",[305,1181,1182],{},".github\u002Fworkflows\u002Finfra.yml"," yet — the\nauto-up step in that workflow is currently disabled across all stacks, so\nadding a job here would be dead weight. Run ",[305,1185,1186],{},"pnpm run pulumi:up"," locally for now.",[1189,1190,1191],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":456,"searchDepth":478,"depth":478,"links":1193},[1194,1195,1197,1198,1199,1200,1201,1202,1203,1204],{"id":316,"depth":478,"text":317},{"id":508,"depth":478,"text":1196},"Stack config (Pulumi.\u003Cstack>.yaml)",{"id":630,"depth":478,"text":631},{"id":694,"depth":478,"text":695},{"id":727,"depth":478,"text":728},{"id":914,"depth":478,"text":915},{"id":935,"depth":478,"text":936},{"id":1110,"depth":478,"text":1111},{"id":1144,"depth":478,"text":1145},{"id":1171,"depth":478,"text":1172},"Self-hosted OpenReplay\nrunning on a single Hetzner Cloud VM. The VM userData runs the documented\nopenreplay -i $DOMAIN installer (which sets up K3s with embedded containerd,\nplus helm\u002Ftemplater\u002Fkubectl as standalone binaries — no Docker on the host).","md",null,{},{"title":188,"description":1205},"infra\u002Fopenreplay\u002FREADME","B6X1NXy-9-gzLPQZJKg9z129-nz64cf9OITXYcVMUds",1779007964101]