create-apollo-monorepo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +519 -6
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -26,6 +26,7 @@ const BACKEND_REPO_URL = "https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git";
26
26
  const BACKEND_BRANCH = "main";
27
27
  const BACKEND_PATH = "apps/backend";
28
28
  const FRONTEND_PATH = "apps/frontend";
29
+ const PROXY_PATH = "apps/proxy";
29
30
  const MIN_NODE_MAJOR = 20;
30
31
 
31
32
  const COLORS = {
@@ -355,13 +356,42 @@ function writeRootPackageJson(targetDir, dirName) {
355
356
  // dist/server.mjs on next request.
356
357
  dev:
357
358
  "pnpm predev:setup && concurrently -k -n FE,BE,PL -c blue,magenta,yellow \"pnpm --filter ./apps/frontend dev\" \"pnpm --filter ./apps/backend exec next dev\" \"pnpm cms-plugins:dev\"",
359
+ // Optional single-origin dev: same as `pnpm dev` but adds a Node.js
360
+ // reverse proxy on :3030 that fronts FE :3001 + BE :3000. Use it when
361
+ // you want one URL, shared cookies, and no CORS — without nginx.
362
+ "dev:rp":
363
+ "pnpm predev:setup && concurrently -k -n FE,BE,PL,PX -c blue,magenta,yellow,cyan \"pnpm --filter ./apps/frontend dev\" \"pnpm --filter ./apps/backend exec next dev\" \"pnpm cms-plugins:dev\" \"pnpm dev:proxy\"",
364
+ "dev:proxy": "node apps/proxy/server.mjs",
358
365
  "dev:frontend": "pnpm --filter ./apps/frontend dev",
359
366
  "dev:backend":
360
367
  "pnpm predev:setup && pnpm --filter ./apps/backend exec next dev",
361
368
  "dev:cron": "pnpm --filter ./apps/backend exec bun scripts/dev-cron.ts",
362
369
  // Build pipeline: cms-plugins → apollo-cms's own plugins → backend → frontend.
370
+ // `prebuild` runs first (npm/pnpm convention) and fails fast if the
371
+ // backend's required env vars are missing.
372
+ prebuild: "node scripts/check-env.mjs",
363
373
  build:
364
374
  "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build && pnpm --filter ./apps/backend build && pnpm --filter ./apps/frontend build",
375
+ "build:backend":
376
+ "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build && pnpm --filter ./apps/backend build",
377
+ "build:frontend": "pnpm --filter ./apps/frontend build",
378
+ // Production start. Runs FE :3001 + BE :3000 in parallel via
379
+ // \`next start\`. Run \`pnpm backend:upgrade\` BEFORE this — start does
380
+ // NOT run migrations on boot (zero-downtime restart safety).
381
+ start:
382
+ "concurrently -k -n FE,BE -c blue,magenta \"pnpm --filter ./apps/frontend start\" \"pnpm --filter ./apps/backend exec next start\"",
383
+ // Single-origin production: FE + BE + Node reverse proxy on :3030.
384
+ "start:rp":
385
+ "concurrently -k -n FE,BE,PX -c blue,magenta,cyan \"pnpm --filter ./apps/frontend start\" \"pnpm --filter ./apps/backend exec next start\" \"pnpm start:proxy\"",
386
+ "start:proxy": "NODE_ENV=production node apps/proxy/server.mjs",
387
+ "start:frontend": "pnpm --filter ./apps/frontend start",
388
+ "start:backend": "pnpm --filter ./apps/backend exec next start",
389
+ // Self-hosted scheduler fallback. Vercel deploys use Vercel Cron via
390
+ // apps/backend/vercel.json — skip this on Vercel. For Docker / VPS
391
+ // you can either run \`pnpm start:cron\` alongside \`pnpm start\` (PM2
392
+ // handles this via ecosystem.config.cjs), or use system cron / k8s
393
+ // CronJob to hit /api/cron with CRON_SECRET.
394
+ "start:cron": "pnpm --filter ./apps/backend exec bun scripts/dev-cron.ts",
365
395
  "cms-plugins:build":
366
396
  "pnpm --filter './apps/cms-plugins/*' --parallel --if-present build",
367
397
  "cms-plugins:dev":
@@ -487,6 +517,312 @@ server {
487
517
  writeFileSync(resolve(targetDir, "nginx.conf.sample"), conf);
488
518
  }
489
519
 
520
+ // Optional Node.js reverse proxy. Mirrors nginx.conf.sample routing rules
521
+ // but runs in-process — opt in via `pnpm dev:rp`. Zero deps, ~80 LOC.
522
+ function writeProxyApp(targetDir, dirName, adminPrefix) {
523
+ const dir = resolve(targetDir, PROXY_PATH);
524
+ mkdirSync(dir, { recursive: true });
525
+
526
+ const prefix = adminPrefix || "/admin";
527
+ const server = `// ${dirName} — single-origin reverse proxy (zero deps).
528
+ // Mirrors ../../nginx.conf.sample. Run via \`pnpm dev:rp\` from the repo root.
529
+ //
530
+ // Routes (in order):
531
+ // ${prefix}, ${prefix}/*, /uploads/*, /monitoring → backend
532
+ // /api/editing-presence/* → backend (SSE)
533
+ // /api/{auth,v1,cron,email,health,mcp,admin,...}/* → backend
534
+ // /__proxy/health → 200 OK (proxy itself)
535
+ // everything else → frontend
536
+ //
537
+ // Production note: this proxy doesn't terminate TLS. For HTTPS, sit behind a
538
+ // load balancer (Caddy, Cloudflare, ALB) or use \`nginx.conf.sample\` instead.
539
+
540
+ import http from "node:http";
541
+ import net from "node:net";
542
+
543
+ const PORT = Number(process.env.PORT ?? 3030);
544
+ const BACKEND = parseTarget(process.env.BACKEND ?? "http://127.0.0.1:3000");
545
+ const FRONTEND = parseTarget(process.env.FRONTEND ?? "http://127.0.0.1:3001");
546
+
547
+ const ADMIN_PREFIX = process.env.ADMIN_PREFIX ?? "${prefix}";
548
+ // Pipe-separated list of /api/<segment> paths that route to the backend.
549
+ const BACKEND_API_SEGMENTS = (
550
+ process.env.BACKEND_API_PATHS ??
551
+ "auth|v1|cron|email|health|mcp|admin|editing-presence"
552
+ ).trim();
553
+ const BACKEND_API = new RegExp(\`^/api/(\${BACKEND_API_SEGMENTS})(/|$)\`);
554
+
555
+ // 50MB matches nginx.conf.sample's client_max_body_size and Next.js's
556
+ // Server Actions body limit. Override via MAX_BODY_BYTES (0 = unlimited).
557
+ const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 50 * 1024 * 1024);
558
+ const UPSTREAM_TIMEOUT_MS = Number(process.env.UPSTREAM_TIMEOUT_MS ?? 60_000);
559
+ // When false (default), strip incoming X-Forwarded-* before adding our own —
560
+ // prevents header spoofing when the proxy faces the public internet directly.
561
+ // Set TRUST_PROXY=1 only when behind another trusted reverse proxy (CDN, LB).
562
+ const TRUST_PROXY = process.env.TRUST_PROXY === "1";
563
+
564
+ // Hop-by-hop headers (RFC 7230 §6.1) — must not be forwarded.
565
+ const HOP_BY_HOP = new Set([
566
+ "connection",
567
+ "keep-alive",
568
+ "proxy-authenticate",
569
+ "proxy-authorization",
570
+ "te",
571
+ "trailer",
572
+ "transfer-encoding",
573
+ "upgrade",
574
+ ]);
575
+
576
+ // Reuse TCP connections to upstreams — eliminates ~1ms per request.
577
+ const backendAgent = new http.Agent({ keepAlive: true });
578
+ const frontendAgent = new http.Agent({ keepAlive: true });
579
+
580
+ function pickUpstream(url) {
581
+ if (url === ADMIN_PREFIX || url.startsWith(\`\${ADMIN_PREFIX}/\`))
582
+ return { ...BACKEND, agent: backendAgent };
583
+ if (url.startsWith("/uploads/")) return { ...BACKEND, agent: backendAgent };
584
+ if (url === "/monitoring") return { ...BACKEND, agent: backendAgent };
585
+ if (BACKEND_API.test(url)) return { ...BACKEND, agent: backendAgent };
586
+ return { ...FRONTEND, agent: frontendAgent };
587
+ }
588
+
589
+ function sanitizeHeaders(req, clientIp) {
590
+ const out = {};
591
+ for (const [k, v] of Object.entries(req.headers)) {
592
+ const lower = k.toLowerCase();
593
+ if (HOP_BY_HOP.has(lower)) continue;
594
+ // Strip client-supplied X-Forwarded-* unless TRUST_PROXY is set.
595
+ if (!TRUST_PROXY && lower.startsWith("x-forwarded-")) continue;
596
+ out[k] = v;
597
+ }
598
+ // Always set X-Forwarded-* with our own values (append client IP).
599
+ const existingFor = TRUST_PROXY ? req.headers["x-forwarded-for"] : undefined;
600
+ out["x-forwarded-for"] = existingFor ? \`\${existingFor}, \${clientIp}\` : clientIp;
601
+ out["x-forwarded-proto"] = TRUST_PROXY
602
+ ? req.headers["x-forwarded-proto"] ?? "http"
603
+ : "http";
604
+ out["x-forwarded-host"] = TRUST_PROXY
605
+ ? req.headers["x-forwarded-host"] ?? req.headers.host ?? ""
606
+ : req.headers.host ?? "";
607
+ return out;
608
+ }
609
+
610
+ function logError(scope, err, extra) {
611
+ const ts = new Date().toISOString();
612
+ const detail = extra ? \` \${JSON.stringify(extra)}\` : "";
613
+ console.error(\`[\${ts}] proxy:\${scope} \${err.code ?? ""} \${err.message}\${detail}\`);
614
+ }
615
+
616
+ const server = http.createServer((req, res) => {
617
+ // Health check (proxy itself, never forwarded).
618
+ if (req.url === "/__proxy/health") {
619
+ res.writeHead(200, { "content-type": "application/json" });
620
+ res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
621
+ return;
622
+ }
623
+
624
+ // Enforce body size limit (DoS protection).
625
+ if (MAX_BODY_BYTES > 0) {
626
+ const declared = Number(req.headers["content-length"] ?? 0);
627
+ if (declared > MAX_BODY_BYTES) {
628
+ res.writeHead(413, { "content-type": "text/plain" });
629
+ res.end("Payload Too Large");
630
+ return;
631
+ }
632
+ let received = 0;
633
+ req.on("data", (chunk) => {
634
+ received += chunk.length;
635
+ if (received > MAX_BODY_BYTES) {
636
+ req.destroy(new Error("Body exceeded MAX_BODY_BYTES"));
637
+ }
638
+ });
639
+ }
640
+
641
+ const upstream = pickUpstream(req.url);
642
+ const proxyReq = http.request(
643
+ {
644
+ host: upstream.host,
645
+ port: upstream.port,
646
+ method: req.method,
647
+ path: req.url,
648
+ headers: sanitizeHeaders(req, req.socket.remoteAddress ?? ""),
649
+ agent: upstream.agent,
650
+ timeout: UPSTREAM_TIMEOUT_MS,
651
+ },
652
+ (proxyRes) => {
653
+ // Strip hop-by-hop headers from upstream response too.
654
+ const safe = {};
655
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
656
+ if (!HOP_BY_HOP.has(k.toLowerCase())) safe[k] = v;
657
+ }
658
+ res.writeHead(proxyRes.statusCode ?? 502, safe);
659
+ proxyRes.pipe(res);
660
+ },
661
+ );
662
+ proxyReq.on("timeout", () => {
663
+ logError("upstream-timeout", new Error("upstream timeout"), { url: req.url });
664
+ proxyReq.destroy(new Error("Upstream timeout"));
665
+ });
666
+ proxyReq.on("error", (err) => {
667
+ logError("upstream", err, { url: req.url });
668
+ if (!res.headersSent) {
669
+ res.writeHead(502, { "content-type": "text/plain" });
670
+ res.end("Bad Gateway");
671
+ }
672
+ if (!req.destroyed) req.destroy();
673
+ });
674
+ req.on("error", (err) => {
675
+ logError("client", err, { url: req.url });
676
+ if (!proxyReq.destroyed) proxyReq.destroy();
677
+ });
678
+ req.pipe(proxyReq);
679
+ });
680
+
681
+ // WebSocket / HTTP upgrade passthrough — required for Next.js HMR.
682
+ server.on("upgrade", (req, clientSocket, head) => {
683
+ const upstream = pickUpstream(req.url);
684
+ const upstreamSocket = net.connect(upstream.port, upstream.host, () => {
685
+ const headers = sanitizeHeaders(req, req.socket.remoteAddress ?? "");
686
+ // Re-add Connection: Upgrade & Upgrade headers stripped by sanitizeHeaders.
687
+ headers["connection"] = "Upgrade";
688
+ if (req.headers.upgrade) headers["upgrade"] = req.headers.upgrade;
689
+ const headerLines = [];
690
+ for (const [k, v] of Object.entries(headers)) {
691
+ const values = Array.isArray(v) ? v : [v];
692
+ for (const single of values) {
693
+ const str = String(single);
694
+ // Defense-in-depth: reject CRLF in header values (header injection).
695
+ if (/[\\r\\n]/.test(str)) continue;
696
+ headerLines.push(\`\${k}: \${str}\`);
697
+ }
698
+ }
699
+ upstreamSocket.write(
700
+ \`\${req.method} \${req.url} HTTP/\${req.httpVersion}\\r\\n\` +
701
+ headerLines.join("\\r\\n") +
702
+ "\\r\\n\\r\\n",
703
+ );
704
+ if (head?.length) upstreamSocket.write(head);
705
+ upstreamSocket.pipe(clientSocket);
706
+ clientSocket.pipe(upstreamSocket);
707
+ });
708
+ upstreamSocket.on("error", (err) => {
709
+ logError("ws-upstream", err, { url: req.url });
710
+ clientSocket.destroy();
711
+ });
712
+ clientSocket.on("error", (err) => {
713
+ logError("ws-client", err, { url: req.url });
714
+ upstreamSocket.destroy();
715
+ });
716
+ });
717
+
718
+ server.listen(PORT, () => {
719
+ console.log(\`apollo-proxy → http://localhost:\${PORT}\`);
720
+ console.log(\` \${ADMIN_PREFIX}/*, /uploads/*, /api/* → \${BACKEND.host}:\${BACKEND.port}\`);
721
+ console.log(\` /* → \${FRONTEND.host}:\${FRONTEND.port}\`);
722
+ console.log(\` TRUST_PROXY=\${TRUST_PROXY} MAX_BODY_BYTES=\${MAX_BODY_BYTES} UPSTREAM_TIMEOUT_MS=\${UPSTREAM_TIMEOUT_MS}\`);
723
+ });
724
+
725
+ // Graceful shutdown — let in-flight requests finish, then exit.
726
+ let shuttingDown = false;
727
+ function shutdown(signal) {
728
+ if (shuttingDown) return;
729
+ shuttingDown = true;
730
+ console.log(\`\\napollo-proxy: \${signal} received, draining...\`);
731
+ server.close(() => {
732
+ backendAgent.destroy();
733
+ frontendAgent.destroy();
734
+ process.exit(0);
735
+ });
736
+ // Hard exit after 10s if connections refuse to drain.
737
+ setTimeout(() => process.exit(1), 10_000).unref();
738
+ }
739
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
740
+ process.on("SIGINT", () => shutdown("SIGINT"));
741
+
742
+ function parseTarget(input) {
743
+ const url = new URL(input.startsWith(":") ? \`http://127.0.0.1\${input}\` : input);
744
+ return { host: url.hostname, port: Number(url.port || 80) };
745
+ }
746
+ `;
747
+ writeFileSync(resolve(dir, "server.mjs"), server);
748
+
749
+ const pkg = {
750
+ name: `@${dirName}/proxy`,
751
+ private: true,
752
+ version: "0.0.0",
753
+ description: "Optional Node.js reverse proxy fronting frontend + backend on a single origin",
754
+ type: "module",
755
+ main: "server.mjs",
756
+ scripts: {
757
+ start: "node server.mjs",
758
+ },
759
+ engines: { node: ">=20" },
760
+ };
761
+ writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
762
+
763
+ const readme = `# @${dirName}/proxy
764
+
765
+ Optional single-origin reverse proxy. Routes \`${prefix}/*\`, \`/api/*\`, and
766
+ \`/uploads/*\` to the backend; everything else to the frontend. Zero deps.
767
+
768
+ ## When to use
769
+
770
+ - You want **one URL** for FE + BE locally (shared cookies, no CORS).
771
+ - You don't want to install nginx for local dev.
772
+
773
+ ## Usage
774
+
775
+ From the repo root:
776
+
777
+ \`\`\`bash
778
+ pnpm dev:rp # runs FE :3001 + BE :3000 + plugins watcher + proxy :3030
779
+ \`\`\`
780
+
781
+ Then open http://localhost:3030.
782
+
783
+ For best results, set \`NEXT_PUBLIC_SITE_URL=http://localhost:3030\` in your
784
+ \`.env.local\` so Better Auth, OAuth callbacks, and email links all use the
785
+ single-origin URL.
786
+
787
+ ## Configuration
788
+
789
+ | Env var | Default | Notes |
790
+ | --------------------- | -------------------------------- | -------------------------------------------------------------- |
791
+ | \`PORT\` | \`3030\` | Public port |
792
+ | \`BACKEND\` | \`http://127.0.0.1:3000\` | apps/backend dev server |
793
+ | \`FRONTEND\` | \`http://127.0.0.1:3001\` | apps/frontend dev server |
794
+ | \`ADMIN_PREFIX\` | \`${prefix}\` | Backend admin path prefix |
795
+ | \`BACKEND_API_PATHS\` | \`auth\\|v1\\|cron\\|email\\|health\\|mcp\\|admin\\|editing-presence\` | Pipe-separated /api/* segments routed to backend |
796
+ | \`MAX_BODY_BYTES\` | \`52428800\` (50MB) | Reject larger bodies. \`0\` = unlimited |
797
+ | \`UPSTREAM_TIMEOUT_MS\` | \`60000\` | Request timeout to upstream |
798
+ | \`TRUST_PROXY\` | \`0\` | Set \`1\` only if behind another trusted proxy (CDN/LB) |
799
+
800
+ ## Health check
801
+
802
+ \`GET /__proxy/health\` returns \`{"status":"ok","uptime":<seconds>}\`. Use it for
803
+ liveness probes; the path is reserved by the proxy and never forwarded.
804
+
805
+ ## Better Auth interaction
806
+
807
+ Apollo CMS's Better Auth derives the request origin from \`x-forwarded-host\` /
808
+ \`x-forwarded-proto\`. This proxy sets both correctly, so \`trustedOrigins\` keeps
809
+ working through the proxy. The recent fix in \`src/lib/auth/server.ts\` ensures
810
+ the actual request origin (e.g. \`localhost:3030\`) is added to trusted origins
811
+ even when \`NEXT_PUBLIC_SITE_URL\` points elsewhere.
812
+
813
+ ## Production
814
+
815
+ This proxy doesn't terminate TLS. For HTTPS, either:
816
+
817
+ - Sit it behind a TLS-terminating load balancer (Caddy, Cloudflare, ALB), **or**
818
+ - Use \`nginx.conf.sample\` at the repo root instead — nginx handles TLS, gzip,
819
+ and large-scale traffic better.
820
+
821
+ The Node proxy is intended for dev and small self-hosted deploys.
822
+ `;
823
+ writeFileSync(resolve(dir, "README.md"), readme);
824
+ }
825
+
490
826
  function writeRootGitignore(targetDir) {
491
827
  const ignore = [
492
828
  "node_modules",
@@ -931,6 +1267,122 @@ console.log(\`Run: pnpm install && pnpm cms-plugins:build && pnpm dev:backend\`)
931
1267
  writeFileSync(resolve(scriptsDir, "new-cms-plugin.mjs"), script);
932
1268
  }
933
1269
 
1270
+ // Pre-build env check. Fails the build before \`next build\` runs if backend
1271
+ // required vars are missing, so users don't sit through a 30s build only to
1272
+ // hit a runtime error on first request.
1273
+ function writeCheckEnvScript(targetDir) {
1274
+ const scriptsDir = resolve(targetDir, "scripts");
1275
+ mkdirSync(scriptsDir, { recursive: true });
1276
+ const script = `#!/usr/bin/env node
1277
+ // Pre-build sanity check. Reads apps/backend/.env.local and verifies that
1278
+ // required vars are present and non-empty. Skipped when SKIP_ENV_CHECK=1.
1279
+ //
1280
+ // In CI/Vercel where envs come from the platform (not files), set
1281
+ // SKIP_ENV_CHECK=1 and rely on the platform's own validation.
1282
+
1283
+ import { existsSync, readFileSync } from "node:fs";
1284
+ import { dirname, resolve } from "node:path";
1285
+ import { fileURLToPath } from "node:url";
1286
+
1287
+ if (process.env.SKIP_ENV_CHECK === "1") process.exit(0);
1288
+
1289
+ const REQUIRED = ["DATABASE_URL", "APOLLO_SECRET", "NEXT_PUBLIC_DEFAULT_LOCALE"];
1290
+
1291
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1292
+ const envPath = resolve(__dirname, "../apps/backend/.env.local");
1293
+
1294
+ if (!existsSync(envPath)) {
1295
+ // Allow build to proceed if env vars are coming from process.env directly.
1296
+ const fromProcess = REQUIRED.every((k) => process.env[k]);
1297
+ if (fromProcess) process.exit(0);
1298
+ console.error(
1299
+ \`✗ Missing apps/backend/.env.local and required vars not in process.env.\\n Required: \${REQUIRED.join(", ")}\\n Hint: re-run the installer or copy apps/backend/.env.local.example\`,
1300
+ );
1301
+ process.exit(1);
1302
+ }
1303
+
1304
+ const env = Object.fromEntries(
1305
+ readFileSync(envPath, "utf-8")
1306
+ .split("\\n")
1307
+ .filter((line) => line && !line.startsWith("#"))
1308
+ .map((line) => {
1309
+ const i = line.indexOf("=");
1310
+ return i === -1 ? [line, ""] : [line.slice(0, i).trim(), line.slice(i + 1).trim()];
1311
+ }),
1312
+ );
1313
+
1314
+ const missing = REQUIRED.filter((k) => !env[k] && !process.env[k]);
1315
+ if (missing.length) {
1316
+ console.error(\`✗ Missing required env vars: \${missing.join(", ")}\\n Edit apps/backend/.env.local or set them in process.env.\`);
1317
+ process.exit(1);
1318
+ }
1319
+ console.log("✓ env check passed");
1320
+ `;
1321
+ writeFileSync(resolve(scriptsDir, "check-env.mjs"), script);
1322
+ }
1323
+
1324
+ // PM2 ecosystem config — production process supervision for self-hosted deploys.
1325
+ // Includes FE, BE, optional proxy, and optional cron in fork mode (no clustering
1326
+ // since Next.js handles that internally and the proxy is single-threaded by design).
1327
+ function writePm2Config(targetDir, dirName, adminPrefix) {
1328
+ const singleOrigin = !!adminPrefix;
1329
+ const config = `// PM2 ecosystem config for ${dirName}.
1330
+ // Usage:
1331
+ // pnpm install
1332
+ // pnpm backend:upgrade
1333
+ // pnpm build
1334
+ // pm2 start ecosystem.config.cjs # FE + BE
1335
+ // pm2 start ecosystem.config.cjs --only proxy # add reverse proxy
1336
+ // pm2 start ecosystem.config.cjs --only cron # self-hosted cron fallback
1337
+ // pm2 save && pm2 startup # persist across reboots
1338
+
1339
+ module.exports = {
1340
+ apps: [
1341
+ {
1342
+ name: "frontend",
1343
+ cwd: "./apps/frontend",
1344
+ script: "node_modules/next/dist/bin/next",
1345
+ args: "start",
1346
+ env: { NODE_ENV: "production", PORT: 3001 },
1347
+ max_memory_restart: "1G",
1348
+ autorestart: true,
1349
+ },
1350
+ {
1351
+ name: "backend",
1352
+ cwd: "./apps/backend",
1353
+ script: "node_modules/next/dist/bin/next",
1354
+ args: "start",
1355
+ env: { NODE_ENV: "production", PORT: 3000 },
1356
+ max_memory_restart: "1G",
1357
+ autorestart: true,
1358
+ },
1359
+ {
1360
+ name: "proxy",
1361
+ script: "./apps/proxy/server.mjs",
1362
+ env: {
1363
+ NODE_ENV: "production",
1364
+ PORT: 3030,
1365
+ BACKEND: "http://127.0.0.1:3000",
1366
+ FRONTEND: "http://127.0.0.1:3001",
1367
+ ${singleOrigin ? `ADMIN_PREFIX: "${adminPrefix}",` : ""}
1368
+ },
1369
+ max_memory_restart: "256M",
1370
+ autorestart: true,
1371
+ },
1372
+ {
1373
+ name: "cron",
1374
+ cwd: "./apps/backend",
1375
+ script: "scripts/dev-cron.ts",
1376
+ interpreter: "bun",
1377
+ env: { NODE_ENV: "production" },
1378
+ autorestart: true,
1379
+ },
1380
+ ],
1381
+ };
1382
+ `;
1383
+ writeFileSync(resolve(targetDir, "ecosystem.config.cjs"), config);
1384
+ }
1385
+
934
1386
  function writeReadme(targetDir, dirName, frontendName, adminPrefix) {
935
1387
  const singleOrigin = !!adminPrefix;
936
1388
 
@@ -951,12 +1403,17 @@ paths to the backend so /_next/* doesn't collide:
951
1403
  The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
952
1404
  If you need your own API, namespace it under \`/api/internal/*\` or similar.
953
1405
 
954
- ### Reverse proxy (nginx)
1406
+ ### Reverse proxy
1407
+
1408
+ Two options ship out of the box:
955
1409
 
956
- A reference \`nginx.conf.sample\` ships at the repo root with the same routing
957
- baked in (frontend on :3001, backend on :3000). Use it when fronting both apps
958
- behind a single TLS-terminating proxy outside Vercel drop in your domain and
959
- SSL certs.
1410
+ - **\`apps/proxy\`** (Node.js, zero deps) opt in with \`pnpm dev:rp\`. Runs FE,
1411
+ BE, plugins watcher, and a reverse proxy on **:3030** so everything sits on
1412
+ one origin (shared cookies, no CORS). Best for local dev and small self-hosted
1413
+ deploys. Configure via \`PORT\`, \`BACKEND\`, \`FRONTEND\`, \`ADMIN_PREFIX\`.
1414
+ - **\`nginx.conf.sample\`** (production) — same routing baked in (frontend on
1415
+ :3001, backend on :3000). Use it when fronting both apps behind a single
1416
+ TLS-terminating proxy outside Vercel — drop in your domain and SSL certs.
960
1417
 
961
1418
  To **disable** single-origin and run the backend on its own subdomain,
962
1419
  delete \`APOLLO_ASSET_PREFIX\` from \`apps/backend/.env.local\` and remove the
@@ -1007,8 +1464,14 @@ ${dirName}/
1007
1464
  pnpm install
1008
1465
  pnpm backend:setup # push schema + seed apollo-cms
1009
1466
  pnpm dev # runs frontend (3001) + backend (3000) in parallel
1467
+ # or:
1468
+ pnpm dev:rp # same + reverse proxy on :3030 (single-origin, shared cookies)
1010
1469
  \`\`\`
1011
1470
 
1471
+ When using \`pnpm dev:rp\`, set \`NEXT_PUBLIC_SITE_URL=http://localhost:3030\`
1472
+ in your \`.env.local\` so Better Auth, OAuth callbacks, and email links use
1473
+ the unified origin.
1474
+
1012
1475
  ${originSection}
1013
1476
  ## Custom plugins
1014
1477
 
@@ -1067,6 +1530,53 @@ Do **not** edit files inside \`apps/backend\`. Open issues / PRs in the
1067
1530
  Shared dev env lives in the root \`.env.local\`. The backend reads its own
1068
1531
  \`apps/backend/.env.local\` (already populated by the installer).
1069
1532
 
1533
+ ## Deploy (self-hosted)
1534
+
1535
+ Standard sequence on a VPS / Docker host / bare metal:
1536
+
1537
+ \`\`\`bash
1538
+ pnpm install
1539
+ pnpm backend:upgrade # db push + replay data migrations + seed (run ONCE per release)
1540
+ pnpm build # builds plugins → backend → frontend
1541
+ pnpm start # FE :3001 + BE :3000 — or:
1542
+ pnpm start:rp # adds Node reverse proxy on :3030 (single origin)
1543
+ \`\`\`
1544
+
1545
+ \`pnpm start\` does **not** run migrations on boot — that lets you restart
1546
+ processes without re-running \`drizzle-kit push\` every time. Always run
1547
+ \`pnpm backend:upgrade\` once per release before \`pnpm start\`.
1548
+
1549
+ | Script | What it does |
1550
+ | ------------------- | ------------------------------------------------------------------- |
1551
+ | \`pnpm build\` | Full pipeline: cms-plugins → backend plugins → backend → frontend |
1552
+ | \`pnpm start\` | FE + BE (parallel \`next start\`) |
1553
+ | \`pnpm start:rp\` | FE + BE + reverse proxy on :3030 |
1554
+ | \`pnpm start:proxy\` | Reverse proxy alone (already running FE/BE separately) |
1555
+ | \`pnpm start:cron\` | Self-hosted cron fallback — only if not using Vercel Cron / k8s |
1556
+
1557
+ ### PM2 (recommended for VPS)
1558
+
1559
+ The installer scaffolds \`ecosystem.config.cjs\`. To supervise everything:
1560
+
1561
+ \`\`\`bash
1562
+ pm2 start ecosystem.config.cjs # FE + BE + proxy + cron (all four)
1563
+ pm2 start ecosystem.config.cjs --only frontend,backend # just the two apps
1564
+ pm2 save && pm2 startup # persist across reboots
1565
+ pm2 logs # tail all processes
1566
+ pm2 reload all # zero-downtime restart
1567
+ \`\`\`
1568
+
1569
+ The proxy and cron processes can be omitted with \`--only\` if you front the
1570
+ apps with nginx/Caddy or use platform cron (system cron, k8s CronJob).
1571
+
1572
+ ### Docker / k8s
1573
+
1574
+ For containerized deploys:
1575
+ - Bake the build into the image (\`pnpm install && pnpm build\`)
1576
+ - Set entrypoint to \`pnpm start\` or \`pnpm start:rp\`
1577
+ - Run \`pnpm backend:upgrade\` as a separate init container / Job before app pods start
1578
+ - Use platform-native cron (k8s CronJob hitting \`/api/cron\` with \`CRON_SECRET\`) instead of \`pnpm start:cron\`
1579
+
1070
1580
  ## Deploy on Vercel
1071
1581
 
1072
1582
  Two Vercel projects, one repo. Each project picks up its own Root Directory.
@@ -1182,10 +1692,13 @@ async function main() {
1182
1692
  writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
1183
1693
  writeReadme(targetDir, dirName, frontendName, adminPrefix);
1184
1694
  if (adminPrefix) writeNginxSample(targetDir, adminPrefix);
1695
+ writeProxyApp(targetDir, dirName, adminPrefix);
1696
+ writeCheckEnvScript(targetDir);
1697
+ writePm2Config(targetDir, dirName, adminPrefix);
1185
1698
  success(
1186
1699
  `package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md${
1187
1700
  adminPrefix ? ", nginx.conf.sample" : ""
1188
- }`,
1701
+ }, apps/proxy, ecosystem.config.cjs, scripts/check-env.mjs`,
1189
1702
  );
1190
1703
 
1191
1704
  // ── Step 4: git init ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-apollo-monorepo",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend (single-origin via Next.js rewrites + assetPrefix)",
5
5
  "bin": {
6
6
  "create-apollo-monorepo": "index.mjs"