1 ---
2 name: smbcloud-deploy-nextjs
3 description: Use when deploying or debugging Next.js apps on smbCloud, especially the dedicated `nextjs-ssr` flow that builds locally, uploads `.next/standalone` via rsync, restarts PM2 over SSH, and serves traffic behind Nginx.
4 ---
5
6 # smbCloud Deploy Next.js
7
8 Use this skill when work touches Next.js deployment on smbCloud.
9
10 Applies to:
11
12 - `kind = "nextjs-ssr"` deploy targets
13 - `.smb/config.toml` deploy configuration
14 - `process_deploy_nextjs_ssr.rs`
15 - PM2 process management for standalone builds
16 - Nginx proxying for SSR apps
17 - production-only issues caused by CORS, ports, or runtime mismatch
18
19 ## Deployment model
20
21 Keep the deploy hierarchy straight:
22
23 - `Project` = umbrella workspace in smbCloud (not deployable)
24 - `DeployRepo` = the git repository inside that workspace
25 - `FrontendApp` = the deployable app unit, identified by `frontend_app_id` in config
26 - `Deployment` = one release event, linked to a FrontendApp via `frontend_app_id`
27
28 The canonical chain is: Deployment → FrontendApp → DeployRepo → Project
29
30 For `nextjs-ssr`, smbCloud does not use git-push deploy.
31
32 The CLI route in `process_deploy.rs` short-circuits on:
33
34 - `kind = "nextjs-ssr"`
35
36 That path builds locally, then:
37
38 - rsyncs `.next/standalone/` into the remote app directory root
39 - rsyncs `.next/static/` into the runtime directory's `.next/static/`
40 - rsyncs `public/` into the runtime directory's `public/`
41 - runs `ssh` and restarts the app with PM2
42
43 When `outputFileTracingRoot` points above the app directory, Next may preserve the source path inside `.next/standalone/` (for example `.next/standalone/web/server.js`). The deploy path must keep that nested runtime layout intact.
44
45 Do not describe this flow as git based unless the project is actually using the generic git deploy path.
46
47 ## Required app configuration
48
49 The Next.js app must produce a standalone server bundle.
50
51 Create `next.config.mjs` or equivalent with:
52
53 ```js
54 /** @type {import('next').NextConfig} */
55 const nextConfig = {
56 output: "standalone",
57 };
58
59 export default nextConfig;
60 ```
61
62 Without `output: "standalone"`, the deploy code fails because `.next/standalone/` is missing.
63
64 The app package scripts should support:
65
66 - `build`: `next build`
67
68 `pnpm` is the safe package manager for the current CLI implementation.
69
70 ## Required smbCloud config
71
72 Minimum `.smb/config.toml` for SSR:
73
74 ```toml
75 [project]
76 id = 28 # workspace id (for API routing)
77 frontend_app_id = "uuid" # FrontendApp id (for deployment tracking)
78 deploy_repo_id = 123 # DeployRepo id (links to git repository record)
79 deployment_method = 1
80 kind = "nextjs-ssr"
81 source = "."
82 package_manager = "pnpm"
83 pm2_app = "my-app"
84 path = "apps/web/my-app"
85 port = 3028
86 ```
87
88 Important rules:
89
90 - `kind = "nextjs-ssr"` is what activates the SSR deploy path
91 - `id` is the umbrella workspace ID, used for auth and API routing
92 - `frontend_app_id` identifies the FrontendApp record for this deploy target — include it so deployments are attributed to the correct app, not just the workspace
93 - `deploy_repo_id` identifies the DeployRepo record backing this app — include it when available
94 - `source` is the local Next.js app directory
95 - `path` is the remote destination directory on the server
96 - `pm2_app` is mandatory because the CLI restarts PM2 by name
97 - `port` should be set explicitly and must match nginx
98 - `deployment_method = 1` may still be present, but the `kind` routing is what matters most
99
100 ## Expected remote layout
101
102 After a successful deploy, the remote directory should look like one of these layouts:
103
104 - flat runtime
105 - `/home/git/apps/web/<app>/server.js`
106 - `/home/git/apps/web/<app>/.next/static/...`
107 - `/home/git/apps/web/<app>/public/...`
108 - nested runtime when standalone preserves the source path
109 - `/home/git/apps/web/<app>/node_modules/...`
110 - `/home/git/apps/web/<app>/web/server.js`
111 - `/home/git/apps/web/<app>/web/.next/static/...`
112 - `/home/git/apps/web/<app>/web/public/...`
113
114 The CLI uploads the contents of `.next/standalone/`, not the folder itself. In nested-runtime mode it also needs to keep PM2 compatibility for old root-level `server.js` and `.next/standalone/server.js` entrypoints.
115
116 ## PM2 behavior
117
118 The CLI's deploy path now deletes and starts fresh, preferring the server's
119 ecosystem config file when it exists. The CLI checks for `ecosystem.config.cjs`
120 first, then falls back to `ecosystem.config.js`. During deploy, the server-side
121 migration auto-renames `.js` to `.cjs` when applicable.
122
123 Conceptually it behaves like:
124
125 ```sh
126 cd "$APP_PATH"
127 mkdir -p logs
128
129 if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
130 pm2 delete "$PM2_APP"
131 fi
132
133 if [ -f ecosystem.config.cjs ]; then
134 pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
135 elif [ -f ecosystem.config.js ]; then
136 pm2 start ecosystem.config.js --only "$PM2_APP" --env production
137 else
138 NODE_ENV=production PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
139 fi
140
141 pm2 save
142 ```
143
144 This has two consequences:
145
146 - old git-based `post-receive` hooks are irrelevant for the SSR path
147 - the configured `port` in `.smb/config.toml` is used for the fallback start path
148 - the live server `ecosystem.config.cjs` (or `.js`) is the runtime source of truth when present
149
150 ### Environment variables — ecosystem file (standard pattern)
151
152 The server runs in a **multi-tenant setup**: multiple Next.js apps share one server, each managed as a separate PM2 process. The standard way to manage per-app environment variables is an `ecosystem.config.cjs` file kept **on the server only** (never committed to the app repo).
153
154 Use the `.cjs` extension. This is required when the project's `package.json` has `"type": "module"`, because Node treats `.js` files as ESM in that case and `module.exports` syntax will fail.
155
156 The flow works like this:
157
158 1. On first setup, SSH in, create the ecosystem file, start with it, and save:
159
160 ```sh
161 ssh git@<server>
162 cd /home/git/apps/web/<app>
163
164 cat > ecosystem.config.cjs << 'EOF'
165 module.exports = {
166 apps: [{
167 name: "<pm2_app>",
168 script: "server.js",
169 cwd: "/home/git/apps/web/<app>",
170 env_production: {
171 NODE_ENV: "production",
172 PORT: 3026,
173 HOSTNAME: "127.0.0.1",
174 MY_SECRET: "...",
175 }
176 }]
177 }
178 EOF
179
180 pm2 start ecosystem.config.cjs --env production
181 pm2 save
182 ```
183
184 2. Every subsequent CLI deploy deletes the PM2 process and starts it fresh. The CLI checks for `ecosystem.config.cjs` first, then `ecosystem.config.js`. If neither exists, it falls back to `node server.js` with inline `NODE_ENV`, `PORT`, and `HOSTNAME`.
185
186 3. To change or add env vars, SSH in, edit `ecosystem.config.cjs`, then:
187
188 ```sh
189 pm2 restart <pm2_app> --update-env
190 pm2 save
191 ```
192
193 The ecosystem file is the server-side source of truth for each app's runtime config. The CLI preserves both `ecosystem.config.cjs` and `ecosystem.config.js` on the server during rsync and must not delete either.
194
195 ### Manual PM2 operations (without ecosystem file)
196
197 If you are operating without an ecosystem file, prefer the same fresh-start model the CLI uses:
198
199 ```sh
200 cd /home/git/apps/web/<app>
201 if pm2 describe <pm2_app> > /dev/null 2>&1; then
202 pm2 delete <pm2_app>
203 fi
204 PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js
205 pm2 save
206 ```
207
208 If you are using an ecosystem file and only changed env vars manually, this is still valid:
209
210 ```sh
211 cd /home/git/apps/web/<app>
212 pm2 restart <pm2_app> --update-env
213 pm2 save
214 ```
215
216 ## Port management
217
218 Use one source of truth for the runtime port.
219
220 If an older ecosystem config (`.cjs` or `.js`) exists and contains both:
221
222 - `args: "start -p 3026"`
223 - `env_production.PORT = 3025`
224
225 that is a broken configuration. Standardize on one port value.
226
227 Current rules:
228
229 - fallback fresh-start path uses `port` from `.smb/config.toml`
230 - ecosystem-managed apps should set the same port in `env_production.PORT` (in `ecosystem.config.cjs`)
231 - nginx upstream must match that port
232
233 ## Nginx for SSR
234
235 Do not use a static-site Nginx config like:
236
237 ```nginx
238 root /home/git/apps/web/my-app;
239 location / {
240 try_files $uri $uri/ =404;
241 }
242 ```
243
244 That is for static files, not Next.js SSR.
245
246 Use reverse proxying to the local Node process instead:
247
248 ```nginx
249 location / {
250 proxy_pass http://127.0.0.1:3026;
251 proxy_http_version 1.1;
252
253 proxy_set_header Host $host;
254 proxy_set_header X-Real-IP $remote_addr;
255 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
256 proxy_set_header X-Forwarded-Proto $scheme;
257
258 proxy_set_header Upgrade $http_upgrade;
259 proxy_set_header Connection "upgrade";
260
261 proxy_cache_bypass $http_upgrade;
262 }
263 ```
264
265 Nginx must proxy to the actual PM2 port.
266
267 ### Do not `alias` `/_next/static` for nested standalone builds
268
269 The proxy-everything block above is the safe default: let the standalone Node
270 server serve `/_next/static` itself. The common breakage is an nginx block that
271 tries to serve those assets from disk:
272
273 ```nginx
274 # WRONG for a nested (monorepo) standalone build
275 location /_next/static {
276 alias /home/git/apps/web/<app>/.next/static; # 404s every JS/CSS
277 }
278 ```
279
280 When the app builds with `outputFileTracingRoot` (monorepo), standalone is
281 **nested**, so static actually lands at `…/<app>/<source>/.next/static`
282 (e.g. `…/<app>/apps/web/.next/static`), not `…/<app>/.next/static`. The alias
283 points at the wrong directory and every chunk 404s while the HTML still loads —
284 a confusing "page renders, no styles/JS" symptom.
285
286 Fixes, in order of preference:
287
288 1. Drop the `/_next/static` `alias` block and proxy everything to the Node
289 process (the block above). Layout-independent — works for flat and nested.
290 2. If you must serve static from disk, point the alias at the nested path:
291 `alias …/<app>/<source>/.next/static;`.
292
293 Flat (single-repo, `source = "."`) builds keep static at `…/<app>/.next/static`,
294 so a disk alias there happens to be correct — but proxy-everything still works
295 and is the recommended uniform pattern.
296
297 ### One file + SAN cert for multiple hostnames
298
299 To serve several hostnames for the same app (e.g. a primary domain plus a
300 `*.5mb.app` alias) from one `server` block, they must share one certificate — a
301 `server` block can only present one cert. Issue a SAN cert listing both names,
302 then write one block:
303
304 ```sh
305 sudo certbot certonly --nginx -d app.example.com -d app.example.net --cert-name myapp
306 ```
307
308 ```nginx
309 server {
310 listen 443 ssl http2;
311 server_name app.example.com app.example.net;
312 ssl_certificate /etc/letsencrypt/live/myapp/fullchain.pem;
313 ssl_certificate_key /etc/letsencrypt/live/myapp/privkey.pem;
314 # ... single proxy_pass block; Host $host passes the real hostname through
315 }
316 ```
317
318 Mixing two different certs (a per-domain cert plus a shared wildcard) forces two
319 `server` blocks. Run `certbot certonly` first, then write the config that
320 references the new lineage — pointing `ssl_certificate` at a path that does not
321 exist yet makes `nginx -t` fail.
322
323 ## Common failure modes
324
325 ### `.next/standalone` missing
326
327 Cause:
328
329 - no `output: "standalone"` in Next config
330
331 Fix:
332
333 - add standalone output
334 - rebuild locally
335
336 ### `server.js` missing on the server
337
338 Check:
339
340 - whether the SSR deploy path actually ran
341 - whether `path` points to the directory you inspected
342 - whether `rsync` uploaded to a different location
343
344 ### Missing `public/`
345
346 `public/` is optional. The current deploy code skips it when the directory does not exist.
347
348 If assets are missing in production, check whether the app actually has a `public/` directory locally and whether the expected files were copied into the remote `public/` directory.
349
350 ### `Cannot find module 'next'` or `502 Bad Gateway`
351
352 Cause:
353
354 - `.next/standalone/node_modules/*` contains `pnpm` symlinks
355 - rsync preserved the symlinks instead of copying their targets
356 - or the standalone output itself is incomplete and missing traced runtime files
357 - PM2 starts `server.js`, Node cannot resolve `next`, and nginx returns `502 Bad Gateway`
358
359 Fix:
360
361 - use the dedicated `nextjs-ssr` CLI path in current `smbcloud-cli`
362 - make sure the standalone upload uses `rsync --copy-links`
363 - verify `node .next/standalone/server.js` works locally before deploy
364 - if local standalone fails too, add `outputFileTracingIncludes` for the missing runtime files and rebuild
365 - then restart PM2 and recheck `pm2 logs <pm2_app>`
366
367 ### `Cannot find module 'styled-jsx'`, `@swc/helpers`, or other pnpm peer deps
368
369 A common concrete failure looks like:
370
371 - `code: 'MODULE_NOT_FOUND'`
372 - `path: '/home/git/apps/web/<app>/node_modules/@swc/helpers'`
373
374 That usually means the standalone upload succeeded, but the top-level hoisted symlink for a scoped package was not recreated on the server.
375
376 Cause:
377
378 - pnpm stores peer dependencies inside `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>` with internal symlinks that Node's standard `require.resolve()` cannot follow
379 - Next.js standalone traces the files into `.pnpm/` correctly, but does not create the flat `node_modules/<pkg>` entries needed for resolution
380 - this affects `styled-jsx`, `@swc/helpers`, `@next/env`, and other packages that `next` requires at startup
381
382 Fix:
383
384 - the CLI deploy script now auto-hoists in two passes:
385 - first mirror pnpm's virtual directory at `node_modules/.pnpm/node_modules/*`
386 - then fall back to scanning `node_modules/.pnpm/*/node_modules/*` for anything still missing
387 - this preserves pnpm's exact resolved package choice and fixes scoped packages like `@swc/helpers` as well as unscoped ones like `styled-jsx`
388 - no manual intervention needed for new deploys once the updated CLI is used
389 - to fix a server manually, prefer pnpm's virtual directory as the source of truth:
390 - create `node_modules/@scope/` directories as needed
391 - symlink `node_modules/<pkg>` or `node_modules/@scope/<pkg>` to the corresponding entry under `node_modules/.pnpm/node_modules/`
392 - only if that directory is missing, scan individual `.pnpm/<store-entry>/node_modules/*` folders as a fallback
393
394 ### Wrong port after deploy
395
396 Cause:
397
398 - `port` in `.smb/config.toml`, server ecosystem config (`.cjs` or `.js`), and nginx upstream disagree
399
400 Fix:
401
402 - standardize the same port in all three places
403 - if using ecosystem config, make sure PM2 is actually starting from that file
404
405 ### PM2 "module is not defined" or "require is not defined"
406
407 Cause:
408
409 - the project's `package.json` has `"type": "module"`, which makes Node treat `.js` files as ESM
410 - `ecosystem.config.js` uses `module.exports` (CommonJS), which is invalid under ESM
411 - deploy shims that use `require()` will also fail under ESM
412
413 Fix:
414
415 - rename `ecosystem.config.js` to `ecosystem.config.cjs` on the server
416 - deploy shims must use `import()` not `require()`
417 - the CLI now handles both automatically: it checks for `.cjs` first and the server-side migration auto-renames `.js` to `.cjs` during deploy
418
419 ### `rsync` of `.next/standalone/` fails with status 23 (dangling pnpm symlink)
420
421 A reproducible failure on pnpm monorepos. The deploy aborts at the standalone
422 upload with an empty stderr and:
423
424 ```
425 ✘ rsync of '.next/standalone/' failed (status 23):
426 ```
427
428 Run the same rsync by hand and you see the real cause:
429
430 ```
431 IO error encountered -- skipping file deletion
432 ```
433
434 Cause:
435
436 - Next.js standalone tracing emits a virtual pnpm symlink such as
437 `.next/standalone/node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/node_modules/semver`,
438 but only the version it actually traced (e.g. `semver@7.7.3`) is copied into
439 the bundle. The `@6.3.1` target is never written, so the symlink dangles.
440 - The CLI rsyncs standalone with `--copy-links`, which tries to follow every
441 symlink. A dangling link is an IO error → rsync exits 23 and skips deletion.
442 - The package is not part of the traced runtime, so the link is safe to drop.
443
444 Find them:
445
446 ```sh
447 find .next/standalone/ -type l ! -exec test -e {} \; -print
448 ```
449
450 Fix:
451
452 - The CLI now handles this automatically. `process_deploy_nextjs_ssr` runs a
453 step 3b (`prune_dangling_symlinks`) after the build and before the standalone
454 rsync: it walks `.next/standalone/` and deletes any symlink whose target does
455 not exist (real dirs are traversed; symlinked dirs are never followed). When
456 it removes anything it prints `Pruned N dangling symlink(s) …`. No manual step
457 is needed on current `smbcloud-cli`.
458 - Older `smb` (≤ 0.4.7) lacks the prune. On those, remove the link(s) before
459 upload and re-run the deploy — but the rebuild regenerates them, so it must
460 happen after `next build` and before rsync:
461 `find .next/standalone/ -type l ! -exec test -e {} \; -delete`
462 Driving the deploy by hand, delete then run the three rsyncs yourself. A
463 repo-local equivalent is a `postbuild` script that runs the same `find …
464 -delete`, since `smb` invokes `pnpm build` (which fires `postbuild`) in the
465 right window.
466
467 ### Misleading deploy output
468
469 The CLI may print generic deploy messages such as:
470
471 - `Deploying > Building the app`
472 - `App is running`
473
474 These messages do not mean git push happened. Confirm behavior from the code path, not the spinner text.
475
476 ## Auth and production-only web issues
477
478 If the Next.js app uses the wasm auth client from `smbcloud-auth-sdk-wasm`, production failures that show:
479
480 - `Network error. Please check your internet connection and try again.`
481
482 are often not actual connectivity failures.
483
484 Check these first:
485
486 - Rails CORS allows the production origin
487 - `Authorization` response header is exposed over CORS
488 - backend auth status codes match the Rust parser logic
489
490 ### Rails requirement
491
492 For browser clients using `POST /v1/client/users/sign_in`, if the token is returned in the `Authorization` response header, CORS must include:
493
494 - production origins such as `https://ondeinference.com`
495 - `expose: %w[... Authorization]`
496
497 Without that, browser fetch in wasm can surface as a fake network error.
498
499 ### Rust requirement
500
501 The shared Rust networking layer must not collapse all non-200 auth responses into `NetworkError`.
502
503 If backend returns meaningful auth errors such as `401` invalid password, update the parser in `request_login` to preserve the real failure.
504
505 ## Recommended validation
506
507 Use the smallest checks that prove the contract.
508
509 ### Next.js app
510
511 - `pnpm build`
512 - verify standalone contains a runnable server entrypoint
513 - if `.next/standalone/server.js` exists, verify `node .next/standalone/server.js` starts locally
514 - if standalone preserves the source path, verify `node .next/standalone/<source>/server.js` starts locally
515
516 ### smbcloud-cli
517
518 - `cargo check -p smbcloud-network`
519 - `cargo check -p cli` when deploy code changed
520
521 ### Server
522
523 - `pm2 list`
524 - `pm2 logs <pm2_app>`
525 - `ls -la /home/git/apps/web/<app>`
526 - `ls -ld /home/git/apps/web/<app>/node_modules/next`
527 - `sudo nginx -t`
528
529 ## Common mistakes
530
531 - assuming `deployment_method = 1` alone activates SSR deploy
532 - forgetting `kind = "nextjs-ssr"`
533 - keeping an old git `post-receive` hook and expecting it to manage SSR deploys
534 - `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static
535 is under `<app>/<source>/.next/static`, so the alias 404s; proxy everything instead
536 - assuming a dangling pnpm symlink in `.next/standalone/` still breaks the
537 deploy — current `smbcloud-cli` prunes them in step 3b before the
538 `rsync --copy-links` upload; only pre-prune manually on `smb` ≤ 0.4.7
539 - giving two hostnames one `server` block without a shared SAN cert
540 - using static-file Nginx config for an SSR app
541 - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server
542 - editing the repo copy of `ecosystem.config.cjs` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
543 - editing the server `ecosystem.config.cjs` but the deploy script falling back to inline `node server.js` because the file path check is wrong
544 - keeping an `ecosystem.config.js` when the project uses ESM (`"type": "module"` in `package.json`) — rename it to `.cjs` or let the CLI migration handle it
545 - leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml`
546 - forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available
547 - treating browser CORS failures as real internet outages
548 - deploying without `frontend_app_id` in config — the API will try to infer the app, but explicit is better than implicit