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, SvelteKit, 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`, `sveltekit`, `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 [[projects]]
126 id = <n>
127 name = "<app>"
128 repository = "<app>"
129 description = "SvelteKit app built with adapter-static."
130 source = "apps/<app>"
131 path = "apps/web/<app>"
132 runner = 0
133 kind = "sveltekit"
134 output = "build"
135 package_manager = "pnpm"
136 created_at = "2025-09-20T16:33:05.154Z"
137 updated_at = "2025-09-20T16:33:05.154Z"
138 ```
139
140 ## Sub-project field reference
141
142 | Field | Required | Description |
143 | ------------------- | ----------- | ------------------------------------------------------------------------------------------- |
144 | `id` | yes | umbrella workspace ID in smbCloud |
145 | `frontend_app_id` | recommended | FrontendApp UUID — include so deployments are attributed to the correct app |
146 | `deploy_repo_id` | recommended | DeployRepo ID — links the deploy target to its git repository record |
147 | `name` | yes | unique identifier, used with `--project` flag and in the selection prompt |
148 | `repository` | yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
149 | `description` | no | human-readable description |
150 | `source` | yes | local path to the sub-project directory, relative to the monorepo root |
151 | `path` | depends | remote directory on the server, relative to `~/`, required for `nextjs-ssr`, `vite-spa`, and `sveltekit` |
152 | `runner` | yes | server tier: `0` (NodeJs), `1` (Static), `2` (Ruby), `3` (Swift) |
153 | `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"vite-spa"`, `"sveltekit"`, `"rails"`, or omitted for generic deploy |
154 | `package_manager` | depends | `"pnpm"` or `"npm"`, used by `nextjs-ssr`, `vite-spa`, and `sveltekit`; defaults to `"pnpm"` |
155 | `pm2_app` | depends | PM2 process name — required for `nextjs-ssr` |
156 | `port` | depends | runtime port — required for `nextjs-ssr`, defaults to `3000` if omitted |
157 | `output` | depends | build output directory: `vite-spa` defaults to `"dist"`, `sveltekit` to `"build"` |
158 | `shared_lib` | no | path to shared library directory to rsync before deploy — used by `rails` |
159 | `compile_cmd` | no | SSH command to run on the server after syncing shared libs — used by `rails` |
160 | `deployment_method` | no | `0` (Git) or `1` (Rsync) — only matters when `kind` is not set |
161 | `created_at` | yes | ISO 8601 timestamp |
162 | `updated_at` | yes | ISO 8601 timestamp |
163
164 ## Deploy routing
165
166 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.
167
168 ```
169 smb deploy --project <name>
170 │
171 ▼
172 resolve_sub_project()
173 │
174 ▼
175 config.project.kind?
176 │
177 ┌────┼──────────┬──────────────┐
178 ▼ ▼ ▼ ▼
179 vite-spa nextjs-ssr rails (none)
180 sveltekit
181 │ │ │ │
182 ▼ ▼ ▼ ▼
183 pnpm pnpm rsync deployment_method?
184 build install lib/ │
185 │ + build compile ┌──┴──┐
186 ▼ │ git push Git Rsync
187 rsync ▼ │ │ │
188 dist/ rsync 3 ▼ ▼ ▼
189 dirs post- git rsync
190 │ receive push -a
191 ▼ hook
192 SSH PM2
193 restart
194 ```
195
196 ### Strategy: `nextjs-ssr`
197
198 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.
199
200 Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
201
202 ### Strategy: `vite-spa`
203
204 Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
205
206 Required fields: `kind`, `source`, `path`, `output`, `package_manager`
207
208 A plain Svelte + Vite app (no SvelteKit) is a `vite-spa`.
209
210 ### Strategy: `sveltekit`
211
212 SvelteKit built with `@sveltejs/adapter-static`. Shares the `vite-spa` ship path (`process_deploy_static_site.rs`); only the build differs:
213
214 1. Read the adapter from `package.json`. Anything other than `adapter-static` (`adapter-auto`, `adapter-node`, …) fails before building, naming the adapter. Server-rendered SvelteKit needs a Node process, which this path doesn't manage.
215 2. `<package_manager> install`, then `<package_manager> run build`, inside `source/`, with the terminal attached.
216 3. Check `output` (default `build`) has a top-level `.html` file: a prerendered `index.html` or an SPA fallback such as `200.html`.
217 4. Rsync `output/` to `path`.
218
219 Required fields: `kind`, `source`, `path`
220
221 Optional: `output` (default `"build"`, match `pages` in `adapter-static`), `package_manager` (default `"pnpm"`)
222
223 For a client-rendered app, set `fallback` in `adapter-static` and keep `paths.relative: false` in `svelte.config.js`, so deep links load assets from `/_app/` instead of a relative path. Nginx needs a matching `try_files $uri $uri/ /<fallback>;`. Use `200.html` rather than `index.html` as the fallback when `/` is prerendered, so deep links don't boot from the landing page's markup.
224
225 ### Strategy: `rails`
226
227 Rsync shared libraries, SSH compile native extensions, git force-push the sub-project directory to the server's bare repo (triggers post-receive hook).
228
229 Required fields: `kind`, `source`, `repository`, `runner = 2`
230
231 Optional: `shared_lib`, `compile_cmd`
232
233 ### Strategy: generic git or rsync
234
235 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.
236
237 ## The `source` field
238
239 The `source` field is the most important monorepo-specific field. It tells the CLI where the sub-project lives relative to the monorepo root.
240
241 For `nextjs-ssr`, `vite-spa`, and `sveltekit`, the CLI changes into the `source` directory before running build commands:
242
243 - `pnpm install` runs inside `source/`
244 - `pnpm build` runs inside `source/`
245 - rsync sources are relative to `source/`
246 - for Next.js SSR, standalone output may still preserve the `source/` path inside `.next/standalone/` when tracing is rooted above the app directory
247
248 For `rails`, the CLI:
249
250 - rsyncs `shared_lib` from the monorepo root (not from `source`)
251 - runs `compile_cmd` on the server via SSH
252 - initializes a temporary git repo inside `source/` and force-pushes it
253
254 The `source` path must be a valid directory relative to where `smb deploy` is run (the monorepo root).
255
256 ## Runner types and server tiers
257
258 Each sub-project's `runner` determines which smbCloud server receives the deployment:
259
260 | Runner | Value | SSH Host | Use Case |
261 | ---------- | ----- | -------------------- | ----------------------------------------- |
262 | `NodeJs` | `0` | `api.smbcloud.xyz` | Next.js, Vite, Node.js apps |
263 | `Static` | `1` | `api.smbcloud.xyz` | Pure static sites (Nginx serves directly) |
264 | `Ruby` | `2` | `api-1.smbcloud.xyz` | Rails apps |
265 | `Swift` | `3` | `api-1.smbcloud.xyz` | Vapor apps |
266 | `Monorepo` | `255` | — | Container only, never deployed directly |
267
268 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.
269
270 ## Multi-tenant server layout
271
272 Multiple apps share the same server, each in its own directory under the deploy user's home:
273
274 ```
275 /home/git/
276 ├── apps/
277 │ └── web/
278 │ ├── splitfireweb/ # Next.js SSR (PM2: splitfire-web, port 3010)
279 │ │ ├── server.js
280 │ │ ├── .next/static/
281 │ │ └── public/
282 │ ├── connecteddeviceskarokowe/ # Vite SPA (no PM2, Nginx serves directly)
283 │ │ └── index.html
284 │ ├── ondeinference.com/ # Next.js SSR (PM2: ondeinference-web, port 3026)
285 │ │ ├── server.js
286 │ │ ├── .next/static/
287 │ │ └── public/
288 │ └── aircraftshubweb/ # Next.js SSR (PM2: aircraftshub-web, port 3022)
289 │ ├── server.js
290 │ ├── .next/static/
291 │ └── public/
292 └── musik88-production.git/ # Bare repo (Rails post-receive hook)
293 ```
294
295 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`.
296
297 ### Port allocation
298
299 Every `nextjs-ssr` sub-project must have a unique `port` value. Ports are allocated manually and must be consistent across three places:
300
301 1. `.smb/config.toml` — the `port` field in the `[[projects]]` entry
302 2. PM2 — the `PORT` environment variable passed to `node server.js`
303 3. Nginx — the `proxy_pass` upstream address
304
305 If any of these disagree, the app will either not start, not be reachable, or serve the wrong app.
306
307 Current allocations on `api.smbcloud.xyz`:
308
309 | App | PM2 Name | Port |
310 | ----------------- | ------------------- | ---- |
311 | splitfire-web | `splitfire-web` | 3010 |
312 | aircraftshub-web | `aircraftshub-web` | 3022 |
313 | ondeinference-web | `ondeinference-web` | 3026 |
314
315 ### Nginx configuration per app type
316
317 **Next.js SSR apps** use reverse proxy. In a monorepo the safe default is to
318 proxy *everything* (including `/_next/static`) to the Node process — do not
319 serve static from disk with an `alias`, because monorepo builds are **nested**
320 (see warning below):
321
322 ```nginx
323 server {
324 server_name example.com;
325
326 location / {
327 proxy_pass http://127.0.0.1:<port>;
328 proxy_http_version 1.1;
329 proxy_set_header Host $host;
330 proxy_set_header X-Real-IP $remote_addr;
331 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
332 proxy_set_header X-Forwarded-Proto $scheme;
333 proxy_set_header Upgrade $http_upgrade;
334 proxy_set_header Connection "upgrade";
335 proxy_buffering off;
336 proxy_cache_bypass $http_upgrade;
337 }
338 }
339 ```
340
341 > **Nested-static gotcha.** A monorepo sub-project builds with
342 > `outputFileTracingRoot` pointing at the repo root, so standalone is nested and
343 > static lands at `…/<app>/<source>/.next/static` (e.g.
344 > `…/<app>/apps/web/.next/static`), **not** `…/<app>/.next/static`. The
345 > classic disk alias —
346 > `location /_next/static/ { alias /home/git/apps/web/<app>/.next/static/; }` —
347 > therefore 404s every JS/CSS chunk while HTML still renders ("page loads, no
348 > styles"). Proxy-everything avoids this entirely. If you insist on a disk alias
349 > for cache headers, point it at the nested path:
350 > `alias /home/git/apps/web/<app>/<source>/.next/static/;`.
351 > Flat single-repo builds (`source = "."`) keep static at `…/<app>/.next/static`,
352 > so an alias there is correct — but proxy-everything is the uniform choice.
353
354 **Vite SPA / static apps** use direct file serving:
355
356 ```nginx
357 server {
358 server_name example.com;
359 root /home/git/apps/web/<app>/;
360
361 location / {
362 try_files $uri $uri/ /index.html;
363 }
364 }
365 ```
366
367 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.
368
369 ## CLI usage
370
371 ### Interactive mode
372
373 ```sh
374 cd my-monorepo/
375 smb deploy
376 # → "Select project to deploy"
377 # → 1. splitfireweb
378 # 2. musik88web
379 # 3. connecteddevices
380 ```
381
382 ### Direct mode (CI and scripts)
383
384 ```sh
385 smb deploy --project splitfireweb
386 smb deploy -p musik88web
387 smb deploy -p connecteddevices
388 ```
389
390 The `--project` / `-p` value must match the `name` field in a `[[projects]]` entry exactly. It is case-sensitive.
391
392 ## CI workflow parity
393
394 GitHub Actions workflows mirror the CLI deploy strategies. Each sub-project has its own workflow file triggered by a dedicated deploy branch.
395
396 ### Next.js SSR CI deploy
397
398 Trigger: push to `release/splitfire-web`
399
400 ```yaml
401 env:
402 GIT_HOST: api.smbcloud.xyz
403 GIT_USER: git
404 REMOTE_PATH: apps/web/splitfireweb
405 PM2_APP: splitfire-web
406
407 steps:
408 - name: Build
409 run: |
410 cd frontend/splitfire-web
411 pnpm install --ignore-scripts
412 pnpm build
413
414 - name: Upload
415 run: |
416 cd frontend/splitfire-web
417 rsync -az --delete --copy-links --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
418 rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
419 rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
420
421 - name: Restart
422 run: |
423 ssh $GIT_USER@$GIT_HOST bash << 'EOF'
424 set -e
425 cd ~/apps/web/splitfireweb
426 mkdir -p logs
427 if pm2 describe "splitfire-web" > /dev/null 2>&1; then
428 pm2 delete "splitfire-web"
429 fi
430 if [ -f ecosystem.config.cjs ]; then
431 pm2 start ecosystem.config.cjs --only "splitfire-web" --env production
432 elif [ -f ecosystem.config.js ]; then
433 pm2 start ecosystem.config.js --only "splitfire-web" --env production
434 else
435 PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
436 fi
437 pm2 save
438 EOF
439 ```
440
441 ### Rails CI deploy
442
443 Trigger: push to `release/backend-musik88-web`
444
445 ```yaml
446 steps:
447 - name: Upload shared lib
448 run: rsync -r ./lib $GIT_USER@$GIT_HOST:~/
449
450 - name: Compile native gem
451 run: ssh $GIT_USER@$GIT_HOST 'bash -s' < compile.sh ./lib/gems/gem_error_codes
452
453 - name: Git force-push
454 run: |
455 cd backend/musik88-web
456 git init
457 git add .
458 git commit -m "Deploy to production"
459 git remote add prod $GIT_USER@$GIT_HOST:musik88-production
460 git push --set-upstream prod main --force
461 ```
462
463 ### Vite SPA CI deploy
464
465 Trigger: push to the relevant deploy branch
466
467 ```yaml
468 steps:
469 - name: Build
470 run: |
471 cd frontend/karokowe-connected-devices
472 pnpm install
473 pnpm build
474
475 - name: Upload
476 run: |
477 cd frontend/karokowe-connected-devices
478 rsync -az --delete --mkpath dist/ $GIT_USER@$GIT_HOST:apps/web/connecteddeviceskarokowe/
479 ```
480
481 ### CI rsync flags
482
483 All CI rsync commands must include:
484
485 - `--mkpath` — creates remote directories if they do not exist (prevents failure on first deploy)
486 - `--delete` — removes stale files on the remote (prevents serving old assets)
487 - `-az` — archive mode with compression
488
489 For Next.js SSR standalone uploads, also include:
490
491 - `--copy-links` on `.next/standalone/` — dereferences `pnpm` symlinked dependencies so the server does not end up with broken `node_modules` links
492
493 ### CI PM2 restart
494
495 Current CI and CLI parity should use delete + fresh start, not restart-in-place:
496
497 - `pm2 delete <app>` if it exists
498 - `pm2 start ecosystem.config.cjs --only <app> --env production` when the `.cjs` file exists (preferred)
499 - fall back to `pm2 start ecosystem.config.js --only <app> --env production` if only the legacy `.js` file exists
500 - otherwise fall back to `PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <app> -- server.js`
501 - `pm2 save`
502
503
504 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.
505
506 This matches the standalone Next.js deploy model and avoids stale PM2 command state from older deploy methods.
507
508 ## Deployment tracking
509
510 Every deploy records status in the smbCloud API regardless of strategy:
511
512 1. `POST /deployments` with `status: Started` before transferring files
513 2. `PATCH /deployments/:id` with `status: Done` on success
514 3. `PATCH /deployments/:id` with `status: Failed` on failure
515
516 Current tracking model in the CLI:
517
518 - requests still route through the umbrella workspace `id`
519 - 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
520
521 For non-git deploys (rsync, nextjs-ssr, vite-spa, sveltekit), the `commit_hash` field is a UTC timestamp (`20250920T163305Z`) since there is no git commit on the deploy path.
522
523 ## Adding a new sub-project to a monorepo
524
525 1. Create the project in smbCloud (via `smb init` in a temporary directory, or the web console) to get an `id`
526 2. Add a `[[projects]]` entry to the monorepo's `.smb/config.toml` with the correct `kind`, `source`, `path`, `runner`, and strategy-specific fields
527 3. If `kind = "nextjs-ssr"`: allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config
528 4. If `kind = "vite-spa"` or `kind = "sveltekit"`: add Nginx static file config (with an SPA fallback `try_files` for client-rendered SvelteKit)
529 5. If `kind = "rails"`: set up a bare git repo on the server with a post-receive hook
530 6. Test with `smb deploy -p <name>`
531
532 ## Removing a sub-project
533
534 1. Remove the `[[projects]]` entry from `.smb/config.toml`
535 2. On the server: `pm2 delete <pm2_app> && pm2 save` (for SSR apps)
536 3. On the server: remove the Nginx config and `sudo nginx -t && sudo systemctl reload nginx`
537 4. On the server: remove the app directory
538
539 ## Standalone repos vs monorepo sub-projects
540
541 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`.
542
543 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.
544
545 To convert a standalone repo into a monorepo sub-project:
546
547 1. Move the `[project]` fields into a `[[projects]]` entry
548 2. Create a new `[project]` with `runner = 255`
549 3. Set `source` to the sub-directory path
550 4. Adjust `path` if the remote directory structure differs
551
552 > **Deploy from the sub-project, not the leftover standalone repo.** Once an app
553 > has been folded into a monorepo (`apps/<name>`), that copy is the source of
554 > truth — deploy it with `smb deploy -p <name>`. If the old standalone repo
555 > still exists on disk it is easy to build and deploy from there by accident,
556 > which silently diverges from what the monorepo would ship. Archive the old
557 > repo. Note this also flips the build from flat to nested standalone, so the
558 > nginx `/_next/static` handling may need the nested-path / proxy-everything fix.
559
560 ## Debugging monorepo deploys
561
562 ### "No [[projects]] entries found"
563
564 The root `[project]` has `runner = 255` but there are no `[[projects]]` entries. Add at least one sub-project.
565
566 ### "Sub-project 'foo' not found in [[projects]]"
567
568 The `--project` name does not match any `[[projects]]` entry's `name` field. Check spelling and case.
569
570 ### Wrong sub-project deploys
571
572 If the interactive prompt selects the wrong project, verify the `[[projects]]` order in `.smb/config.toml` — the prompt lists them in file order.
573
574 ### Build runs in wrong directory
575
576 The `source` field is incorrect. It must be relative to the monorepo root where `smb deploy` is run.
577
578 ### Port conflict on multi-tenant server
579
580 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.
581
582 ### Stale assets after deploy / `/_next/static` 404s
583
584 The Nginx `alias` for `/_next/static/` (or the Vite SPA `root`) points at the
585 wrong directory. For a nested monorepo standalone build the assets live at
586 `<path>/<source>/.next/static`, not `<path>/.next/static` — see the nested-static
587 gotcha under "Nginx configuration per app type". Either proxy `/_next/static` to
588 the Node process or fix the alias to the nested path. Confirm with
589 `curl -sI https://<domain>/_next/static/...` (want 200).
590
591 ### `rsync of '.next/standalone/' failed (status 23)`
592
593 `smb deploy` aborts at the standalone upload with empty stderr; running rsync by
594 hand shows `IO error encountered -- skipping file deletion`. Cause: Next.js
595 standalone leaves a dangling pnpm symlink (e.g.
596 `node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/...` when only
597 `semver@7.x` was traced) and the CLI's `rsync --copy-links` errors trying to
598 follow it. Fix: after `next build`, before rsync, run
599 `find .next/standalone/ -type l ! -exec test -e {} \; -delete`. Proper fix is in
600 the CLI (prune dangling links before the standalone rsync). See the nextjs skill.
601
602 ### `Cannot find module 'next'` after Next.js SSR deploy
603
604 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.
605
606 ### PM2 not picking up new environment variables
607
608 Current CLI parity is delete + fresh start. Check these in order:
609
610 1. the server's `ecosystem.config.cjs` (or legacy `.js`) was edited, not just the repo copy
611 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`
612 3. `pm2 save` ran after the fresh start
613
614 If you are changing env manually outside the deploy flow, `pm2 restart <app> --update-env && pm2 save` is still a valid operator command.
615
616 ## Validation
617
618 ### Config
619
620 - every `[[projects]]` entry has a unique `name`
621 - every `[[projects]]` entry has a unique `id`
622 - every `nextjs-ssr` entry has a unique `port`
623 - `source` directories exist on disk
624 - `runner` values match the intended server tier
625
626 ### Local
627
628 - `smb deploy -p <name>` completes without error for each sub-project
629 - `cargo check -p smbcloud-cli` passes after any deploy code changes
630
631 ### Server
632
633 - `pm2 list` shows expected processes with correct names and ports
634 - `sudo nginx -t` passes
635 - each domain resolves to the correct app content
636 - `ls -la /home/git/apps/web/<app>/` confirms expected files
637
638 ## Common mistakes
639
640 - setting `runner = 255` on a standalone project that has no `[[projects]]` entries
641 - 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
642 - duplicating `port` values across sub-projects on the same server
643 - using `kind = "nextjs-ssr"` on a project without `output: "standalone"` in its Next.js config
644 - using `kind = "sveltekit"` with `adapter-auto` or `adapter-node`; switch `svelte.config.js` to `adapter-static` instead
645 - running `smb deploy` from a subdirectory instead of the monorepo root — `source` paths will not resolve
646 - forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
647 - forgetting `--copy-links` on `.next/standalone/` uploads — the server receives broken `pnpm` symlinks and PM2 crashes with `Cannot find module 'next'`
648 - keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
649 - mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
650 - treating the root workspace id as the deployable app id
651 - forgetting to add `frontend_app_id` on `[[projects]]` entries once the API exposes it
652 - using `pm2 restart` without `--update-env` after changing ecosystem file variables in older restart-in-place workflows
653 - assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server