feature/project-new-structure
claude/smbcloud-github-app-k9641p
development
feature/apps
feature/build-release
feature/ci-docs
feature/ci-ruby-gems
feature/cicd
feature/completion
feature/dist-nuget
feature/fix-ci-sdk-pypi-rubygem
feature/fix-clippy-on-stable-channel
feature/fix-deployment-script
feature/github-email
feature/gresiq-v1
feature/project-new-structure
feature/rsync-deploy
feature/ruby-runner
feature/sdk-npm
feature/sdk-workflows
feature/smbcloud-cli-release-npm
feature/smbcloud-deploy
feature/wasm
main
release/v0.7.4
| 1 | --- |
| 2 | name: smbcloud-deploy-nextjs |
| 3 | description: Use when deploying or debugging Next.js apps on smbCloud, especially the dedicated `nextjs-ssr` flow that builds locally, uploads `.next/standalone` via rsync, restarts PM2 over SSH, and serves traffic behind Nginx. |
| 4 | --- |
| 5 | |
| 6 | # smbCloud Deploy Next.js |
| 7 | |
| 8 | Use this skill when work touches Next.js deployment on smbCloud. |
| 9 | |
| 10 | Applies to: |
| 11 | |
| 12 | - `kind = "nextjs-ssr"` deploy targets |
| 13 | - `.smb/config.toml` deploy configuration |
| 14 | - `process_deploy_nextjs_ssr.rs` |
| 15 | - PM2 process management for standalone builds |
| 16 | - Nginx proxying for SSR apps |
| 17 | - production-only issues caused by CORS, ports, or runtime mismatch |
| 18 | |
| 19 | ## Deployment model |
| 20 | |
| 21 | Keep the deploy hierarchy straight: |
| 22 | |
| 23 | - `Project` = umbrella workspace in smbCloud (not deployable) |
| 24 | - `DeployRepo` = the git repository inside that workspace |
| 25 | - `FrontendApp` = the deployable app unit, identified by `frontend_app_id` in config |
| 26 | - `Deployment` = one release event, linked to a FrontendApp via `frontend_app_id` |
| 27 | |
| 28 | The canonical chain is: Deployment → FrontendApp → DeployRepo → Project |
| 29 | |
| 30 | For `nextjs-ssr`, smbCloud does not use git-push deploy. |
| 31 | |
| 32 | The CLI route in `process_deploy.rs` short-circuits on: |
| 33 | |
| 34 | - `kind = "nextjs-ssr"` |
| 35 | |
| 36 | That path builds locally, then: |
| 37 | |
| 38 | - rsyncs `.next/standalone/` into the remote app directory root |
| 39 | - rsyncs `.next/static/` into the runtime directory's `.next/static/` |
| 40 | - rsyncs `public/` into the runtime directory's `public/` |
| 41 | - runs `ssh` and restarts the app with PM2 |
| 42 | |
| 43 | When `outputFileTracingRoot` points above the app directory, Next may preserve the source path inside `.next/standalone/` (for example `.next/standalone/web/server.js`). The deploy path must keep that nested runtime layout intact. |
| 44 | |
| 45 | Do not describe this flow as git based unless the project is actually using the generic git deploy path. |
| 46 | |
| 47 | ## Required app configuration |
| 48 | |
| 49 | The Next.js app must produce a standalone server bundle. |
| 50 | |
| 51 | Create `next.config.mjs` or equivalent with: |
| 52 | |
| 53 | ```js |
| 54 | /** @type {import('next').NextConfig} */ |
| 55 | const nextConfig = { |
| 56 | output: "standalone", |
| 57 | }; |
| 58 | |
| 59 | export default nextConfig; |
| 60 | ``` |
| 61 | |
| 62 | Without `output: "standalone"`, the deploy code fails because `.next/standalone/` is missing. |
| 63 | |
| 64 | The app package scripts should support: |
| 65 | |
| 66 | - `build`: `next build` |
| 67 | |
| 68 | `pnpm` is the safe package manager for the current CLI implementation. |
| 69 | |
| 70 | ## Required smbCloud config |
| 71 | |
| 72 | Minimum `.smb/config.toml` for SSR: |
| 73 | |
| 74 | ```toml |
| 75 | [project] |
| 76 | id = 28 # workspace id (for API routing) |
| 77 | frontend_app_id = "uuid" # FrontendApp id (for deployment tracking) |
| 78 | deploy_repo_id = 123 # DeployRepo id (links to git repository record) |
| 79 | deployment_method = 1 |
| 80 | kind = "nextjs-ssr" |
| 81 | source = "." |
| 82 | package_manager = "pnpm" |
| 83 | pm2_app = "my-app" |
| 84 | path = "apps/web/my-app" |
| 85 | port = 3028 |
| 86 | ``` |
| 87 | |
| 88 | Important rules: |
| 89 | |
| 90 | - `kind = "nextjs-ssr"` is what activates the SSR deploy path |
| 91 | - `id` is the umbrella workspace ID, used for auth and API routing |
| 92 | - `frontend_app_id` identifies the FrontendApp record for this deploy target — include it so deployments are attributed to the correct app, not just the workspace |
| 93 | - `deploy_repo_id` identifies the DeployRepo record backing this app — include it when available |
| 94 | - `source` is the local Next.js app directory |
| 95 | - `path` is the remote destination directory on the server |
| 96 | - `pm2_app` is mandatory because the CLI restarts PM2 by name |
| 97 | - `port` should be set explicitly and must match nginx |
| 98 | - `deployment_method = 1` may still be present, but the `kind` routing is what matters most |
| 99 | |
| 100 | ## Expected remote layout |
| 101 | |
| 102 | After a successful deploy, the remote directory should look like one of these layouts: |
| 103 | |
| 104 | - flat runtime |
| 105 | - `/home/git/apps/web/<app>/server.js` |
| 106 | - `/home/git/apps/web/<app>/.next/static/...` |
| 107 | - `/home/git/apps/web/<app>/public/...` |
| 108 | - nested runtime when standalone preserves the source path |
| 109 | - `/home/git/apps/web/<app>/node_modules/...` |
| 110 | - `/home/git/apps/web/<app>/web/server.js` |
| 111 | - `/home/git/apps/web/<app>/web/.next/static/...` |
| 112 | - `/home/git/apps/web/<app>/web/public/...` |
| 113 | |
| 114 | The CLI uploads the contents of `.next/standalone/`, not the folder itself. In nested-runtime mode it also needs to keep PM2 compatibility for old root-level `server.js` and `.next/standalone/server.js` entrypoints. |
| 115 | |
| 116 | ## PM2 behavior |
| 117 | |
| 118 | The CLI's deploy path now deletes and starts fresh, preferring the server's |
| 119 | ecosystem config file when it exists. The CLI checks for `ecosystem.config.cjs` |
| 120 | first, then falls back to `ecosystem.config.js`. During deploy, the server-side |
| 121 | migration auto-renames `.js` to `.cjs` when applicable. |
| 122 | |
| 123 | Conceptually it behaves like: |
| 124 | |
| 125 | ```sh |
| 126 | cd "$APP_PATH" |
| 127 | mkdir -p logs |
| 128 | |
| 129 | if pm2 describe "$PM2_APP" > /dev/null 2>&1; then |
| 130 | pm2 delete "$PM2_APP" |
| 131 | fi |
| 132 | |
| 133 | if [ -f ecosystem.config.cjs ]; then |
| 134 | pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production |
| 135 | elif [ -f ecosystem.config.js ]; then |
| 136 | pm2 start ecosystem.config.js --only "$PM2_APP" --env production |
| 137 | else |
| 138 | NODE_ENV=production PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js |
| 139 | fi |
| 140 | |
| 141 | pm2 save |
| 142 | ``` |
| 143 | |
| 144 | This has two consequences: |
| 145 | |
| 146 | - old git-based `post-receive` hooks are irrelevant for the SSR path |
| 147 | - the configured `port` in `.smb/config.toml` is used for the fallback start path |
| 148 | - the live server `ecosystem.config.cjs` (or `.js`) is the runtime source of truth when present |
| 149 | |
| 150 | ### Environment variables — ecosystem file (standard pattern) |
| 151 | |
| 152 | The server runs in a **multi-tenant setup**: multiple Next.js apps share one server, each managed as a separate PM2 process. The standard way to manage per-app environment variables is an `ecosystem.config.cjs` file kept **on the server only** (never committed to the app repo). |
| 153 | |
| 154 | Use the `.cjs` extension. This is required when the project's `package.json` has `"type": "module"`, because Node treats `.js` files as ESM in that case and `module.exports` syntax will fail. |
| 155 | |
| 156 | The flow works like this: |
| 157 | |
| 158 | 1. On first setup, SSH in, create the ecosystem file, start with it, and save: |
| 159 | |
| 160 | ```sh |
| 161 | ssh git@<server> |
| 162 | cd /home/git/apps/web/<app> |
| 163 | |
| 164 | cat > ecosystem.config.cjs << 'EOF' |
| 165 | module.exports = { |
| 166 | apps: [{ |
| 167 | name: "<pm2_app>", |
| 168 | script: "server.js", |
| 169 | cwd: "/home/git/apps/web/<app>", |
| 170 | env_production: { |
| 171 | NODE_ENV: "production", |
| 172 | PORT: 3026, |
| 173 | HOSTNAME: "127.0.0.1", |
| 174 | MY_SECRET: "...", |
| 175 | } |
| 176 | }] |
| 177 | } |
| 178 | EOF |
| 179 | |
| 180 | pm2 start ecosystem.config.cjs --env production |
| 181 | pm2 save |
| 182 | ``` |
| 183 | |
| 184 | 2. Every subsequent CLI deploy deletes the PM2 process and starts it fresh. The CLI checks for `ecosystem.config.cjs` first, then `ecosystem.config.js`. If neither exists, it falls back to `node server.js` with inline `NODE_ENV`, `PORT`, and `HOSTNAME`. |
| 185 | |
| 186 | 3. To change or add env vars, SSH in, edit `ecosystem.config.cjs`, then: |
| 187 | |
| 188 | ```sh |
| 189 | pm2 restart <pm2_app> --update-env |
| 190 | pm2 save |
| 191 | ``` |
| 192 | |
| 193 | The ecosystem file is the server-side source of truth for each app's runtime config. The CLI preserves both `ecosystem.config.cjs` and `ecosystem.config.js` on the server during rsync and must not delete either. |
| 194 | |
| 195 | ### Manual PM2 operations (without ecosystem file) |
| 196 | |
| 197 | If you are operating without an ecosystem file, prefer the same fresh-start model the CLI uses: |
| 198 | |
| 199 | ```sh |
| 200 | cd /home/git/apps/web/<app> |
| 201 | if pm2 describe <pm2_app> > /dev/null 2>&1; then |
| 202 | pm2 delete <pm2_app> |
| 203 | fi |
| 204 | PORT=<port> HOSTNAME=127.0.0.1 pm2 start node --name <pm2_app> -- server.js |
| 205 | pm2 save |
| 206 | ``` |
| 207 | |
| 208 | If you are using an ecosystem file and only changed env vars manually, this is still valid: |
| 209 | |
| 210 | ```sh |
| 211 | cd /home/git/apps/web/<app> |
| 212 | pm2 restart <pm2_app> --update-env |
| 213 | pm2 save |
| 214 | ``` |
| 215 | |
| 216 | ## Port management |
| 217 | |
| 218 | Use one source of truth for the runtime port. |
| 219 | |
| 220 | If an older ecosystem config (`.cjs` or `.js`) exists and contains both: |
| 221 | |
| 222 | - `args: "start -p 3026"` |
| 223 | - `env_production.PORT = 3025` |
| 224 | |
| 225 | that is a broken configuration. Standardize on one port value. |
| 226 | |
| 227 | Current rules: |
| 228 | |
| 229 | - fallback fresh-start path uses `port` from `.smb/config.toml` |
| 230 | - ecosystem-managed apps should set the same port in `env_production.PORT` (in `ecosystem.config.cjs`) |
| 231 | - nginx upstream must match that port |
| 232 | |
| 233 | ## Nginx for SSR |
| 234 | |
| 235 | Do not use a static-site Nginx config like: |
| 236 | |
| 237 | ```nginx |
| 238 | root /home/git/apps/web/my-app; |
| 239 | location / { |
| 240 | try_files $uri $uri/ =404; |
| 241 | } |
| 242 | ``` |
| 243 | |
| 244 | That is for static files, not Next.js SSR. |
| 245 | |
| 246 | Use reverse proxying to the local Node process instead: |
| 247 | |
| 248 | ```nginx |
| 249 | location / { |
| 250 | proxy_pass http://127.0.0.1:3026; |
| 251 | proxy_http_version 1.1; |
| 252 | |
| 253 | proxy_set_header Host $host; |
| 254 | proxy_set_header X-Real-IP $remote_addr; |
| 255 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| 256 | proxy_set_header X-Forwarded-Proto $scheme; |
| 257 | |
| 258 | proxy_set_header Upgrade $http_upgrade; |
| 259 | proxy_set_header Connection "upgrade"; |
| 260 | |
| 261 | proxy_cache_bypass $http_upgrade; |
| 262 | } |
| 263 | ``` |
| 264 | |
| 265 | Nginx must proxy to the actual PM2 port. |
| 266 | |
| 267 | ## Common failure modes |
| 268 | |
| 269 | ### `.next/standalone` missing |
| 270 | |
| 271 | Cause: |
| 272 | |
| 273 | - no `output: "standalone"` in Next config |
| 274 | |
| 275 | Fix: |
| 276 | |
| 277 | - add standalone output |
| 278 | - rebuild locally |
| 279 | |
| 280 | ### `server.js` missing on the server |
| 281 | |
| 282 | Check: |
| 283 | |
| 284 | - whether the SSR deploy path actually ran |
| 285 | - whether `path` points to the directory you inspected |
| 286 | - whether `rsync` uploaded to a different location |
| 287 | |
| 288 | ### Missing `public/` |
| 289 | |
| 290 | `public/` is optional. The current deploy code skips it when the directory does not exist. |
| 291 | |
| 292 | If assets are missing in production, check whether the app actually has a `public/` directory locally and whether the expected files were copied into the remote `public/` directory. |
| 293 | |
| 294 | ### `Cannot find module 'next'` or `502 Bad Gateway` |
| 295 | |
| 296 | Cause: |
| 297 | |
| 298 | - `.next/standalone/node_modules/*` contains `pnpm` symlinks |
| 299 | - rsync preserved the symlinks instead of copying their targets |
| 300 | - or the standalone output itself is incomplete and missing traced runtime files |
| 301 | - PM2 starts `server.js`, Node cannot resolve `next`, and nginx returns `502 Bad Gateway` |
| 302 | |
| 303 | Fix: |
| 304 | |
| 305 | - use the dedicated `nextjs-ssr` CLI path in current `smbcloud-cli` |
| 306 | - make sure the standalone upload uses `rsync --copy-links` |
| 307 | - verify `node .next/standalone/server.js` works locally before deploy |
| 308 | - if local standalone fails too, add `outputFileTracingIncludes` for the missing runtime files and rebuild |
| 309 | - then restart PM2 and recheck `pm2 logs <pm2_app>` |
| 310 | |
| 311 | ### `Cannot find module 'styled-jsx'`, `@swc/helpers`, or other pnpm peer deps |
| 312 | |
| 313 | A common concrete failure looks like: |
| 314 | |
| 315 | - `code: 'MODULE_NOT_FOUND'` |
| 316 | - `path: '/home/git/apps/web/<app>/node_modules/@swc/helpers'` |
| 317 | |
| 318 | That usually means the standalone upload succeeded, but the top-level hoisted symlink for a scoped package was not recreated on the server. |
| 319 | |
| 320 | Cause: |
| 321 | |
| 322 | - pnpm stores peer dependencies inside `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>` with internal symlinks that Node's standard `require.resolve()` cannot follow |
| 323 | - Next.js standalone traces the files into `.pnpm/` correctly, but does not create the flat `node_modules/<pkg>` entries needed for resolution |
| 324 | - this affects `styled-jsx`, `@swc/helpers`, `@next/env`, and other packages that `next` requires at startup |
| 325 | |
| 326 | Fix: |
| 327 | |
| 328 | - the CLI deploy script now auto-hoists in two passes: |
| 329 | - first mirror pnpm's virtual directory at `node_modules/.pnpm/node_modules/*` |
| 330 | - then fall back to scanning `node_modules/.pnpm/*/node_modules/*` for anything still missing |
| 331 | - this preserves pnpm's exact resolved package choice and fixes scoped packages like `@swc/helpers` as well as unscoped ones like `styled-jsx` |
| 332 | - no manual intervention needed for new deploys once the updated CLI is used |
| 333 | - to fix a server manually, prefer pnpm's virtual directory as the source of truth: |
| 334 | - create `node_modules/@scope/` directories as needed |
| 335 | - symlink `node_modules/<pkg>` or `node_modules/@scope/<pkg>` to the corresponding entry under `node_modules/.pnpm/node_modules/` |
| 336 | - only if that directory is missing, scan individual `.pnpm/<store-entry>/node_modules/*` folders as a fallback |
| 337 | |
| 338 | ### Wrong port after deploy |
| 339 | |
| 340 | Cause: |
| 341 | |
| 342 | - `port` in `.smb/config.toml`, server ecosystem config (`.cjs` or `.js`), and nginx upstream disagree |
| 343 | |
| 344 | Fix: |
| 345 | |
| 346 | - standardize the same port in all three places |
| 347 | - if using ecosystem config, make sure PM2 is actually starting from that file |
| 348 | |
| 349 | ### PM2 "module is not defined" or "require is not defined" |
| 350 | |
| 351 | Cause: |
| 352 | |
| 353 | - the project's `package.json` has `"type": "module"`, which makes Node treat `.js` files as ESM |
| 354 | - `ecosystem.config.js` uses `module.exports` (CommonJS), which is invalid under ESM |
| 355 | - deploy shims that use `require()` will also fail under ESM |
| 356 | |
| 357 | Fix: |
| 358 | |
| 359 | - rename `ecosystem.config.js` to `ecosystem.config.cjs` on the server |
| 360 | - deploy shims must use `import()` not `require()` |
| 361 | - the CLI now handles both automatically: it checks for `.cjs` first and the server-side migration auto-renames `.js` to `.cjs` during deploy |
| 362 | |
| 363 | ### Misleading deploy output |
| 364 | |
| 365 | The CLI may print generic deploy messages such as: |
| 366 | |
| 367 | - `Deploying > Building the app` |
| 368 | - `App is running` |
| 369 | |
| 370 | These messages do not mean git push happened. Confirm behavior from the code path, not the spinner text. |
| 371 | |
| 372 | ## Auth and production-only web issues |
| 373 | |
| 374 | If the Next.js app uses the wasm auth client from `smbcloud-auth-sdk-wasm`, production failures that show: |
| 375 | |
| 376 | - `Network error. Please check your internet connection and try again.` |
| 377 | |
| 378 | are often not actual connectivity failures. |
| 379 | |
| 380 | Check these first: |
| 381 | |
| 382 | - Rails CORS allows the production origin |
| 383 | - `Authorization` response header is exposed over CORS |
| 384 | - backend auth status codes match the Rust parser logic |
| 385 | |
| 386 | ### Rails requirement |
| 387 | |
| 388 | For browser clients using `POST /v1/client/users/sign_in`, if the token is returned in the `Authorization` response header, CORS must include: |
| 389 | |
| 390 | - production origins such as `https://ondeinference.com` |
| 391 | - `expose: %w[... Authorization]` |
| 392 | |
| 393 | Without that, browser fetch in wasm can surface as a fake network error. |
| 394 | |
| 395 | ### Rust requirement |
| 396 | |
| 397 | The shared Rust networking layer must not collapse all non-200 auth responses into `NetworkError`. |
| 398 | |
| 399 | If backend returns meaningful auth errors such as `401` invalid password, update the parser in `request_login` to preserve the real failure. |
| 400 | |
| 401 | ## Recommended validation |
| 402 | |
| 403 | Use the smallest checks that prove the contract. |
| 404 | |
| 405 | ### Next.js app |
| 406 | |
| 407 | - `pnpm build` |
| 408 | - verify standalone contains a runnable server entrypoint |
| 409 | - if `.next/standalone/server.js` exists, verify `node .next/standalone/server.js` starts locally |
| 410 | - if standalone preserves the source path, verify `node .next/standalone/<source>/server.js` starts locally |
| 411 | |
| 412 | ### smbcloud-cli |
| 413 | |
| 414 | - `cargo check -p smbcloud-network` |
| 415 | - `cargo check -p cli` when deploy code changed |
| 416 | |
| 417 | ### Server |
| 418 | |
| 419 | - `pm2 list` |
| 420 | - `pm2 logs <pm2_app>` |
| 421 | - `ls -la /home/git/apps/web/<app>` |
| 422 | - `ls -ld /home/git/apps/web/<app>/node_modules/next` |
| 423 | - `sudo nginx -t` |
| 424 | |
| 425 | ## Common mistakes |
| 426 | |
| 427 | - assuming `deployment_method = 1` alone activates SSR deploy |
| 428 | - forgetting `kind = "nextjs-ssr"` |
| 429 | - keeping an old git `post-receive` hook and expecting it to manage SSR deploys |
| 430 | - using static-file Nginx config for an SSR app |
| 431 | - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server |
| 432 | - editing the repo copy of `ecosystem.config.cjs` and expecting a normal CLI deploy to overwrite the server copy — it is preserved intentionally |
| 433 | - editing the server `ecosystem.config.cjs` but the deploy script falling back to inline `node server.js` because the file path check is wrong |
| 434 | - keeping an `ecosystem.config.js` when the project uses ESM (`"type": "module"` in `package.json`) — rename it to `.cjs` or let the CLI migration handle it |
| 435 | - leaving port values inconsistent across PM2, Nginx, and `.smb/config.toml` |
| 436 | - forgetting to set `frontend_app_id` on the deploy target once app-level deployment tracking is available |
| 437 | - treating browser CORS failures as real internet outages |
| 438 | - deploying without `frontend_app_id` in config — the API will try to infer the app, but explicit is better than implicit |