Fix Next.js SSR deploy to preserve ecosystem.config.js and logs
- Protect operator-managed runtime files from rsync deletion - Prefer pm2 start with ecosystem.config.js if present on server - Always delete and start pm2 process fresh after deploy
keypair34 committed
May 9, 2026 at 22:43 UTC
12a933e4ee7c4e8b7f80184b94488e62fa4c9e6b
2 files changed
+93
-43
crates/cli/src/account/login/process.rs
+4
@@ -100,6 +100,10 @@ async fn process_authorization(env: Environment, auth: SmbAuthorization) -> Resu
100
return Err(error);
101
}
102
GithubNotLinked => return connect_github_account(env, auth).await,
103
+ _ => {
104
+ let error = anyhow!(error_code.to_string());
105
+ return Err(error);
106
+ }
107
}
108
}
109
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+89
-43
@@ -36,7 +36,7 @@ use {
36
/// 4. rsync .next/standalone/ → server:path/ (server + bundled deps)
37
/// 5. rsync .next/static/ → server:path/.next/static/ (static chunks)
38
/// 6. rsync public/ → server:path/public/ (public assets)
39
-/// 7. SSH: pm2 restart/start `node server.js`
39
+/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.js` if present)
40
/// 8. PATCH deployment record as Done
41
pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> {
42
let source = config.project.source.as_deref().unwrap_or(".");
@@ -214,16 +214,37 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
214
}
215
);
216
217
+ struct Transfer {
218
+ local_rel: &'static str,
219
+ remote_rel: &'static str,
220
+ protect_runtime_files: bool,
221
+ }
222
+
223
// (local_source, remote_destination)
224
// .next/standalone contents go to the root of remote_path.
225
// .next/static and public go into their correct subdirectories within it.
220
- let transfers: &[(&str, &str)] = &[
226
+ let transfers: &[Transfer] = &[
227
// standalone contents → remote root (server.js lives here)
222
- (".next/standalone/", ""),
228
+ Transfer {
229
+ local_rel: ".next/standalone/",
230
+ remote_rel: "",
231
+ // The server copy of ecosystem.config.js is operator-managed runtime
232
+ // config and must survive deploys. Without this protection, rsync
233
+ // `--delete` removes it because it does not exist in .next/standalone/.
234
+ protect_runtime_files: true,
235
+ },
236
// static chunks → .next/static/ inside the standalone tree
224
- (".next/static/", ".next/static/"),
237
+ Transfer {
238
+ local_rel: ".next/static/",
239
+ remote_rel: ".next/static/",
240
+ protect_runtime_files: false,
241
+ },
242
// public assets → public/ inside the standalone tree
226
- ("public/", "public/"),
243
+ Transfer {
244
+ local_rel: "public/",
245
+ remote_rel: "public/",
246
+ protect_runtime_files: false,
247
+ },
248
];
249
250
let mut upload_spinner = Spinner::new(
@@ -231,24 +252,35 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
252
succeed_message(&format!("Uploading to {}…", remote_path)),
253
);
254
234
- for (local_rel, remote_rel) in transfers {
235
- let local_path = format!("{}/{}", source, local_rel);
236
- let destination = format!("{}{}", remote_base, remote_rel);
255
+ for transfer in transfers {
256
+ let local_path = format!("{}/{}", source, transfer.local_rel);
257
+ let destination = format!("{}{}", remote_base, transfer.remote_rel);
258
259
// Skip optional directories that the project does not have.
260
if !std::path::Path::new(&local_path).exists() {
261
continue;
262
}
263
264
+ let mut rsync_args = vec!["-az".to_string(), "--delete".to_string()];
265
+
266
+ if transfer.protect_runtime_files {
267
+ rsync_args.extend([
268
+ "--exclude".to_string(),
269
+ "ecosystem.config.js".to_string(),
270
+ "--exclude".to_string(),
271
+ "logs/".to_string(),
272
+ ]);
273
+ }
274
+
275
+ rsync_args.extend([
276
+ "-e".to_string(),
277
+ ssh_command.clone(),
278
+ local_path.clone(),
279
+ destination,
280
+ ]);
281
+
282
let output = Command::new("rsync")
244
- .args([
245
- "-az",
246
- "--delete",
247
- "-e",
248
- &ssh_command,
249
- &local_path,
250
- &destination,
251
- ])
283
+ .args(&rsync_args)
284
.output()
285
.map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
286
@@ -265,7 +297,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
297
.await;
298
return Err(anyhow!(fail_message(&format!(
299
"rsync of '{}' failed (status {}): {}",
268
- local_rel,
300
+ transfer.local_rel,
301
output.status.code().unwrap_or(-1),
302
stderr.trim()
303
))));
@@ -279,15 +311,17 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
311
312
// ── Step 8: SSH remote restart ───────────────────────────────────────────
313
//
282
- // Runs `node server.js` via pm2 inside the deployed standalone directory.
283
- // PORT and HOSTNAME are set so Next.js binds correctly behind nginx.
314
+ // Runs the deployed app via pm2 inside the standalone directory.
315
+ //
316
+ // We always delete the existing pm2 process (if any) and start fresh. A
317
+ // bare `pm2 restart` would re-execute the old command (e.g. `next start
318
+ // --port XXXX` from a previous git-push deploy), which fails when the
319
+ // working directory now contains standalone output instead of the full
320
+ // Next.js build tree.
321
//
285
- // We always delete the existing pm2 process (if any) and start fresh with
286
- // `node server.js`. A bare `pm2 restart` would re-execute the *old* command
287
- // (e.g. `next start --port XXXX` from a previous git-push deploy), which
288
- // fails when the working directory now contains standalone output instead of
289
- // the full Next.js build tree. Deleting first guarantees the entry point
290
- // and environment are always correct.
322
+ // If the server has an operator-managed `ecosystem.config.js`, prefer it as
323
+ // the source of truth for runtime env and pm2 settings. Otherwise fall back
324
+ // to `node server.js` with the minimal inline env needed to bind the app.
325
//
326
// The port defaults to 3000 and can be overridden with `port = XXXX` in
327
// .smb/config.toml — it must match the nginx upstream configuration.
@@ -296,24 +330,36 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
330
331
let deploy_script = format!(
332
r#"set -e
299
-APP_PATH="{remote_path}"
300
-PM2_APP="{pm2_app}"
301
-
302
-if [ ! -d "$APP_PATH" ]; then
303
- echo "Error: $APP_PATH is not a directory."
304
- exit 1
305
-fi
306
-
307
-cd "$APP_PATH"
308
-
309
-echo "Starting $PM2_APP with pm2..."
310
-if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
311
- pm2 delete "$PM2_APP"
312
-fi
313
-NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
314
-pm2 save
315
-echo "Done."
316
-"#,
333
+ APP_PATH="{remote_path}"
334
+ PM2_APP="{pm2_app}"
335
+
336
+ case "$APP_PATH" in
337
+ /*) ;;
338
+ *) APP_PATH="$HOME/$APP_PATH" ;;
339
+ esac
340
+
341
+ if [ ! -d "$APP_PATH" ]; then
342
+ echo "Error: $APP_PATH is not a directory."
343
+ exit 1
344
+ fi
345
+
346
+ cd "$APP_PATH"
347
+ mkdir -p logs
348
+
349
+ echo "Starting $PM2_APP with pm2..."
350
+ if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
351
+ pm2 delete "$PM2_APP"
352
+ fi
353
+
354
+ if [ -f ecosystem.config.js ]; then
355
+ pm2 start ecosystem.config.js --only "$PM2_APP" --env production
356
+ else
357
+ NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
358
+ fi
359
+
360
+ pm2 save
361
+ echo "Done."
362
+ "#,
363
remote_path = remote_path,
364
pm2_app = pm2_app,
365
port = port,