release/v0.7.4
name: smbcloud-deploy-monorepo
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.
smbCloud Deploy Monorepo
Use this skill when work touches monorepo deployment on smbCloud.
Applies to:
- repositories with
runner = 255in[project] .smb/config.tomlfiles with[[projects]]arraysprocess_deploy.rsmonorepo resolution logic- sub-project routing to
nextjs-ssr,vite-spa,sveltekit,rails, rsync, or git deploy paths - multi-tenant PM2 and Nginx server configuration
- CI workflows that deploy individual sub-projects from a monorepo
What makes a monorepo
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.
Keep the deploy hierarchy straight:
- root
[project]= umbrella workspace in smbCloud (not deployable) - repo = the monorepo itself, represented by a
DeployReporecord - each
[[projects]]entry = one deployableFrontendAppinside that repo, identified byfrontend_app_id - each deployment record = one release of one
FrontendApp, linked viafrontend_app_id
The canonical chain is: Deployment → FrontendApp → DeployRepo → Project
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.
The CLI detects this in process_deploy.rs:
let resolved_name = match project_name {
Some(name) => Some(name),
None if config.project.runner == Runner::Monorepo => Some(prompt_select_project(&config)?),
None => None,
};
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.
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.
Config structure
A monorepo .smb/config.toml has two layers:
Root project (container)
name = "splitfire"
description = "SplitFire AI monorepo."
[project]
id = 38
name = "splitfire"
repository = "splitfire"
runner = 255
description = "SplitFire AI monorepo."
created_at = "2025-09-20T16:33:05.154Z"
updated_at = "2025-09-20T16:33:05.154Z"
The root id is the workspace ID. It is not a deployable app ID.
Key rules:
runner = 255is mandatory — this is what activates monorepo behavioridmust be a valid smbCloud project ID (created viasmb initor the web console)- the root project has no
kind,source,path, orpm2_app— it is not deployable
Sub-projects (deployable units)
Each [[projects]] entry is a self-contained deployable app target with its own kind, source, path, runner, and strategy-specific fields.
[[projects]]
id = 38
frontend_app_id = "a7f6d5d4-..."
deploy_repo_id = 12
name = "splitfireweb"
repository = "splitfireweb"
description = "SplitFire AI website."
source = "frontend/splitfire-web"
path = "apps/web/splitfireweb"
runner = 0
kind = "nextjs-ssr"
package_manager = "pnpm"
pm2_app = "splitfire-web"
port = 3010
created_at = "2025-09-20T16:33:05.154Z"
updated_at = "2025-09-20T16:33:05.154Z"
[[projects]]
id = 98
name = "musik88web"
repository = "musik88-production"
description = "Musik88 web or SplitFire AI API."
source = "backend/musik88-web"
runner = 2
kind = "rails"
shared_lib = "lib"
compile_cmd = "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile"
created_at = "2025-09-20T16:33:05.154Z"
updated_at = "2025-09-20T16:33:05.154Z"
[[projects]]
id = 53
name = "connecteddevices"
repository = "connecteddevices"
description = "KaroKowe connected devices TV web app."
source = "frontend/karokowe-connected-devices"
path = "apps/web/connecteddeviceskarokowe"
runner = 0
kind = "vite-spa"
output = "dist"
package_manager = "pnpm"
created_at = "2025-09-20T16:33:05.154Z"
updated_at = "2025-09-20T16:33:05.154Z"
[[projects]]
id = <n>
name = "<app>"
repository = "<app>"
description = "SvelteKit app built with adapter-static."
source = "apps/<app>"
path = "apps/web/<app>"
runner = 0
kind = "sveltekit"
output = "build"
package_manager = "pnpm"
created_at = "2025-09-20T16:33:05.154Z"
updated_at = "2025-09-20T16:33:05.154Z"
Sub-project field reference
| Field | Required | Description |
|---|---|---|
id |
yes | umbrella workspace ID in smbCloud |
frontend_app_id |
recommended | FrontendApp UUID — include so deployments are attributed to the correct app |
deploy_repo_id |
recommended | DeployRepo ID — links the deploy target to its git repository record |
name |
yes | unique identifier, used with --project flag and in the selection prompt |
repository |
yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
description |
no | human-readable description |
source |
yes | local path to the sub-project directory, relative to the monorepo root |
path |
depends | remote directory on the server, relative to ~/, required for nextjs-ssr, vite-spa, and sveltekit |
runner |
yes | server tier: 0 (NodeJs), 1 (Static), 2 (Ruby), 3 (Swift) |
kind |
depends | deploy strategy: "nextjs-ssr", "vite-spa", "sveltekit", "rails", or omitted for generic deploy |
package_manager |
depends | "pnpm" or "npm", used by nextjs-ssr, vite-spa, and sveltekit; defaults to "pnpm" |
pm2_app |
depends | PM2 process name — required for nextjs-ssr |
port |
depends | runtime port — required for nextjs-ssr, defaults to 3000 if omitted |
output |
depends | build output directory: vite-spa defaults to "dist", sveltekit to "build" |
shared_lib |
no | path to shared library directory to rsync before deploy — used by rails |
compile_cmd |
no | SSH command to run on the server after syncing shared libs — used by rails |
deployment_method |
no | 0 (Git) or 1 (Rsync) — only matters when kind is not set |
created_at |
yes | ISO 8601 timestamp |
updated_at |
yes | ISO 8601 timestamp |
Deploy routing
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.
smb deploy --project <name>
│
▼
resolve_sub_project()
│
▼
config.project.kind?
│
┌────┼──────────┬──────────────┐
▼ ▼ ▼ ▼
vite-spa nextjs-ssr rails (none)
sveltekit
│ │ │ │
▼ ▼ ▼ ▼
pnpm pnpm rsync deployment_method?
build install lib/ │
│ + build compile ┌──┴──┐
▼ │ git push Git Rsync
rsync ▼ │ │ │
dist/ rsync 3 ▼ ▼ ▼
dirs post- git rsync
│ receive push -a
▼ hook
SSH PM2
restart
Strategy: nextjs-ssr
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.
Required fields: kind, source, path, pm2_app, package_manager, port
Strategy: vite-spa
Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
Required fields: kind, source, path, output, package_manager
A plain Svelte + Vite app (no SvelteKit) is a vite-spa.
Strategy: sveltekit
SvelteKit built with @sveltejs/adapter-static. Shares the vite-spa ship path (process_deploy_static_site.rs); only the build differs:
- Read the adapter from
package.json. Anything other thanadapter-static(adapter-auto,adapter-node, …) fails before building, naming the adapter. Server-rendered SvelteKit needs a Node process, which this path doesn't manage. <package_manager> install, then<package_manager> run build, insidesource/, with the terminal attached.- Check
output(defaultbuild) has a top-level.htmlfile: a prerenderedindex.htmlor an SPA fallback such as200.html. - Rsync
output/topath.
Required fields: kind, source, path
Optional: output (default "build", match pages in adapter-static), package_manager (default "pnpm")
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.
Strategy: rails
Rsync shared libraries, SSH compile native extensions, git force-push the sub-project directory to the server's bare repo (triggers post-receive hook).
Required fields: kind, source, repository, runner = 2
Optional: shared_lib, compile_cmd
Strategy: generic git or rsync
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.
The source field
The source field is the most important monorepo-specific field. It tells the CLI where the sub-project lives relative to the monorepo root.
For nextjs-ssr, vite-spa, and sveltekit, the CLI changes into the source directory before running build commands:
pnpm installruns insidesource/pnpm buildruns insidesource/- rsync sources are relative to
source/ - for Next.js SSR, standalone output may still preserve the
source/path inside.next/standalone/when tracing is rooted above the app directory
For rails, the CLI:
- rsyncs
shared_libfrom the monorepo root (not fromsource) - runs
compile_cmdon the server via SSH - initializes a temporary git repo inside
source/and force-pushes it
The source path must be a valid directory relative to where smb deploy is run (the monorepo root).
Runner types and server tiers
Each sub-project's runner determines which smbCloud server receives the deployment:
| Runner | Value | SSH Host | Use Case |
|---|---|---|---|
NodeJs |
0 |
api.smbcloud.xyz |
Next.js, Vite, Node.js apps |
Static |
1 |
api.smbcloud.xyz |
Pure static sites (Nginx serves directly) |
Ruby |
2 |
api-1.smbcloud.xyz |
Rails apps |
Swift |
3 |
api-1.smbcloud.xyz |
Vapor apps |
Monorepo |
255 |
— | Container only, never deployed directly |
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.
Multi-tenant server layout
Multiple apps share the same server, each in its own directory under the deploy user's home:
/home/git/
├── apps/
│ └── web/
│ ├── splitfireweb/ # Next.js SSR (PM2: splitfire-web, port 3010)
│ │ ├── server.js
│ │ ├── .next/static/
│ │ └── public/
│ ├── connecteddeviceskarokowe/ # Vite SPA (no PM2, Nginx serves directly)
│ │ └── index.html
│ ├── ondeinference.com/ # Next.js SSR (PM2: ondeinference-web, port 3026)
│ │ ├── server.js
│ │ ├── .next/static/
│ │ └── public/
│ └── aircraftshubweb/ # Next.js SSR (PM2: aircraftshub-web, port 3022)
│ ├── server.js
│ ├── .next/static/
│ └── public/
└── musik88-production.git/ # Bare repo (Rails post-receive hook)
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.
Port allocation
Every nextjs-ssr sub-project must have a unique port value. Ports are allocated manually and must be consistent across three places:
.smb/config.toml— theportfield in the[[projects]]entry- PM2 — the
PORTenvironment variable passed tonode server.js - Nginx — the
proxy_passupstream address
If any of these disagree, the app will either not start, not be reachable, or serve the wrong app.
Current allocations on api.smbcloud.xyz:
| App | PM2 Name | Port |
|---|---|---|
| splitfire-web | splitfire-web |
3010 |
| aircraftshub-web | aircraftshub-web |
3022 |
| ondeinference-web | ondeinference-web |
3026 |
Nginx configuration per app type
Next.js SSR apps use reverse proxy. In a monorepo the safe default is to
proxy everything (including /_next/static) to the Node process — do not
serve static from disk with an alias, because monorepo builds are nested
(see warning below):
server {
server_name example.com;
location / {
proxy_pass http://127.0.0.1:<port>;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache_bypass $http_upgrade;
}
}
Nested-static gotcha. A monorepo sub-project builds with
outputFileTracingRootpointing at the repo root, so standalone is nested and static lands at…/<app>/<source>/.next/static(e.g.…/<app>/apps/web/.next/static), not…/<app>/.next/static. The classic disk alias —location /_next/static/ { alias /home/git/apps/web/<app>/.next/static/; }— therefore 404s every JS/CSS chunk while HTML still renders ("page loads, no styles"). Proxy-everything avoids this entirely. If you insist on a disk alias for cache headers, point it at the nested path:alias /home/git/apps/web/<app>/<source>/.next/static/;. Flat single-repo builds (source = ".") keep static at…/<app>/.next/static, so an alias there is correct — but proxy-everything is the uniform choice.
Vite SPA / static apps use direct file serving:
server {
server_name example.com;
root /home/git/apps/web/<app>/;
location / {
try_files $uri $uri/ /index.html;
}
}
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.
CLI usage
Interactive mode
cd my-monorepo/
smb deploy
# → "Select project to deploy"
# → 1. splitfireweb
# 2. musik88web
# 3. connecteddevices
Direct mode (CI and scripts)
smb deploy --project splitfireweb
smb deploy -p musik88web
smb deploy -p connecteddevices
The --project / -p value must match the name field in a [[projects]] entry exactly. It is case-sensitive.
CI workflow parity
GitHub Actions workflows mirror the CLI deploy strategies. Each sub-project has its own workflow file triggered by a dedicated deploy branch.
Next.js SSR CI deploy
Trigger: push to release/splitfire-web
env:
GIT_HOST: api.smbcloud.xyz
GIT_USER: git
REMOTE_PATH: apps/web/splitfireweb
PM2_APP: splitfire-web
steps:
- name: Build
run: |
cd frontend/splitfire-web
pnpm install --ignore-scripts
pnpm build
- name: Upload
run: |
cd frontend/splitfire-web
rsync -az --delete --copy-links --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
- name: Restart
run: |
ssh $GIT_USER@$GIT_HOST bash << 'EOF'
set -e
cd ~/apps/web/splitfireweb
mkdir -p logs
if pm2 describe "splitfire-web" > /dev/null 2>&1; then
pm2 delete "splitfire-web"
fi
if [ -f ecosystem.config.cjs ]; then
pm2 start ecosystem.config.cjs --only "splitfire-web" --env production
elif [ -f ecosystem.config.js ]; then
pm2 start ecosystem.config.js --only "splitfire-web" --env production
else
PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
fi
pm2 save
EOF
Rails CI deploy
Trigger: push to release/backend-musik88-web
steps:
- name: Upload shared lib
run: rsync -r ./lib $GIT_USER@$GIT_HOST:~/
- name: Compile native gem
run: ssh $GIT_USER@$GIT_HOST 'bash -s' < compile.sh ./lib/gems/gem_error_codes
- name: Git force-push
run: |
cd backend/musik88-web
git init
git add .
git commit -m "Deploy to production"
git remote add prod $GIT_USER@$GIT_HOST:musik88-production
git push --set-upstream prod main --force
Vite SPA CI deploy
Trigger: push to the relevant deploy branch
steps:
- name: Build
run: |
cd frontend/karokowe-connected-devices
pnpm install
pnpm build
- name: Upload
run: |
cd frontend/karokowe-connected-devices
rsync -az --delete --mkpath dist/ $GIT_USER@$GIT_HOST:apps/web/connecteddeviceskarokowe/
CI rsync flags
All CI rsync commands must include:
--mkpath— creates remote directories if they do not exist (prevents failure on first deploy)--delete— removes stale files on the remote (prevents serving old assets)-az— archive mode with compression
For Next.js SSR standalone uploads, also include:
--copy-linkson.next/standalone/— dereferencespnpmsymlinked dependencies so the server does not end up with brokennode_moduleslinks
CI PM2 restart
Current CI and CLI parity should use delete + fresh start, not restart-in-place:
pm2 delete <app>if it existspm2 start ecosystem.config.cjs --only <app> --env productionwhen the.cjsfile exists (preferred)- fall back to
pm2 start ecosystem.config.js --only <app> --env productionif only the legacy.jsfile exists - otherwise fall back to
PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <app> -- server.js pm2 save
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.
This matches the standalone Next.js deploy model and avoids stale PM2 command state from older deploy methods.
Deployment tracking
Every deploy records status in the smbCloud API regardless of strategy:
POST /deploymentswithstatus: Startedbefore transferring filesPATCH /deployments/:idwithstatus: Doneon successPATCH /deployments/:idwithstatus: Failedon failure
Current tracking model in the CLI:
- requests still route through the umbrella workspace
id - when
frontend_app_idis 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
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.
Adding a new sub-project to a monorepo
- Create the project in smbCloud (via
smb initin a temporary directory, or the web console) to get anid - Add a
[[projects]]entry to the monorepo's.smb/config.tomlwith the correctkind,source,path,runner, and strategy-specific fields - If
kind = "nextjs-ssr": allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config - If
kind = "vite-spa"orkind = "sveltekit": add Nginx static file config (with an SPA fallbacktry_filesfor client-rendered SvelteKit) - If
kind = "rails": set up a bare git repo on the server with a post-receive hook - Test with
smb deploy -p <name>
Removing a sub-project
- Remove the
[[projects]]entry from.smb/config.toml - On the server:
pm2 delete <pm2_app> && pm2 save(for SSR apps) - On the server: remove the Nginx config and
sudo nginx -t && sudo systemctl reload nginx - On the server: remove the app directory
Standalone repos vs monorepo sub-projects
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.
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.
To convert a standalone repo into a monorepo sub-project:
- Move the
[project]fields into a[[projects]]entry - Create a new
[project]withrunner = 255 - Set
sourceto the sub-directory path - Adjust
pathif the remote directory structure differs
Deploy from the sub-project, not the leftover standalone repo. Once an app has been folded into a monorepo (
apps/<name>), that copy is the source of truth — deploy it withsmb deploy -p <name>. If the old standalone repo still exists on disk it is easy to build and deploy from there by accident, which silently diverges from what the monorepo would ship. Archive the old repo. Note this also flips the build from flat to nested standalone, so the nginx/_next/statichandling may need the nested-path / proxy-everything fix.
Debugging monorepo deploys
"No [[projects]] entries found"
The root [project] has runner = 255 but there are no [[projects]] entries. Add at least one sub-project.
"Sub-project 'foo' not found in [[projects]]"
The --project name does not match any [[projects]] entry's name field. Check spelling and case.
Wrong sub-project deploys
If the interactive prompt selects the wrong project, verify the [[projects]] order in .smb/config.toml — the prompt lists them in file order.
Build runs in wrong directory
The source field is incorrect. It must be relative to the monorepo root where smb deploy is run.
Port conflict on multi-tenant server
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.
Stale assets after deploy / /_next/static 404s
The Nginx alias for /_next/static/ (or the Vite SPA root) points at the
wrong directory. For a nested monorepo standalone build the assets live at
<path>/<source>/.next/static, not <path>/.next/static — see the nested-static
gotcha under "Nginx configuration per app type". Either proxy /_next/static to
the Node process or fix the alias to the nested path. Confirm with
curl -sI https://<domain>/_next/static/... (want 200).
rsync of '.next/standalone/' failed (status 23)
smb deploy aborts at the standalone upload with empty stderr; running rsync by
hand shows IO error encountered -- skipping file deletion. Cause: Next.js
standalone leaves a dangling pnpm symlink (e.g.
node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/... when only
semver@7.x was traced) and the CLI's rsync --copy-links errors trying to
follow it. Fix: after next build, before rsync, run
find .next/standalone/ -type l ! -exec test -e {} \; -delete. Proper fix is in
the CLI (prune dangling links before the standalone rsync). See the nextjs skill.
Cannot find module 'next' after Next.js SSR deploy
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.
PM2 not picking up new environment variables
Current CLI parity is delete + fresh start. Check these in order:
- the server's
ecosystem.config.cjs(or legacy.js) was edited, not just the repo copy - the deploy script is actually starting from
ecosystem.config.cjs— if only a.jsfile exists and"type": "module"is set inpackage.json, PM2 will fail; rename to.cjs pm2 saveran after the fresh start
If you are changing env manually outside the deploy flow, pm2 restart <app> --update-env && pm2 save is still a valid operator command.
Validation
Config
- every
[[projects]]entry has a uniquename - every
[[projects]]entry has a uniqueid - every
nextjs-ssrentry has a uniqueport sourcedirectories exist on diskrunnervalues match the intended server tier
Local
smb deploy -p <name>completes without error for each sub-projectcargo check -p smbcloud-clipasses after any deploy code changes
Server
pm2 listshows expected processes with correct names and portssudo nginx -tpasses- each domain resolves to the correct app content
ls -la /home/git/apps/web/<app>/confirms expected files
Common mistakes
- setting
runner = 255on a standalone project that has no[[projects]]entries - forgetting to set
runner = 255on the root[project]of a monorepo — the CLI treats it as a standalone project and tries to deploy the root - duplicating
portvalues across sub-projects on the same server - using
kind = "nextjs-ssr"on a project withoutoutput: "standalone"in its Next.js config - using
kind = "sveltekit"withadapter-autooradapter-node; switchsvelte.config.jstoadapter-staticinstead - running
smb deployfrom a subdirectory instead of the monorepo root —sourcepaths will not resolve - forgetting
--mkpathon rsync in CI workflows — first deploys fail because the remote directory does not exist - forgetting
--copy-linkson.next/standalone/uploads — the server receives brokenpnpmsymlinks and PM2 crashes withCannot find module 'next' - keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
- mixing up
path(remote destination) andsource(local directory) — they serve different purposes - treating the root workspace id as the deployable app id
- forgetting to add
frontend_app_idon[[projects]]entries once the API exposes it - using
pm2 restartwithout--update-envafter changing ecosystem file variables in older restart-in-place workflows - assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server