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 sub-projects are listed as `[[projects]]` entries. The root project is never deployed directly — it is a container.
22
23 The CLI detects this in `process_deploy.rs`:
24
25 ```rust
26 let resolved_name = match project_name {
27 Some(name) => Some(name),
28 None if config.project.runner == Runner::Monorepo => Some(prompt_select_project(&config)?),
29 None => None,
30 };
31 ```
32
33 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.
34
35 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.
36
37 ## Config structure
38
39 A monorepo `.smb/config.toml` has two layers:
40
41 ### Root project (container)
42
43 ```toml
44 name = "splitfire"
45 description = "SplitFire AI monorepo."
46
47 [project]
48 id = 38
49 name = "splitfire"
50 repository = "splitfire"
51 runner = 255
52 description = "SplitFire AI monorepo."
53 created_at = "2025-09-20T16:33:05.154Z"
54 updated_at = "2025-09-20T16:33:05.154Z"
55 ```
56
57 Key rules:
58
59 - `runner = 255` is mandatory — this is what activates monorepo behavior
60 - `id` must be a valid smbCloud project ID (created via `smb init` or the web console)
61 - the root project has no `kind`, `source`, `path`, or `pm2_app` — it is not deployable
62
63 ### Sub-projects (deployable units)
64
65 Each `[[projects]]` entry is a self-contained deploy target with its own `kind`, `source`, `path`, `runner`, and strategy-specific fields.
66
67 ```toml
68 [[projects]]
69 id = 54
70 name = "splitfireweb"
71 repository = "splitfireweb"
72 description = "SplitFire AI website."
73 source = "frontend/splitfire-web"
74 path = "apps/web/splitfireweb"
75 runner = 0
76 kind = "nextjs-ssr"
77 package_manager = "pnpm"
78 pm2_app = "splitfire-web"
79 port = 3010
80 created_at = "2025-09-20T16:33:05.154Z"
81 updated_at = "2025-09-20T16:33:05.154Z"
82
83 [[projects]]
84 id = 98
85 name = "musik88web"
86 repository = "musik88-production"
87 description = "Musik88 web or SplitFire AI API."
88 source = "backend/musik88-web"
89 runner = 2
90 kind = "rails"
91 shared_lib = "lib"
92 compile_cmd = "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile"
93 created_at = "2025-09-20T16:33:05.154Z"
94 updated_at = "2025-09-20T16:33:05.154Z"
95
96 [[projects]]
97 id = 53
98 name = "connecteddevices"
99 repository = "connecteddevices"
100 description = "KaroKowe connected devices TV web app."
101 source = "frontend/karokowe-connected-devices"
102 path = "apps/web/connecteddeviceskarokowe"
103 runner = 0
104 kind = "vite-spa"
105 output = "dist"
106 package_manager = "pnpm"
107 created_at = "2025-09-20T16:33:05.154Z"
108 updated_at = "2025-09-20T16:33:05.154Z"
109 ```
110
111 ## Sub-project field reference
112
113 | Field | Required | Description |
114 | ------------------- | -------- | ------------------------------------------------------------------------------------------- |
115 | `id` | yes | smbCloud project ID |
116 | `name` | yes | unique identifier, used with `--project` flag and in the selection prompt |
117 | `repository` | yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
118 | `description` | no | human-readable description |
119 | `source` | yes | local path to the sub-project directory, relative to the monorepo root |
120 | `path` | depends | remote directory on the server, relative to `~/` — required for `nextjs-ssr` and `vite-spa` |
121 | `runner` | yes | server tier: `0` (NodeJs), `1` (Static), `2` (Ruby), `3` (Swift) |
122 | `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"vite-spa"`, `"rails"`, or omitted for generic deploy |
123 | `package_manager` | depends | `"pnpm"` or `"npm"` — required for `nextjs-ssr` and `vite-spa` |
124 | `pm2_app` | depends | PM2 process name — required for `nextjs-ssr` |
125 | `port` | depends | runtime port — required for `nextjs-ssr`, defaults to `3000` if omitted |
126 | `output` | depends | build output directory — required for `vite-spa`, typically `"dist"` |
127 | `shared_lib` | no | path to shared library directory to rsync before deploy — used by `rails` |
128 | `compile_cmd` | no | SSH command to run on the server after syncing shared libs — used by `rails` |
129 | `deployment_method` | no | `0` (Git) or `1` (Rsync) — only matters when `kind` is not set |
130 | `created_at` | yes | ISO 8601 timestamp |
131 | `updated_at` | yes | ISO 8601 timestamp |
132
133 ## Deploy routing
134
135 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.
136
137 ```
138 smb deploy --project <name>
139 │
140 ▼
141 resolve_sub_project()
142 │
143 ▼
144 config.project.kind?
145 │
146 ┌────┼──────────┬──────────────┐
147 ▼ ▼ ▼ ▼
148 vite-spa nextjs-ssr rails (none)
149 │ │ │ │
150 ▼ ▼ ▼ ▼
151 pnpm pnpm rsync deployment_method?
152 build install lib/ │
153 │ + build compile ┌──┴──┐
154 ▼ │ git push Git Rsync
155 rsync ▼ │ │ │
156 dist/ rsync 3 ▼ ▼ ▼
157 dirs post- git rsync
158 │ receive push -a
159 ▼ hook
160 SSH PM2
161 restart
162 ```
163
164 ### Strategy: `nextjs-ssr`
165
166 Local build, rsync three directories, SSH PM2 restart. See `smbcloud-deploy-nextjs` skill for details.
167
168 Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
169
170 ### Strategy: `vite-spa`
171
172 Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
173
174 Required fields: `kind`, `source`, `path`, `output`, `package_manager`
175
176 ### Strategy: `rails`
177
178 Rsync shared libraries, SSH compile native extensions, git force-push the sub-project directory to the server's bare repo (triggers post-receive hook).
179
180 Required fields: `kind`, `source`, `repository`, `runner = 2`
181
182 Optional: `shared_lib`, `compile_cmd`
183
184 ### Strategy: generic git or rsync
185
186 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.
187
188 ## The `source` field
189
190 The `source` field is the most important monorepo-specific field. It tells the CLI where the sub-project lives relative to the monorepo root.
191
192 For `nextjs-ssr` and `vite-spa`, the CLI changes into the `source` directory before running build commands:
193
194 - `pnpm install` runs inside `source/`
195 - `pnpm build` runs inside `source/`
196 - rsync sources are relative to `source/`
197
198 For `rails`, the CLI:
199
200 - rsyncs `shared_lib` from the monorepo root (not from `source`)
201 - runs `compile_cmd` on the server via SSH
202 - initializes a temporary git repo inside `source/` and force-pushes it
203
204 The `source` path must be a valid directory relative to where `smb deploy` is run (the monorepo root).
205
206 ## Runner types and server tiers
207
208 Each sub-project's `runner` determines which smbCloud server receives the deployment:
209
210 | Runner | Value | SSH Host | Use Case |
211 | ---------- | ----- | -------------------- | ----------------------------------------- |
212 | `NodeJs` | `0` | `api.smbcloud.xyz` | Next.js, Vite, Node.js apps |
213 | `Static` | `1` | `api.smbcloud.xyz` | Pure static sites (Nginx serves directly) |
214 | `Ruby` | `2` | `api-1.smbcloud.xyz` | Rails apps |
215 | `Swift` | `3` | `api-1.smbcloud.xyz` | Vapor apps |
216 | `Monorepo` | `255` | — | Container only, never deployed directly |
217
218 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.
219
220 ## Multi-tenant server layout
221
222 Multiple apps share the same server, each in its own directory under the deploy user's home:
223
224 ```
225 /home/git/
226 ├── apps/
227 │ └── web/
228 │ ├── splitfireweb/ # Next.js SSR (PM2: splitfire-web, port 3010)
229 │ │ ├── server.js
230 │ │ ├── .next/static/
231 │ │ └── public/
232 │ ├── connecteddeviceskarokowe/ # Vite SPA (no PM2, Nginx serves directly)
233 │ │ └── index.html
234 │ ├── ondeinference.com/ # Next.js SSR (PM2: ondeinference-web, port 3026)
235 │ │ ├── server.js
236 │ │ ├── .next/static/
237 │ │ └── public/
238 │ └── aircraftshubweb/ # Next.js SSR (PM2: aircraftshub-web, port 3022)
239 │ ├── server.js
240 │ ├── .next/static/
241 │ └── public/
242 └── musik88-production.git/ # Bare repo (Rails post-receive hook)
243 ```
244
245 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`.
246
247 ### Port allocation
248
249 Every `nextjs-ssr` sub-project must have a unique `port` value. Ports are allocated manually and must be consistent across three places:
250
251 1. `.smb/config.toml` — the `port` field in the `[[projects]]` entry
252 2. PM2 — the `PORT` environment variable passed to `node server.js`
253 3. Nginx — the `proxy_pass` upstream address
254
255 If any of these disagree, the app will either not start, not be reachable, or serve the wrong app.
256
257 Current allocations on `api.smbcloud.xyz`:
258
259 | App | PM2 Name | Port |
260 | ----------------- | ------------------- | ---- |
261 | splitfire-web | `splitfire-web` | 3010 |
262 | aircraftshub-web | `aircraftshub-web` | 3022 |
263 | ondeinference-web | `ondeinference-web` | 3026 |
264
265 ### Nginx configuration per app type
266
267 **Next.js SSR apps** use reverse proxy:
268
269 ```nginx
270 upstream app_name {
271 server 127.0.0.1:<port>;
272 keepalive 64;
273 }
274
275 server {
276 server_name example.com;
277
278 location /_next/static/ {
279 alias /home/git/apps/web/<app>/.next/static/;
280 expires max;
281 add_header Cache-Control "public, max-age=31536000, immutable";
282 access_log off;
283 }
284
285 location / {
286 proxy_pass http://app_name;
287 proxy_http_version 1.1;
288 proxy_set_header Host $host;
289 proxy_set_header X-Real-IP $remote_addr;
290 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
291 proxy_set_header X-Forwarded-Proto $scheme;
292 proxy_set_header Upgrade $http_upgrade;
293 proxy_set_header Connection $connection_upgrade;
294 proxy_buffering off;
295 }
296 }
297 ```
298
299 **Vite SPA / static apps** use direct file serving:
300
301 ```nginx
302 server {
303 server_name example.com;
304 root /home/git/apps/web/<app>/;
305
306 location / {
307 try_files $uri $uri/ /index.html;
308 }
309 }
310 ```
311
312 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.
313
314 ## CLI usage
315
316 ### Interactive mode
317
318 ```sh
319 cd my-monorepo/
320 smb deploy
321 # → "Select project to deploy"
322 # → 1. splitfireweb
323 # 2. musik88web
324 # 3. connecteddevices
325 ```
326
327 ### Direct mode (CI and scripts)
328
329 ```sh
330 smb deploy --project splitfireweb
331 smb deploy -p musik88web
332 smb deploy -p connecteddevices
333 ```
334
335 The `--project` / `-p` value must match the `name` field in a `[[projects]]` entry exactly. It is case-sensitive.
336
337 ## CI workflow parity
338
339 GitHub Actions workflows mirror the CLI deploy strategies. Each sub-project has its own workflow file triggered by a dedicated deploy branch.
340
341 ### Next.js SSR CI deploy
342
343 Trigger: push to `release/splitfire-web`
344
345 ```yaml
346 env:
347 GIT_HOST: api.smbcloud.xyz
348 GIT_USER: git
349 REMOTE_PATH: apps/web/splitfireweb
350 PM2_APP: splitfire-web
351
352 steps:
353 - name: Build
354 run: |
355 cd frontend/splitfire-web
356 pnpm install --ignore-scripts
357 pnpm build
358
359 - name: Upload
360 run: |
361 cd frontend/splitfire-web
362 rsync -az --delete --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
363 rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
364 rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
365
366 - name: Restart
367 run: |
368 ssh $GIT_USER@$GIT_HOST bash << 'EOF'
369 set -e
370 cd ~/apps/web/splitfireweb
371 if pm2 describe "splitfire-web" > /dev/null 2>&1; then
372 pm2 restart "splitfire-web" --update-env
373 else
374 PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
375 fi
376 pm2 save
377 EOF
378 ```
379
380 ### Rails CI deploy
381
382 Trigger: push to `release/backend-musik88-web`
383
384 ```yaml
385 steps:
386 - name: Upload shared lib
387 run: rsync -r ./lib $GIT_USER@$GIT_HOST:~/
388
389 - name: Compile native gem
390 run: ssh $GIT_USER@$GIT_HOST 'bash -s' < compile.sh ./lib/gems/gem_error_codes
391
392 - name: Git force-push
393 run: |
394 cd backend/musik88-web
395 git init
396 git add .
397 git commit -m "Deploy to production"
398 git remote add prod $GIT_USER@$GIT_HOST:musik88-production
399 git push --set-upstream prod main --force
400 ```
401
402 ### Vite SPA CI deploy
403
404 Trigger: push to the relevant deploy branch
405
406 ```yaml
407 steps:
408 - name: Build
409 run: |
410 cd frontend/karokowe-connected-devices
411 pnpm install
412 pnpm build
413
414 - name: Upload
415 run: |
416 cd frontend/karokowe-connected-devices
417 rsync -az --delete --mkpath dist/ $GIT_USER@$GIT_HOST:apps/web/connecteddeviceskarokowe/
418 ```
419
420 ### CI rsync flags
421
422 All CI rsync commands must include:
423
424 - `--mkpath` — creates remote directories if they do not exist (prevents failure on first deploy)
425 - `--delete` — removes stale files on the remote (prevents serving old assets)
426 - `-az` — archive mode with compression
427
428 ### CI PM2 restart
429
430 The CI restart command must use `pm2 restart <app> --update-env` on subsequent deploys so environment variable changes from the ecosystem file are picked up. Without `--update-env`, PM2 keeps the environment from the original `pm2 start` invocation.
431
432 ## Deployment tracking
433
434 Every deploy records status in the smbCloud API regardless of strategy:
435
436 1. `POST /deployments` with `status: Started` before transferring files
437 2. `PATCH /deployments/:id` with `status: Done` on success
438 3. `PATCH /deployments/:id` with `status: Failed` on failure
439
440 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.
441
442 ## Adding a new sub-project to a monorepo
443
444 1. Create the project in smbCloud (via `smb init` in a temporary directory, or the web console) to get an `id`
445 2. Add a `[[projects]]` entry to the monorepo's `.smb/config.toml` with the correct `kind`, `source`, `path`, `runner`, and strategy-specific fields
446 3. If `kind = "nextjs-ssr"`: allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config
447 4. If `kind = "vite-spa"`: add Nginx static file config
448 5. If `kind = "rails"`: set up a bare git repo on the server with a post-receive hook
449 6. Test with `smb deploy -p <name>`
450
451 ## Removing a sub-project
452
453 1. Remove the `[[projects]]` entry from `.smb/config.toml`
454 2. On the server: `pm2 delete <pm2_app> && pm2 save` (for SSR apps)
455 3. On the server: remove the Nginx config and `sudo nginx -t && sudo systemctl reload nginx`
456 4. On the server: remove the app directory
457
458 ## Standalone repos vs monorepo sub-projects
459
460 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`.
461
462 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.
463
464 To convert a standalone repo into a monorepo sub-project:
465
466 1. Move the `[project]` fields into a `[[projects]]` entry
467 2. Create a new `[project]` with `runner = 255`
468 3. Set `source` to the sub-directory path
469 4. Adjust `path` if the remote directory structure differs
470
471 ## Debugging monorepo deploys
472
473 ### "No [[projects]] entries found"
474
475 The root `[project]` has `runner = 255` but there are no `[[projects]]` entries. Add at least one sub-project.
476
477 ### "Sub-project 'foo' not found in [[projects]]"
478
479 The `--project` name does not match any `[[projects]]` entry's `name` field. Check spelling and case.
480
481 ### Wrong sub-project deploys
482
483 If the interactive prompt selects the wrong project, verify the `[[projects]]` order in `.smb/config.toml` — the prompt lists them in file order.
484
485 ### Build runs in wrong directory
486
487 The `source` field is incorrect. It must be relative to the monorepo root where `smb deploy` is run.
488
489 ### Port conflict on multi-tenant server
490
491 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.
492
493 ### Stale assets after deploy
494
495 The Nginx `alias` directive for `/_next/static/` or the `root` for a Vite SPA points to the wrong directory or a stale deploy path. Verify the Nginx config matches the `path` field in `.smb/config.toml`.
496
497 ### PM2 not picking up new environment variables
498
499 Use `pm2 restart <app> --update-env && pm2 save`. Without `--update-env`, PM2 reuses the environment from the original start.
500
501 ## Validation
502
503 ### Config
504
505 - every `[[projects]]` entry has a unique `name`
506 - every `[[projects]]` entry has a unique `id`
507 - every `nextjs-ssr` entry has a unique `port`
508 - `source` directories exist on disk
509 - `runner` values match the intended server tier
510
511 ### Local
512
513 - `smb deploy -p <name>` completes without error for each sub-project
514 - `cargo check -p smbcloud-cli` passes after any deploy code changes
515
516 ### Server
517
518 - `pm2 list` shows expected processes with correct names and ports
519 - `sudo nginx -t` passes
520 - each domain resolves to the correct app content
521 - `ls -la /home/git/apps/web/<app>/` confirms expected files
522
523 ## Common mistakes
524
525 - setting `runner = 255` on a standalone project that has no `[[projects]]` entries
526 - 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
527 - duplicating `port` values across sub-projects on the same server
528 - using `kind = "nextjs-ssr"` on a project without `output: "standalone"` in its Next.js config
529 - running `smb deploy` from a subdirectory instead of the monorepo root — `source` paths will not resolve
530 - forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
531 - keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
532 - mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
533 - using `pm2 restart` without `--update-env` after changing ecosystem file variables
534 - assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server