1 ---
2 name: smbcloud-deploy-monorepo
3 description: Use when deploying from a monorepo with smbCloud CLI, especially repositories that contain multiple deployable sub-projects with different strategies (Next.js SSR, Vite SPA, Rails, static) behind a single `.smb/config.toml` with `runner = 255` and `[[projects]]` entries.
4 ---
5
6 # smbCloud Deploy Monorepo
7
8 Use this skill when work touches monorepo deployment on smbCloud.
9
10 Applies to:
11
12 - repositories with `runner = 255` in `[project]`
13 - `.smb/config.toml` files with `[[projects]]` arrays
14 - `process_deploy.rs` monorepo resolution logic
15 - sub-project routing to `nextjs-ssr`, `vite-spa`, `rails`, rsync, or git deploy paths
16 - multi-tenant PM2 and Nginx server configuration
17 - CI workflows that deploy individual sub-projects from a monorepo
18
19 ## What makes a monorepo
20
21 A monorepo is a repository where `[project]` has `runner = 255` (the `Monorepo` variant) and deployable targets are listed as `[[projects]]` entries. The root project is never deployed directly — it is a container.
22
23 Keep the deploy hierarchy straight:
24
25 - root `[project]` = umbrella workspace in smbCloud (not deployable)
26 - repo = the monorepo itself, represented by a `DeployRepo` record
27 - each `[[projects]]` entry = one deployable `FrontendApp` inside that repo, identified by `frontend_app_id`
28 - each deployment record = one release of one `FrontendApp`, linked via `frontend_app_id`
29
30 The canonical chain is: Deployment → FrontendApp → DeployRepo → Project
31
32 The config format still uses `[[projects]]` for historical reasons, but conceptually those entries are app targets, not separate umbrella workspaces. Each entry should include `frontend_app_id` and `deploy_repo_id` so deployments are attributed to the correct app.
33
34 The CLI detects this in `process_deploy.rs`:
35
36 ```rust
37 let resolved_name = match project_name {
38 Some(name) => Some(name),
39 None if config.project.runner == Runner::Monorepo => Some(prompt_select_project(&config)?),
40 None => None,
41 };
42 ```
43
44 When `runner = 255` and no `--project` flag is passed, the CLI shows an interactive selection prompt. When `--project <name>` is passed, the prompt is skipped.
45
46 After selection, `resolve_sub_project()` swaps `config.project` with the matching `[[projects]]` entry. From that point, all downstream deploy code operates on a single `Project` — it does not know or care that it came from a monorepo.
47
48 ## Config structure
49
50 A monorepo `.smb/config.toml` has two layers:
51
52 ### Root project (container)
53
54 ```toml
55 name = "splitfire"
56 description = "SplitFire AI monorepo."
57
58 [project]
59 id = 38
60 name = "splitfire"
61 repository = "splitfire"
62 runner = 255
63 description = "SplitFire AI monorepo."
64 created_at = "2025-09-20T16:33:05.154Z"
65 updated_at = "2025-09-20T16:33:05.154Z"
66 ```
67
68 The root `id` is the workspace ID. It is not a deployable app ID.
69
70 Key rules:
71
72 - `runner = 255` is mandatory — this is what activates monorepo behavior
73 - `id` must be a valid smbCloud project ID (created via `smb init` or the web console)
74 - the root project has no `kind`, `source`, `path`, or `pm2_app` — it is not deployable
75
76 ### Sub-projects (deployable units)
77
78 Each `[[projects]]` entry is a self-contained deployable app target with its own `kind`, `source`, `path`, `runner`, and strategy-specific fields.
79
80 ```toml
81 [[projects]]
82 id = 38
83 frontend_app_id = "a7f6d5d4-..."
84 deploy_repo_id = 12
85 name = "splitfireweb"
86 repository = "splitfireweb"
87 description = "SplitFire AI website."
88 source = "frontend/splitfire-web"
89 path = "apps/web/splitfireweb"
90 runner = 0
91 kind = "nextjs-ssr"
92 package_manager = "pnpm"
93 pm2_app = "splitfire-web"
94 port = 3010
95 created_at = "2025-09-20T16:33:05.154Z"
96 updated_at = "2025-09-20T16:33:05.154Z"
97
98 [[projects]]
99 id = 98
100 name = "musik88web"
101 repository = "musik88-production"
102 description = "Musik88 web or SplitFire AI API."
103 source = "backend/musik88-web"
104 runner = 2
105 kind = "rails"
106 shared_lib = "lib"
107 compile_cmd = "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile"
108 created_at = "2025-09-20T16:33:05.154Z"
109 updated_at = "2025-09-20T16:33:05.154Z"
110
111 [[projects]]
112 id = 53
113 name = "connecteddevices"
114 repository = "connecteddevices"
115 description = "KaroKowe connected devices TV web app."
116 source = "frontend/karokowe-connected-devices"
117 path = "apps/web/connecteddeviceskarokowe"
118 runner = 0
119 kind = "vite-spa"
120 output = "dist"
121 package_manager = "pnpm"
122 created_at = "2025-09-20T16:33:05.154Z"
123 updated_at = "2025-09-20T16:33:05.154Z"
124 ```
125
126 ## Sub-project field reference
127
128 | Field | Required | Description |
129 | ------------------- | ----------- | ------------------------------------------------------------------------------------------- |
130 | `id` | yes | umbrella workspace ID in smbCloud |
131 | `frontend_app_id` | recommended | FrontendApp UUID — include so deployments are attributed to the correct app |
132 | `deploy_repo_id` | recommended | DeployRepo ID — links the deploy target to its git repository record |
133 | `name` | yes | unique identifier, used with `--project` flag and in the selection prompt |
134 | `repository` | yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
135 | `description` | no | human-readable description |
136 | `source` | yes | local path to the sub-project directory, relative to the monorepo root |
137 | `path` | depends | remote directory on the server, relative to `~/` — required for `nextjs-ssr` and `vite-spa` |
138 | `runner` | yes | server tier: `0` (NodeJs), `1` (Static), `2` (Ruby), `3` (Swift) |
139 | `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"vite-spa"`, `"rails"`, or omitted for generic deploy |
140 | `package_manager` | depends | `"pnpm"` or `"npm"` — required for `nextjs-ssr` and `vite-spa` |
141 | `pm2_app` | depends | PM2 process name — required for `nextjs-ssr` |
142 | `port` | depends | runtime port — required for `nextjs-ssr`, defaults to `3000` if omitted |
143 | `output` | depends | build output directory — required for `vite-spa`, typically `"dist"` |
144 | `shared_lib` | no | path to shared library directory to rsync before deploy — used by `rails` |
145 | `compile_cmd` | no | SSH command to run on the server after syncing shared libs — used by `rails` |
146 | `deployment_method` | no | `0` (Git) or `1` (Rsync) — only matters when `kind` is not set |
147 | `created_at` | yes | ISO 8601 timestamp |
148 | `updated_at` | yes | ISO 8601 timestamp |
149
150 ## Deploy routing
151
152 The `kind` field drives strategy selection. This happens after `resolve_sub_project()` has already swapped the active project, so routing logic is identical for standalone and monorepo projects.
153
154 ```
155 smb deploy --project <name>
156 │
157 ▼
158 resolve_sub_project()
159 │
160 ▼
161 config.project.kind?
162 │
163 ┌────┼──────────┬──────────────┐
164 ▼ ▼ ▼ ▼
165 vite-spa nextjs-ssr rails (none)
166 │ │ │ │
167 ▼ ▼ ▼ ▼
168 pnpm pnpm rsync deployment_method?
169 build install lib/ │
170 │ + build compile ┌──┴──┐
171 ▼ │ git push Git Rsync
172 rsync ▼ │ │ │
173 dist/ rsync 3 ▼ ▼ ▼
174 dirs post- git rsync
175 │ receive push -a
176 ▼ hook
177 SSH PM2
178 restart
179 ```
180
181 ### Strategy: `nextjs-ssr`
182
183 Local build, rsync three directories, then SSH delete + fresh PM2 start (prefer server `ecosystem.config.cjs` when present; falls back to legacy `.js`). The `.next/standalone/` upload should use `--copy-links` so `pnpm` symlinked dependencies land as real files in the deployed tree. If `outputFileTracingRoot` points above the sub-project directory, Next may preserve the sub-project path inside standalone output, and the deploy path must place `.next/static` and `public` under that nested runtime directory. See `smbcloud-deploy-nextjs` skill for details.
184
185 Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
186
187 ### Strategy: `vite-spa`
188
189 Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
190
191 Required fields: `kind`, `source`, `path`, `output`, `package_manager`
192
193 ### Strategy: `rails`
194
195 Rsync shared libraries, SSH compile native extensions, git force-push the sub-project directory to the server's bare repo (triggers post-receive hook).
196
197 Required fields: `kind`, `source`, `repository`, `runner = 2`
198
199 Optional: `shared_lib`, `compile_cmd`
200
201 ### Strategy: generic git or rsync
202
203 When `kind` is omitted, the `deployment_method` field controls the path. Git push uses libgit2 to push to the server's bare repo. Rsync uses system `rsync` to sync the source tree.
204
205 ## The `source` field
206
207 The `source` field is the most important monorepo-specific field. It tells the CLI where the sub-project lives relative to the monorepo root.
208
209 For `nextjs-ssr` and `vite-spa`, the CLI changes into the `source` directory before running build commands:
210
211 - `pnpm install` runs inside `source/`
212 - `pnpm build` runs inside `source/`
213 - rsync sources are relative to `source/`
214 - for Next.js SSR, standalone output may still preserve the `source/` path inside `.next/standalone/` when tracing is rooted above the app directory
215
216 For `rails`, the CLI:
217
218 - rsyncs `shared_lib` from the monorepo root (not from `source`)
219 - runs `compile_cmd` on the server via SSH
220 - initializes a temporary git repo inside `source/` and force-pushes it
221
222 The `source` path must be a valid directory relative to where `smb deploy` is run (the monorepo root).
223
224 ## Runner types and server tiers
225
226 Each sub-project's `runner` determines which smbCloud server receives the deployment:
227
228 | Runner | Value | SSH Host | Use Case |
229 | ---------- | ----- | -------------------- | ----------------------------------------- |
230 | `NodeJs` | `0` | `api.smbcloud.xyz` | Next.js, Vite, Node.js apps |
231 | `Static` | `1` | `api.smbcloud.xyz` | Pure static sites (Nginx serves directly) |
232 | `Ruby` | `2` | `api-1.smbcloud.xyz` | Rails apps |
233 | `Swift` | `3` | `api-1.smbcloud.xyz` | Vapor apps |
234 | `Monorepo` | `255` | — | Container only, never deployed directly |
235
236 Sub-projects within the same monorepo can target different runners. The SplitFire monorepo demonstrates this: `splitfireweb` (runner 0, api.smbcloud.xyz) and `musik88web` (runner 2, api-1.smbcloud.xyz) deploy to different servers from the same repository.
237
238 ## Multi-tenant server layout
239
240 Multiple apps share the same server, each in its own directory under the deploy user's home:
241
242 ```
243 /home/git/
244 ├── apps/
245 │ └── web/
246 │ ├── splitfireweb/ # Next.js SSR (PM2: splitfire-web, port 3010)
247 │ │ ├── server.js
248 │ │ ├── .next/static/
249 │ │ └── public/
250 │ ├── connecteddeviceskarokowe/ # Vite SPA (no PM2, Nginx serves directly)
251 │ │ └── index.html
252 │ ├── ondeinference.com/ # Next.js SSR (PM2: ondeinference-web, port 3026)
253 │ │ ├── server.js
254 │ │ ├── .next/static/
255 │ │ └── public/
256 │ └── aircraftshubweb/ # Next.js SSR (PM2: aircraftshub-web, port 3022)
257 │ ├── server.js
258 │ ├── .next/static/
259 │ └── public/
260 └── musik88-production.git/ # Bare repo (Rails post-receive hook)
261 ```
262
263 Each `nextjs-ssr` app runs as a separate PM2 process on a unique port. Nginx reverse-proxies each domain to the correct port. Vite SPA and static apps have no PM2 process — Nginx serves files directly with `try_files`.
264
265 ### Port allocation
266
267 Every `nextjs-ssr` sub-project must have a unique `port` value. Ports are allocated manually and must be consistent across three places:
268
269 1. `.smb/config.toml` — the `port` field in the `[[projects]]` entry
270 2. PM2 — the `PORT` environment variable passed to `node server.js`
271 3. Nginx — the `proxy_pass` upstream address
272
273 If any of these disagree, the app will either not start, not be reachable, or serve the wrong app.
274
275 Current allocations on `api.smbcloud.xyz`:
276
277 | App | PM2 Name | Port |
278 | ----------------- | ------------------- | ---- |
279 | splitfire-web | `splitfire-web` | 3010 |
280 | aircraftshub-web | `aircraftshub-web` | 3022 |
281 | ondeinference-web | `ondeinference-web` | 3026 |
282
283 ### Nginx configuration per app type
284
285 **Next.js SSR apps** use reverse proxy. In a monorepo the safe default is to
286 proxy *everything* (including `/_next/static`) to the Node process — do not
287 serve static from disk with an `alias`, because monorepo builds are **nested**
288 (see warning below):
289
290 ```nginx
291 server {
292 server_name example.com;
293
294 location / {
295 proxy_pass http://127.0.0.1:<port>;
296 proxy_http_version 1.1;
297 proxy_set_header Host $host;
298 proxy_set_header X-Real-IP $remote_addr;
299 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
300 proxy_set_header X-Forwarded-Proto $scheme;
301 proxy_set_header Upgrade $http_upgrade;
302 proxy_set_header Connection "upgrade";
303 proxy_buffering off;
304 proxy_cache_bypass $http_upgrade;
305 }
306 }
307 ```
308
309 > **Nested-static gotcha.** A monorepo sub-project builds with
310 > `outputFileTracingRoot` pointing at the repo root, so standalone is nested and
311 > static lands at `…/<app>/<source>/.next/static` (e.g.
312 > `…/<app>/apps/web/.next/static`), **not** `…/<app>/.next/static`. The
313 > classic disk alias —
314 > `location /_next/static/ { alias /home/git/apps/web/<app>/.next/static/; }` —
315 > therefore 404s every JS/CSS chunk while HTML still renders ("page loads, no
316 > styles"). Proxy-everything avoids this entirely. If you insist on a disk alias
317 > for cache headers, point it at the nested path:
318 > `alias /home/git/apps/web/<app>/<source>/.next/static/;`.
319 > Flat single-repo builds (`source = "."`) keep static at `…/<app>/.next/static`,
320 > so an alias there is correct — but proxy-everything is the uniform choice.
321
322 **Vite SPA / static apps** use direct file serving:
323
324 ```nginx
325 server {
326 server_name example.com;
327 root /home/git/apps/web/<app>/;
328
329 location / {
330 try_files $uri $uri/ /index.html;
331 }
332 }
333 ```
334
335 Do not mix these patterns. An SSR app with `try_files` will return 404 for every server-rendered route. A static app with `proxy_pass` will fail because there is no PM2 process to proxy to.
336
337 ## CLI usage
338
339 ### Interactive mode
340
341 ```sh
342 cd my-monorepo/
343 smb deploy
344 # → "Select project to deploy"
345 # → 1. splitfireweb
346 # 2. musik88web
347 # 3. connecteddevices
348 ```
349
350 ### Direct mode (CI and scripts)
351
352 ```sh
353 smb deploy --project splitfireweb
354 smb deploy -p musik88web
355 smb deploy -p connecteddevices
356 ```
357
358 The `--project` / `-p` value must match the `name` field in a `[[projects]]` entry exactly. It is case-sensitive.
359
360 ## CI workflow parity
361
362 GitHub Actions workflows mirror the CLI deploy strategies. Each sub-project has its own workflow file triggered by a dedicated deploy branch.
363
364 ### Next.js SSR CI deploy
365
366 Trigger: push to `release/splitfire-web`
367
368 ```yaml
369 env:
370 GIT_HOST: api.smbcloud.xyz
371 GIT_USER: git
372 REMOTE_PATH: apps/web/splitfireweb
373 PM2_APP: splitfire-web
374
375 steps:
376 - name: Build
377 run: |
378 cd frontend/splitfire-web
379 pnpm install --ignore-scripts
380 pnpm build
381
382 - name: Upload
383 run: |
384 cd frontend/splitfire-web
385 rsync -az --delete --copy-links --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
386 rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
387 rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
388
389 - name: Restart
390 run: |
391 ssh $GIT_USER@$GIT_HOST bash << 'EOF'
392 set -e
393 cd ~/apps/web/splitfireweb
394 mkdir -p logs
395 if pm2 describe "splitfire-web" > /dev/null 2>&1; then
396 pm2 delete "splitfire-web"
397 fi
398 if [ -f ecosystem.config.cjs ]; then
399 pm2 start ecosystem.config.cjs --only "splitfire-web" --env production
400 elif [ -f ecosystem.config.js ]; then
401 pm2 start ecosystem.config.js --only "splitfire-web" --env production
402 else
403 PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
404 fi
405 pm2 save
406 EOF
407 ```
408
409 ### Rails CI deploy
410
411 Trigger: push to `release/backend-musik88-web`
412
413 ```yaml
414 steps:
415 - name: Upload shared lib
416 run: rsync -r ./lib $GIT_USER@$GIT_HOST:~/
417
418 - name: Compile native gem
419 run: ssh $GIT_USER@$GIT_HOST 'bash -s' < compile.sh ./lib/gems/gem_error_codes
420
421 - name: Git force-push
422 run: |
423 cd backend/musik88-web
424 git init
425 git add .
426 git commit -m "Deploy to production"
427 git remote add prod $GIT_USER@$GIT_HOST:musik88-production
428 git push --set-upstream prod main --force
429 ```
430
431 ### Vite SPA CI deploy
432
433 Trigger: push to the relevant deploy branch
434
435 ```yaml
436 steps:
437 - name: Build
438 run: |
439 cd frontend/karokowe-connected-devices
440 pnpm install
441 pnpm build
442
443 - name: Upload
444 run: |
445 cd frontend/karokowe-connected-devices
446 rsync -az --delete --mkpath dist/ $GIT_USER@$GIT_HOST:apps/web/connecteddeviceskarokowe/
447 ```
448
449 ### CI rsync flags
450
451 All CI rsync commands must include:
452
453 - `--mkpath` — creates remote directories if they do not exist (prevents failure on first deploy)
454 - `--delete` — removes stale files on the remote (prevents serving old assets)
455 - `-az` — archive mode with compression
456
457 For Next.js SSR standalone uploads, also include:
458
459 - `--copy-links` on `.next/standalone/` — dereferences `pnpm` symlinked dependencies so the server does not end up with broken `node_modules` links
460
461 ### CI PM2 restart
462
463 Current CI and CLI parity should use delete + fresh start, not restart-in-place:
464
465 - `pm2 delete <app>` if it exists
466 - `pm2 start ecosystem.config.cjs --only <app> --env production` when the `.cjs` file exists (preferred)
467 - fall back to `pm2 start ecosystem.config.js --only <app> --env production` if only the legacy `.js` file exists
468 - otherwise fall back to `PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <app> -- server.js`
469 - `pm2 save`
470
471
472 When a monorepo root `package.json` has `"type": "module"`, PM2 cannot load a `.js` ecosystem config because Node treats it as ESM. The CLI auto-migrates `.js` → `.cjs` on the server during deploy and checks `.cjs` first when starting PM2.
473
474 This matches the standalone Next.js deploy model and avoids stale PM2 command state from older deploy methods.
475
476 ## Deployment tracking
477
478 Every deploy records status in the smbCloud API regardless of strategy:
479
480 1. `POST /deployments` with `status: Started` before transferring files
481 2. `PATCH /deployments/:id` with `status: Done` on success
482 3. `PATCH /deployments/:id` with `status: Failed` on failure
483
484 Current tracking model in the CLI:
485
486 - requests still route through the umbrella workspace `id`
487 - when `frontend_app_id` is present in the deploy target config, the payload also carries it so the API can attribute the deploy to the correct app inside the repo or monorepo
488
489 For non-git deploys (rsync, nextjs-ssr, vite-spa), the `commit_hash` field is a UTC timestamp (`20250920T163305Z`) since there is no git commit on the deploy path.
490
491 ## Adding a new sub-project to a monorepo
492
493 1. Create the project in smbCloud (via `smb init` in a temporary directory, or the web console) to get an `id`
494 2. Add a `[[projects]]` entry to the monorepo's `.smb/config.toml` with the correct `kind`, `source`, `path`, `runner`, and strategy-specific fields
495 3. If `kind = "nextjs-ssr"`: allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config
496 4. If `kind = "vite-spa"`: add Nginx static file config
497 5. If `kind = "rails"`: set up a bare git repo on the server with a post-receive hook
498 6. Test with `smb deploy -p <name>`
499
500 ## Removing a sub-project
501
502 1. Remove the `[[projects]]` entry from `.smb/config.toml`
503 2. On the server: `pm2 delete <pm2_app> && pm2 save` (for SSR apps)
504 3. On the server: remove the Nginx config and `sudo nginx -t && sudo systemctl reload nginx`
505 4. On the server: remove the app directory
506
507 ## Standalone repos vs monorepo sub-projects
508
509 A standalone repo (like `aircraftshub-web` or `ondeinference.com`) has its own `.smb/config.toml` with `kind` and deploy fields directly in `[project]`. It has no `[[projects]]` array and `runner` is not `255`.
510
511 A monorepo sub-project has the same fields, but they live inside a `[[projects]]` entry. After `resolve_sub_project()`, the CLI treats them identically — all strategy code operates on `config.project` regardless of origin.
512
513 To convert a standalone repo into a monorepo sub-project:
514
515 1. Move the `[project]` fields into a `[[projects]]` entry
516 2. Create a new `[project]` with `runner = 255`
517 3. Set `source` to the sub-directory path
518 4. Adjust `path` if the remote directory structure differs
519
520 > **Deploy from the sub-project, not the leftover standalone repo.** Once an app
521 > has been folded into a monorepo (`apps/<name>`), that copy is the source of
522 > truth — deploy it with `smb deploy -p <name>`. If the old standalone repo
523 > still exists on disk it is easy to build and deploy from there by accident,
524 > which silently diverges from what the monorepo would ship. Archive the old
525 > repo. Note this also flips the build from flat to nested standalone, so the
526 > nginx `/_next/static` handling may need the nested-path / proxy-everything fix.
527
528 ## Debugging monorepo deploys
529
530 ### "No [[projects]] entries found"
531
532 The root `[project]` has `runner = 255` but there are no `[[projects]]` entries. Add at least one sub-project.
533
534 ### "Sub-project 'foo' not found in [[projects]]"
535
536 The `--project` name does not match any `[[projects]]` entry's `name` field. Check spelling and case.
537
538 ### Wrong sub-project deploys
539
540 If the interactive prompt selects the wrong project, verify the `[[projects]]` order in `.smb/config.toml` — the prompt lists them in file order.
541
542 ### Build runs in wrong directory
543
544 The `source` field is incorrect. It must be relative to the monorepo root where `smb deploy` is run.
545
546 ### Port conflict on multi-tenant server
547
548 Two `nextjs-ssr` apps have the same `port` value. PM2 will start both, but only one will bind the port. Check `pm2 list` and compare ports across all `.smb/config.toml` files that target the same server.
549
550 ### Stale assets after deploy / `/_next/static` 404s
551
552 The Nginx `alias` for `/_next/static/` (or the Vite SPA `root`) points at the
553 wrong directory. For a nested monorepo standalone build the assets live at
554 `<path>/<source>/.next/static`, not `<path>/.next/static` — see the nested-static
555 gotcha under "Nginx configuration per app type". Either proxy `/_next/static` to
556 the Node process or fix the alias to the nested path. Confirm with
557 `curl -sI https://<domain>/_next/static/...` (want 200).
558
559 ### `rsync of '.next/standalone/' failed (status 23)`
560
561 `smb deploy` aborts at the standalone upload with empty stderr; running rsync by
562 hand shows `IO error encountered -- skipping file deletion`. Cause: Next.js
563 standalone leaves a dangling pnpm symlink (e.g.
564 `node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/...` when only
565 `semver@7.x` was traced) and the CLI's `rsync --copy-links` errors trying to
566 follow it. Fix: after `next build`, before rsync, run
567 `find .next/standalone/ -type l ! -exec test -e {} \; -delete`. Proper fix is in
568 the CLI (prune dangling links before the standalone rsync). See the nextjs skill.
569
570 ### `Cannot find module 'next'` after Next.js SSR deploy
571
572 The remote app directory still contains broken `pnpm` symlinks from `.next/standalone/`, or the standalone output itself is missing traced runtime files. Use the current CLI or CI flow with `rsync --copy-links` for the standalone upload, verify `node .next/standalone/server.js` works locally, then redeploy and inspect `/home/git/apps/web/<app>/node_modules/next` on the server.
573
574 ### PM2 not picking up new environment variables
575
576 Current CLI parity is delete + fresh start. Check these in order:
577
578 1. the server's `ecosystem.config.cjs` (or legacy `.js`) was edited, not just the repo copy
579 2. the deploy script is actually starting from `ecosystem.config.cjs` — if only a `.js` file exists and `"type": "module"` is set in `package.json`, PM2 will fail; rename to `.cjs`
580 3. `pm2 save` ran after the fresh start
581
582 If you are changing env manually outside the deploy flow, `pm2 restart <app> --update-env && pm2 save` is still a valid operator command.
583
584 ## Validation
585
586 ### Config
587
588 - every `[[projects]]` entry has a unique `name`
589 - every `[[projects]]` entry has a unique `id`
590 - every `nextjs-ssr` entry has a unique `port`
591 - `source` directories exist on disk
592 - `runner` values match the intended server tier
593
594 ### Local
595
596 - `smb deploy -p <name>` completes without error for each sub-project
597 - `cargo check -p smbcloud-cli` passes after any deploy code changes
598
599 ### Server
600
601 - `pm2 list` shows expected processes with correct names and ports
602 - `sudo nginx -t` passes
603 - each domain resolves to the correct app content
604 - `ls -la /home/git/apps/web/<app>/` confirms expected files
605
606 ## Common mistakes
607
608 - setting `runner = 255` on a standalone project that has no `[[projects]]` entries
609 - forgetting to set `runner = 255` on the root `[project]` of a monorepo — the CLI treats it as a standalone project and tries to deploy the root
610 - duplicating `port` values across sub-projects on the same server
611 - using `kind = "nextjs-ssr"` on a project without `output: "standalone"` in its Next.js config
612 - running `smb deploy` from a subdirectory instead of the monorepo root — `source` paths will not resolve
613 - forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
614 - forgetting `--copy-links` on `.next/standalone/` uploads — the server receives broken `pnpm` symlinks and PM2 crashes with `Cannot find module 'next'`
615 - keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
616 - mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
617 - treating the root workspace id as the deployable app id
618 - forgetting to add `frontend_app_id` on `[[projects]]` entries once the API exposes it
619 - using `pm2 restart` without `--update-env` after changing ecosystem file variables in older restart-in-place workflows
620 - assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server