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 ## Common failure modes
268
269 ### `.next/standalone` missing
270
271 Cause:
272
273 - no `output: "standalone"` in Next config
274
275 Fix:
276
277 - add standalone output
278 - rebuild locally
279
280 ### `server.js` missing on the server
281
282 Check:
283
284 - whether the SSR deploy path actually ran
285 - whether `path` points to the directory you inspected
286 - whether `rsync` uploaded to a different location
287
288 ### Missing `public/`
289
290 `public/` is optional. The current deploy code skips it when the directory does not exist.
291
292 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.
293
294 ### `Cannot find module 'next'` or `502 Bad Gateway`
295
296 Cause:
297
298 - `.next/standalone/node_modules/*` contains `pnpm` symlinks
299 - rsync preserved the symlinks instead of copying their targets
300 - or the standalone output itself is incomplete and missing traced runtime files
301 - PM2 starts `server.js`, Node cannot resolve `next`, and nginx returns `502 Bad Gateway`
302
303 Fix:
304
305 - use the dedicated `nextjs-ssr` CLI path in current `smbcloud-cli`
306 - make sure the standalone upload uses `rsync --copy-links`
307 - verify `node .next/standalone/server.js` works locally before deploy
308 - if local standalone fails too, add `outputFileTracingIncludes` for the missing runtime files and rebuild
309 - then restart PM2 and recheck `pm2 logs <pm2_app>`
310
311 ### `Cannot find module 'styled-jsx'`, `@swc/helpers`, or other pnpm peer deps
312
313 A common concrete failure looks like:
314
315 - `code: 'MODULE_NOT_FOUND'`
316 - `path: '/home/git/apps/web/<app>/node_modules/@swc/helpers'`
317
318 That usually means the standalone upload succeeded, but the top-level hoisted symlink for a scoped package was not recreated on the server.
319
320 Cause:
321
322 - pnpm stores peer dependencies inside `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>` with internal symlinks that Node's standard `require.resolve()` cannot follow
323 - Next.js standalone traces the files into `.pnpm/` correctly, but does not create the flat `node_modules/<pkg>` entries needed for resolution
324 - this affects `styled-jsx`, `@swc/helpers`, `@next/env`, and other packages that `next` requires at startup
325
326 Fix:
327
328 - the CLI deploy script now auto-hoists in two passes:
329 - first mirror pnpm's virtual directory at `node_modules/.pnpm/node_modules/*`
330 - then fall back to scanning `node_modules/.pnpm/*/node_modules/*` for anything still missing
331 - this preserves pnpm's exact resolved package choice and fixes scoped packages like `@swc/helpers` as well as unscoped ones like `styled-jsx`
332 - no manual intervention needed for new deploys once the updated CLI is used
333 - to fix a server manually, prefer pnpm's virtual directory as the source of truth:
334 - create `node_modules/@scope/` directories as needed
335 - symlink `node_modules/<pkg>` or `node_modules/@scope/<pkg>` to the corresponding entry under `node_modules/.pnpm/node_modules/`
336 - only if that directory is missing, scan individual `.pnpm/<store-entry>/node_modules/*` folders as a fallback
337
338 ### Wrong port after deploy
339
340 Cause:
341
342 - `port` in `.smb/config.toml`, server ecosystem config (`.cjs` or `.js`), and nginx upstream disagree
343
344 Fix:
345
346 - standardize the same port in all three places
347 - if using ecosystem config, make sure PM2 is actually starting from that file
348
349 ### PM2 "module is not defined" or "require is not defined"
350
351 Cause:
352
353 - the project's `package.json` has `"type": "module"`, which makes Node treat `.js` files as ESM
354 - `ecosystem.config.js` uses `module.exports` (CommonJS), which is invalid under ESM
355 - deploy shims that use `require()` will also fail under ESM
356
357 Fix:
358
359 - rename `ecosystem.config.js` to `ecosystem.config.cjs` on the server
360 - deploy shims must use `import()` not `require()`
361 - the CLI now handles both automatically: it checks for `.cjs` first and the server-side migration auto-renames `.js` to `.cjs` during deploy
362
363 ### Misleading deploy output
364
365 The CLI may print generic deploy messages such as:
366
367 - `Deploying > Building the app`
368 - `App is running`
369
370 These messages do not mean git push happened. Confirm behavior from the code path, not the spinner text.
371
372 ## Auth and production-only web issues
373
374 If the Next.js app uses the wasm auth client from `smbcloud-auth-sdk-wasm`, production failures that show:
375
376 - `Network error. Please check your internet connection and try again.`
377
378 are often not actual connectivity failures.
379
380 Check these first:
381
382 - Rails CORS allows the production origin
383 - `Authorization` response header is exposed over CORS
384 - backend auth status codes match the Rust parser logic
385
386 ### Rails requirement
387
388 For browser clients using `POST /v1/client/users/sign_in`, if the token is returned in the `Authorization` response header, CORS must include:
389
390 - production origins such as `https://ondeinference.com`
391 - `expose: %w[... Authorization]`
392
393 Without that, browser fetch in wasm can surface as a fake network error.
394
395 ### Rust requirement
396
397 The shared Rust networking layer must not collapse all non-200 auth responses into `NetworkError`.
398
399 If backend returns meaningful auth errors such as `401` invalid password, update the parser in `request_login` to preserve the real failure.
400
401 ## Recommended validation
402
403 Use the smallest checks that prove the contract.
404
405 ### Next.js app
406
407 - `pnpm build`
408 - verify standalone contains a runnable server entrypoint
409 - if `.next/standalone/server.js` exists, verify `node .next/standalone/server.js` starts locally
410 - if standalone preserves the source path, verify `node .next/standalone/<source>/server.js` starts locally
411
412 ### smbcloud-cli
413
414 - `cargo check -p smbcloud-network`
415 - `cargo check -p cli` when deploy code changed
416
417 ### Server
418
419 - `pm2 list`
420 - `pm2 logs <pm2_app>`
421 - `ls -la /home/git/apps/web/<app>`
422 - `ls -ld /home/git/apps/web/<app>/node_modules/next`
423 - `sudo nginx -t`
424
425 ## Common mistakes
426
427 - assuming `deployment_method = 1` alone activates SSR deploy
428 - forgetting `kind = "nextjs-ssr"`
429 - keeping an old git `post-receive` hook and expecting it to manage SSR deploys
430 - using static-file Nginx config for an SSR app
431 - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server
432 - editing the repo copy of `ecosystem.config.cjs` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally
433 - editing the server `ecosystem.config.cjs` but the deploy script falling back to inline `node server.js` because the file path check is wrong
434 - 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
435 - leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml`
436 - forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available
437 - treating browser CORS failures as real internet outages
438 - deploying without `frontend_app_id` in config — the API will try to infer the app, but explicit is better than implicit