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 = 255 in [project]
  • .smb/config.toml files with [[projects]] arrays
  • process_deploy.rs monorepo 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 DeployRepo record
  • each [[projects]] entry = one deployable FrontendApp inside that repo, identified by frontend_app_id
  • each deployment record = one release of one FrontendApp, linked via frontend_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 = 255 is mandatory — this is what activates monorepo behavior
  • id must be a valid smbCloud project ID (created via smb init or the web console)
  • the root project has no kind, source, path, or pm2_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:

  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.
  2. <package_manager> install, then <package_manager> run build, inside source/, with the terminal attached.
  3. Check output (default build) has a top-level .html file: a prerendered index.html or an SPA fallback such as 200.html.
  4. Rsync output/ to path.

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 install runs inside source/
  • pnpm build runs inside source/
  • 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_lib from the monorepo root (not from source)
  • runs compile_cmd on 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:

  1. .smb/config.toml — the port field in the [[projects]] entry
  2. PM2 — the PORT environment variable passed to node server.js
  3. Nginx — the proxy_pass upstream 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 outputFileTracingRoot pointing 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-links on .next/standalone/ — dereferences pnpm symlinked dependencies so the server does not end up with broken node_modules links

CI PM2 restart

Current CI and CLI parity should use delete + fresh start, not restart-in-place:

  • pm2 delete <app> if it exists
  • pm2 start ecosystem.config.cjs --only <app> --env production when the .cjs file exists (preferred)
  • fall back to pm2 start ecosystem.config.js --only <app> --env production if only the legacy .js file 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:

  1. POST /deployments with status: Started before transferring files
  2. PATCH /deployments/:id with status: Done on success
  3. PATCH /deployments/:id with status: Failed on failure

Current tracking model in the CLI:

  • requests still route through the umbrella workspace id
  • 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

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

  1. Create the project in smbCloud (via smb init in a temporary directory, or the web console) to get an id
  2. Add a [[projects]] entry to the monorepo's .smb/config.toml with the correct kind, source, path, runner, and strategy-specific fields
  3. If kind = "nextjs-ssr": allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config
  4. If kind = "vite-spa" or kind = "sveltekit": add Nginx static file config (with an SPA fallback try_files for client-rendered SvelteKit)
  5. If kind = "rails": set up a bare git repo on the server with a post-receive hook
  6. Test with smb deploy -p <name>

Removing a sub-project

  1. Remove the [[projects]] entry from .smb/config.toml
  2. On the server: pm2 delete <pm2_app> && pm2 save (for SSR apps)
  3. On the server: remove the Nginx config and sudo nginx -t && sudo systemctl reload nginx
  4. 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:

  1. Move the [project] fields into a [[projects]] entry
  2. Create a new [project] with runner = 255
  3. Set source to the sub-directory path
  4. Adjust path if 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 with smb 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/static handling 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:

  1. the server's ecosystem.config.cjs (or legacy .js) was edited, not just the repo copy
  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
  3. pm2 save ran 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 unique name
  • every [[projects]] entry has a unique id
  • every nextjs-ssr entry has a unique port
  • source directories exist on disk
  • runner values match the intended server tier

Local

  • smb deploy -p <name> completes without error for each sub-project
  • cargo check -p smbcloud-cli passes after any deploy code changes

Server

  • pm2 list shows expected processes with correct names and ports
  • sudo nginx -t passes
  • each domain resolves to the correct app content
  • ls -la /home/git/apps/web/<app>/ confirms expected files

Common mistakes

  • setting runner = 255 on a standalone project that has no [[projects]] entries
  • 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
  • duplicating port values across sub-projects on the same server
  • using kind = "nextjs-ssr" on a project without output: "standalone" in its Next.js config
  • using kind = "sveltekit" with adapter-auto or adapter-node; switch svelte.config.js to adapter-static instead
  • running smb deploy from a subdirectory instead of the monorepo root — source paths will not resolve
  • forgetting --mkpath on rsync in CI workflows — first deploys fail because the remote directory does not exist
  • forgetting --copy-links on .next/standalone/ uploads — the server receives broken pnpm symlinks and PM2 crashes with Cannot find module 'next'
  • keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
  • mixing up path (remote destination) and source (local directory) — they serve different purposes
  • treating the root workspace id as the deployable app id
  • forgetting to add frontend_app_id on [[projects]] entries once the API exposes it
  • using pm2 restart without --update-env after 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