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 the app over SSH (PM2 by default, or a git-owned `systemctl --user` service when `process_manager = "systemd"`), 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 # process_manager = "systemd" # optional; default is pm2 (see "systemd --user" below)
87 ```
88
89 Important rules:
90
91 - `kind = "nextjs-ssr"` is what activates the SSR deploy path
92 - `id` is the umbrella workspace ID, used for auth and API routing
93 - `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
94 - `deploy_repo_id` identifies the DeployRepo record backing this app — include it when available
95 - `source` is the local Next.js app directory
96 - `path` is the remote destination directory on the server
97 - `pm2_app` is mandatory because the CLI restarts PM2 by name (with
98 `process_manager = "systemd"` this same value is the systemd `--user` unit
99 name, `<pm2_app>.service`)
100 - `port` should be set explicitly and must match nginx
101 - `process_manager` is optional. Omit it (or set `"pm2"`) for the default PM2
102 path; set `"systemd"` to restart via a git-owned `systemctl --user` service
103 instead (see "Process manager: systemd --user" below)
104 - `deployment_method = 1` may still be present, but the `kind` routing is what matters most
105
106 ## Expected remote layout
107
108 After a successful deploy, the remote directory should look like one of these layouts:
109
110 - flat runtime
111 - `/home/git/apps/web/<app>/server.js`
112 - `/home/git/apps/web/<app>/.next/static/...`
113 - `/home/git/apps/web/<app>/public/...`
114 - nested runtime when standalone preserves the source path
115 - `/home/git/apps/web/<app>/node_modules/...`
116 - `/home/git/apps/web/<app>/web/server.js`
117 - `/home/git/apps/web/<app>/web/.next/static/...`
118 - `/home/git/apps/web/<app>/web/public/...`
119
120 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.
121
122 ## PM2 behavior
123
124 The CLI's deploy path now deletes and starts fresh, preferring the server's
125 ecosystem config file when it exists. The CLI checks for `ecosystem.config.cjs`
126 first, then falls back to `ecosystem.config.js`. During deploy, the server-side
127 migration auto-renames `.js` to `.cjs` when applicable.
128
129 Conceptually it behaves like:
130
131 ```sh
132 cd "$APP_PATH"
133 mkdir -p logs
134
135 if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
136 pm2 delete "$PM2_APP"
137 fi
138
139 if [ -f ecosystem.config.cjs ]; then
140 pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
141 elif [ -f ecosystem.config.js ]; then
142 pm2 start ecosystem.config.js --only "$PM2_APP" --env production
143 else
144 NODE_ENV=production PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
145 fi
146
147 pm2 save
148 ```
149
150 This has two consequences:
151
152 - old git-based `post-receive` hooks are irrelevant for the SSR path
153 - the configured `port` in `.smb/config.toml` is used for the fallback start path
154 - the live server `ecosystem.config.cjs` (or `.js`) is the runtime source of truth when present
155
156 ## Process manager: systemd --user (alternative to PM2)
157
158 Set `process_manager = "systemd"` in `[project]` to have the deploy restart the
159 app through a **git-owned `systemctl --user` service** instead of PM2. The unit
160 is named after `pm2_app` (`<pm2_app>.service`). This is the recommended target
161 for the nbg box — a cgroup-supervised unit kills the whole process tree on
162 `restart` (no orphaned `next-server` holding the port, the failure mode that
163 caused the karokowe.com gateway-timeout outage) and survives reboot via linger,
164 with no `pm2 save` to forget.
165
166 The deploy restart step becomes (no sudo — the CLI SSHes as the git app-owner
167 and drives that user's own manager):
168
169 ```sh
170 export XDG_RUNTIME_DIR="/run/user/$(id -u)"
171 UNIT="$PM2_APP.service"
172 # fails loudly if the unit was never created
173 systemctl --user cat "$UNIT" >/dev/null || exit 1
174 systemctl --user restart "$UNIT"
175 systemctl --user is-active "$UNIT" # verified after a short sleep
176 ```
177
178 Prerequisites (one-time per app, done out-of-band before the first
179 `process_manager = "systemd"` deploy):
180
181 1. Enable linger for the app-owner so its units run without an active login and
182 start at boot: `sudo loginctl enable-linger git`.
183 2. Create the unit and env file, then cut over off PM2 (all as the `git` user).
184 Capture the app's current settings first — `pm2 describe <app>` shows the
185 interpreter, script, cwd, and env PM2 is running with, which is what the unit
186 has to reproduce:
187
188 ```sh
189 export XDG_RUNTIME_DIR="/run/user/$(id -u)"
190 APP=my-app # == pm2_app == the unit name
191 APP_PATH=~/apps/web/my-app # == `path` in .smb/config.toml
192 mkdir -p ~/.config/systemd/user
193
194 # Env file (mode 600 — it holds per-app secrets).
195 # Port/hostname must match .smb/config.toml and the Nginx upstream.
196 install -m 600 /dev/null ~/.config/systemd/user/$APP.env
197 cat >> ~/.config/systemd/user/$APP.env <<'EOF'
198 NODE_ENV=production
199 PORT=3031
200 HOSTNAME=127.0.0.1
201 EOF
202
203 cat > ~/.config/systemd/user/$APP.service <<EOF
204 [Unit]
205 Description=$APP (Next.js standalone)
206 After=network-online.target
207
208 [Service]
209 Type=simple
210 WorkingDirectory=$APP_PATH
211 EnvironmentFile=%h/.config/systemd/user/$APP.env
212 ExecStart=/usr/bin/env node server.js
213 Restart=always
214 RestartSec=2
215
216 [Install]
217 WantedBy=default.target
218 EOF
219 ```
220
221 Then cut over, stopping PM2 first so both supervisors never hold the port at
222 once:
223
224 ```sh
225 pm2 delete "$APP" || true
226 pm2 save # forget it across pm2 resurrect
227 systemctl --user daemon-reload
228 systemctl --user enable --now "$APP.service"
229 systemctl --user is-active "$APP.service" # expect: active
230 curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3031
231 ```
232
233 If `ExecStart` needs to differ (a custom server entrypoint, a pinned Node
234 version via `~/.nvm/versions/node/<v>/bin/node`), match whatever
235 `pm2 describe` reported rather than the defaults above.
236 3. Set `process_manager = "systemd"` in the app's `.smb/config.toml`.
237
238 After that, every `smb deploy` restarts via `systemctl --user` and the deploy
239 aborts if the unit is missing (so a misconfigured `process_manager` can't
240 silently leave the app down).
241
242
243 ### Environment variables — ecosystem file (standard pattern)
244
245 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).
246
247 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.
248
249 The flow works like this:
250
251 1. On first setup, SSH in, create the ecosystem file, start with it, and save:
252
253 ```sh
254 ssh git@<server>
255 cd /home/git/apps/web/<app>
256
257 cat > ecosystem.config.cjs << 'EOF'
258 module.exports = {
259 apps: [{
260 name: "<pm2_app>",
261 script: "server.js",
262 cwd: "/home/git/apps/web/<app>",
263 env_production: {
264 NODE_ENV: "production",
265 PORT: 3026,
266 HOSTNAME: "127.0.0.1",
267 MY_SECRET: "...",
268 }
269 }]
270 }
271 EOF
272
273 pm2 start ecosystem.config.cjs --env production
274 pm2 save
275 ```
276
277 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`.
278
279 3. To change or add env vars, SSH in, edit `ecosystem.config.cjs`, then:
280
281 ```sh
282 pm2 restart <pm2_app> --update-env
283 pm2 save
284 ```
285
286 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.
287
288 ### Manual PM2 operations (without ecosystem file)
289
290 If you are operating without an ecosystem file, prefer the same fresh-start model the CLI uses:
291
292 ```sh
293 cd /home/git/apps/web/<app>
294 if pm2 describe <pm2_app> > /dev/null 2>&1; then
295 pm2 delete <pm2_app>
296 fi
297 PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js
298 pm2 save
299 ```
300
301 If you are using an ecosystem file and only changed env vars manually, this is still valid:
302
303 ```sh
304 cd /home/git/apps/web/<app>
305 pm2 restart <pm2_app> --update-env
306 pm2 save
307 ```
308
309 ## Port management
310
311 Use one source of truth for the runtime port.
312
313 If an older ecosystem config (`.cjs` or `.js`) exists and contains both:
314
315 - `args: "start -p 3026"`
316 - `env_production.PORT = 3025`
317
318 that is a broken configuration. Standardize on one port value.
319
320 Current rules:
321
322 - fallback fresh-start path uses `port` from `.smb/config.toml`
323 - ecosystem-managed apps should set the same port in `env_production.PORT` (in `ecosystem.config.cjs`)
324 - nginx upstream must match that port
325
326 ## Nginx for SSR
327
328 Do not use a static-site Nginx config like:
329
330 ```nginx
331 root /home/git/apps/web/my-app;
332 location / {
333 try_files $uri $uri/ =404;
334 }
335 ```
336
337 That is for static files, not Next.js SSR.
338
339 Use reverse proxying to the local Node process instead:
340
341 ```nginx
342 location / {
343 proxy_pass http://127.0.0.1:3026;
344 proxy_http_version 1.1;
345
346 proxy_set_header Host $host;
347 proxy_set_header X-Real-IP $remote_addr;
348 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
349 proxy_set_header X-Forwarded-Proto $scheme;
350
351 proxy_set_header Upgrade $http_upgrade;
352 proxy_set_header Connection "upgrade";
353
354 proxy_cache_bypass $http_upgrade;
355 }
356 ```
357
358 Nginx must proxy to the actual PM2 port.
359
360 ### Do not `alias` `/_next/static` for nested standalone builds
361
362 The proxy-everything block above is the safe default: let the standalone Node
363 server serve `/_next/static` itself. The common breakage is an nginx block that
364 tries to serve those assets from disk:
365
366 ```nginx
367 # WRONG for a nested (monorepo) standalone build
368 location /_next/static {
369 alias /home/git/apps/web/<app>/.next/static; # 404s every JS/CSS
370 }
371 ```
372
373 When the app builds with `outputFileTracingRoot` (monorepo), standalone is
374 **nested**, so static actually lands at `…/<app>/<source>/.next/static`
375 (e.g. `…/<app>/apps/web/.next/static`), not `…/<app>/.next/static`. The alias
376 points at the wrong directory and every chunk 404s while the HTML still loads —
377 a confusing "page renders, no styles/JS" symptom.
378
379 Fixes, in order of preference:
380
381 1. Drop the `/_next/static` `alias` block and proxy everything to the Node
382 process (the block above). Layout-independent — works for flat and nested.
383 2. If you must serve static from disk, point the alias at the nested path:
384 `alias …/<app>/<source>/.next/static;`.
385
386 Flat (single-repo, `source = "."`) builds keep static at `…/<app>/.next/static`,
387 so a disk alias there happens to be correct — but proxy-everything still works
388 and is the recommended uniform pattern.
389
390 ### One file + SAN cert for multiple hostnames
391
392 To serve several hostnames for the same app (e.g. a primary domain plus a
393 `*.5mb.app` alias) from one `server` block, they must share one certificate — a
394 `server` block can only present one cert. Issue a SAN cert listing both names,
395 then write one block:
396
397 ```sh
398 sudo certbot certonly --nginx -d app.example.com -d app.example.net --cert-name myapp
399 ```
400
401 ```nginx
402 server {
403 listen 443 ssl http2;
404 server_name app.example.com app.example.net;
405 ssl_certificate /etc/letsencrypt/live/myapp/fullchain.pem;
406 ssl_certificate_key /etc/letsencrypt/live/myapp/privkey.pem;
407 # ... single proxy_pass block; Host $host passes the real hostname through
408 }
409 ```
410
411 Mixing two different certs (a per-domain cert plus a shared wildcard) forces two
412 `server` blocks. Run `certbot certonly` first, then write the config that
413 references the new lineage — pointing `ssl_certificate` at a path that does not
414 exist yet makes `nginx -t` fail.
415
416 ## Common failure modes
417
418 ### `.next/standalone` missing
419
420 Cause:
421
422 - no `output: "standalone"` in Next config
423
424 Fix:
425
426 - add standalone output
427 - rebuild locally
428
429 ### `server.js` missing on the server
430
431 Check:
432
433 - whether the SSR deploy path actually ran
434 - whether `path` points to the directory you inspected
435 - whether `rsync` uploaded to a different location
436
437 ### Missing `public/`
438
439 `public/` is optional. The current deploy code skips it when the directory does not exist.
440
441 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.
442
443 ### `Cannot find module 'next'` or `502 Bad Gateway`
444
445 Cause:
446
447 - `.next/standalone/node_modules/*` contains `pnpm` symlinks
448 - rsync preserved the symlinks instead of copying their targets
449 - or the standalone output itself is incomplete and missing traced runtime files
450 - PM2 starts `server.js`, Node cannot resolve `next`, and nginx returns `502 Bad Gateway`
451
452 Fix:
453
454 - use the dedicated `nextjs-ssr` CLI path in current `smbcloud-cli`
455 - make sure the standalone upload uses `rsync --copy-links`
456 - verify `node .next/standalone/server.js` works locally before deploy
457 - if local standalone fails too, add `outputFileTracingIncludes` for the missing runtime files and rebuild
458 - then restart PM2 and recheck `pm2 logs <pm2_app>`
459
460 ### `Cannot find module 'styled-jsx'`, `@swc/helpers`, or other pnpm peer deps
461
462 A common concrete failure looks like:
463
464 - `code: 'MODULE_NOT_FOUND'`
465 - `path: '/home/git/apps/web/<app>/node_modules/@swc/helpers'`
466
467 That usually means the standalone upload succeeded, but the top-level hoisted symlink for a scoped package was not recreated on the server.
468
469 Cause:
470
471 - pnpm stores peer dependencies inside `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>` with internal symlinks that Node's standard `require.resolve()` cannot follow
472 - Next.js standalone traces the files into `.pnpm/` correctly, but does not create the flat `node_modules/<pkg>` entries needed for resolution
473 - this affects `styled-jsx`, `@swc/helpers`, `@next/env`, and other packages that `next` requires at startup
474
475 Fix:
476
477 - the CLI deploy script now auto-hoists in two passes:
478 - first mirror pnpm's virtual directory at `node_modules/.pnpm/node_modules/*`
479 - then fall back to scanning `node_modules/.pnpm/*/node_modules/*` for anything still missing
480 - this preserves pnpm's exact resolved package choice and fixes scoped packages like `@swc/helpers` as well as unscoped ones like `styled-jsx`
481 - no manual intervention needed for new deploys once the updated CLI is used
482 - to fix a server manually, prefer pnpm's virtual directory as the source of truth:
483 - create `node_modules/@scope/` directories as needed
484 - symlink `node_modules/<pkg>` or `node_modules/@scope/<pkg>` to the corresponding entry under `node_modules/.pnpm/node_modules/`
485 - only if that directory is missing, scan individual `.pnpm/<store-entry>/node_modules/*` folders as a fallback
486
487 ### Wrong port after deploy
488
489 Cause:
490
491 - `port` in `.smb/config.toml`, server ecosystem config (`.cjs` or `.js`), and nginx upstream disagree
492
493 Fix:
494
495 - standardize the same port in all three places
496 - if using ecosystem config, make sure PM2 is actually starting from that file
497
498 ### PM2 "module is not defined" or "require is not defined"
499
500 Cause:
501
502 - the project's `package.json` has `"type": "module"`, which makes Node treat `.js` files as ESM
503 - `ecosystem.config.js` uses `module.exports` (CommonJS), which is invalid under ESM
504 - deploy shims that use `require()` will also fail under ESM
505
506 Fix:
507
508 - rename `ecosystem.config.js` to `ecosystem.config.cjs` on the server
509 - deploy shims must use `import()` not `require()`
510 - the CLI now handles both automatically: it checks for `.cjs` first and the server-side migration auto-renames `.js` to `.cjs` during deploy
511
512 ### `rsync` of `.next/standalone/` fails with status 23 (dangling pnpm symlink)
513
514 A reproducible failure on pnpm monorepos. The deploy aborts at the standalone
515 upload with an empty stderr and:
516
517 ```
518 ✘ rsync of '.next/standalone/' failed (status 23):
519 ```
520
521 Run the same rsync by hand and you see the real cause:
522
523 ```
524 IO error encountered -- skipping file deletion
525 ```
526
527 Cause:
528
529 - Next.js standalone tracing emits a virtual pnpm symlink such as
530 `.next/standalone/node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/node_modules/semver`,
531 but only the version it actually traced (e.g. `semver@7.7.3`) is copied into
532 the bundle. The `@6.3.1` target is never written, so the symlink dangles.
533 - The CLI rsyncs standalone with `--copy-links`, which tries to follow every
534 symlink. A dangling link is an IO error → rsync exits 23 and skips deletion.
535 - The package is not part of the traced runtime, so the link is safe to drop.
536
537 Find them:
538
539 ```sh
540 find .next/standalone/ -type l ! -exec test -e {} \; -print
541 ```
542
543 Fix:
544
545 - The CLI now handles this automatically. `process_deploy_nextjs_ssr` runs a
546 step 3b (`prune_dangling_symlinks`) after the build and before the standalone
547 rsync: it walks `.next/standalone/` and deletes any symlink whose target does
548 not exist (real dirs are traversed; symlinked dirs are never followed). When
549 it removes anything it prints `Pruned N dangling symlink(s) …`. No manual step
550 is needed on current `smbcloud-cli`.
551 - Older `smb` (≤ 0.4.7) lacks the prune. On those, remove the link(s) before
552 upload and re-run the deploy — but the rebuild regenerates them, so it must
553 happen after `next build` and before rsync:
554 `find .next/standalone/ -type l ! -exec test -e {} \; -delete`
555 Driving the deploy by hand, delete then run the three rsyncs yourself. A
556 repo-local equivalent is a `postbuild` script that runs the same `find …
557 -delete`, since `smb` invokes `pnpm build` (which fires `postbuild`) in the
558 right window.
559
560 ### Misleading deploy output
561
562 The CLI may print generic deploy messages such as:
563
564 - `Deploying > Building the app`
565 - `App is running`
566
567 These messages do not mean git push happened. Confirm behavior from the code path, not the spinner text.
568
569 ## Auth and production-only web issues
570
571 If the Next.js app uses the wasm auth client from `smbcloud-auth-sdk-wasm`, production failures that show:
572
573 - `Network error. Please check your internet connection and try again.`
574
575 are often not actual connectivity failures.
576
577 Check these first:
578
579 - Rails CORS allows the production origin
580 - `Authorization` response header is exposed over CORS
581 - backend auth status codes match the Rust parser logic
582
583 ### Rails requirement
584
585 For browser clients using `POST /v1/client/users/sign_in`, if the token is returned in the `Authorization` response header, CORS must include:
586
587 - production origins such as `https://ondeinference.com`
588 - `expose: %w[... Authorization]`
589
590 Without that, browser fetch in wasm can surface as a fake network error.
591
592 ### Rust requirement
593
594 The shared Rust networking layer must not collapse all non-200 auth responses into `NetworkError`.
595
596 If backend returns meaningful auth errors such as `401` invalid password, update the parser in `request_login` to preserve the real failure.
597
598 ## Recommended validation
599
600 Use the smallest checks that prove the contract.
601
602 ### Next.js app
603
604 - `pnpm build`
605 - verify standalone contains a runnable server entrypoint
606 - if `.next/standalone/server.js` exists, verify `node .next/standalone/server.js` starts locally
607 - if standalone preserves the source path, verify `node .next/standalone/<source>/server.js` starts locally
608
609 ### smbcloud-cli
610
611 - `cargo check -p smbcloud-network`
612 - `cargo check -p cli` when deploy code changed
613
614 ### Server
615
616 - `pm2 list`
617 - `pm2 logs <pm2_app>`
618 - `ls -la /home/git/apps/web/<app>`
619 - `ls -ld /home/git/apps/web/<app>/node_modules/next`
620 - `sudo nginx -t`
621
622 ## Common mistakes
623
624 - assuming `deployment_method = 1` alone activates SSR deploy
625 - forgetting `kind = "nextjs-ssr"`
626 - keeping an old git `post-receive` hook and expecting it to manage SSR deploys
627 - `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static
628 is under `<app>/<source>/.next/static`, so the alias 404s; proxy everything instead
629 - assuming a dangling pnpm symlink in `.next/standalone/` still breaks the
630 deploy — current `smbcloud-cli` prunes them in step 3b before the
631 `rsync --copy-links` upload; only pre-prune manually on `smb` ≤ 0.4.7
632 - giving two hostnames one `server` block without a shared SAN cert
633 - using static-file Nginx config for an SSR app
634 - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server
635 - editing the repo copy of `ecosystem.config.cjs` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
636 - editing the server `ecosystem.config.cjs` but the deploy script falling back to inline `node server.js` because the file path check is wrong
637 - 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
638 - leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml`
639 - forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available
640 - treating browser CORS failures as real internet outages
641 - deploying without `frontend_app_id` in config — the API will try to infer the app, but explicit is better than implicit