Improve Next.js SSR deploy: capture output, fix pm2 restart, add port config
- Capture and print stdout/stderr for dependency install and remote restart failures, ensuring users see error details. - Change pm2 restart logic to always delete and start the process, preventing stale entry points after switching to standalone output. - Add `port` field to Project config, allowing the standalone server port to be customized (default 3000).
keypair34 committed
Apr 5, 2026 at 22:36 UTC
88b43e0362ef9926a8c7d24a08f1d8306068370f
2 files changed
+64
-33
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+61
-33
@@ -72,10 +72,12 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
72
succeed_message(&format!("Installing dependencies in {}…", source)),
73
);
74
75
- let install_status = Command::new(package_manager)
75
+ // Capture stdout/stderr so pnpm's output does not interleave with the
76
+ // spinner animation. On failure the captured output is printed for the user.
77
+ let install_output = Command::new(package_manager)
78
.args(["install", "--ignore-scripts"])
79
.current_dir(source)
78
- .status()
80
+ .output()
81
.map_err(|e| {
82
anyhow!(fail_message(&format!(
83
"Failed to spawn '{} install': {}",
@@ -83,14 +85,21 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
85
)))
86
})?;
87
86
- if !install_status.success() {
87
- install_spinner.stop_and_persist(
88
- &fail_symbol(),
89
- fail_message("Install failed. See output above."),
90
- );
88
+ if !install_output.status.success() {
89
+ install_spinner.stop_and_persist(&fail_symbol(), fail_message("Install failed."));
90
+ let stderr = String::from_utf8_lossy(&install_output.stderr);
91
+ let stdout = String::from_utf8_lossy(&install_output.stdout);
92
+ let details = if !stderr.trim().is_empty() {
93
+ stderr
94
+ } else {
95
+ stdout
96
+ };
97
+ if !details.trim().is_empty() {
98
+ eprintln!("{}", details.trim());
99
+ }
100
return Err(anyhow!(fail_message(&format!(
101
"'{} install --ignore-scripts' exited with status {}",
93
- package_manager, install_status
102
+ package_manager, install_output.status
103
))));
104
}
105
@@ -100,11 +109,10 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
109
);
110
111
// ── Step 2: pnpm build ───────────────────────────────────────────────────
112
+ // No spinner — Next.js writes its own rich progress output to the terminal.
113
+ // Blank lines before and after keep it visually separated from our log lines.
114
104
- let mut build_spinner = Spinner::new(
105
- Spinners::SimpleDotsScrolling,
106
- succeed_message(&format!("Building {} with {}…", source, package_manager)),
107
- );
115
+ println!();
116
117
let build_status = Command::new(package_manager)
118
.arg("build")
@@ -117,18 +125,20 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
125
)))
126
})?;
127
128
+ println!();
129
+
130
if !build_status.success() {
121
- build_spinner.stop_and_persist(
122
- &fail_symbol(),
123
- fail_message("Build failed. See output above."),
124
- );
131
return Err(anyhow!(fail_message(&format!(
132
"'{} build' exited with status {}",
133
package_manager, build_status
134
))));
135
}
136
131
- build_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Build complete."));
137
+ println!(
138
+ "{} {}",
139
+ succeed_symbol(),
140
+ succeed_message("Build complete.")
141
+ );
142
143
// ── Step 3: verify standalone output exists ──────────────────────────────
144
//
@@ -266,11 +276,18 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
276
//
277
// Runs `node server.js` via pm2 inside the deployed standalone directory.
278
// PORT and HOSTNAME are set so Next.js binds correctly behind nginx.
269
- // nvm is sourced from ~/.profile so the right Node version is used.
279
//
271
- // pm2 describe checks whether the process already exists:
272
- // - exists → pm2 restart (picks up new server.js in place)
273
- // - missing → pm2 start (first deploy)
280
+ // We always delete the existing pm2 process (if any) and start fresh with
281
+ // `node server.js`. A bare `pm2 restart` would re-execute the *old* command
282
+ // (e.g. `next start --port XXXX` from a previous git-push deploy), which
283
+ // fails when the working directory now contains standalone output instead of
284
+ // the full Next.js build tree. Deleting first guarantees the entry point
285
+ // and environment are always correct.
286
+ //
287
+ // The port defaults to 3000 and can be overridden with `port = XXXX` in
288
+ // .smb/config.toml — it must match the nginx upstream configuration.
289
+
290
+ let port = config.project.port.unwrap_or(3000);
291
292
let deploy_script = format!(
293
r#"set -e
@@ -284,17 +301,17 @@ fi
301
302
cd "$APP_PATH"
303
287
-echo "Starting/restarting $PM2_APP with pm2..."
304
+echo "Starting $PM2_APP with pm2..."
305
if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
289
- pm2 restart "$PM2_APP"
290
-else
291
- PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
306
+ pm2 delete "$PM2_APP"
307
fi
308
+PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
309
pm2 save
310
echo "Done."
311
"#,
312
remote_path = remote_path,
313
pm2_app = pm2_app,
314
+ port = port,
315
);
316
317
let mut restart_spinner = Spinner::new(
@@ -333,6 +350,8 @@ echo "Done."
350
"bash -s",
351
])
352
.stdin(Stdio::piped())
353
+ .stdout(Stdio::piped())
354
+ .stderr(Stdio::piped())
355
.spawn()
356
.map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
357
@@ -342,17 +361,26 @@ echo "Done."
361
.map_err(|e| anyhow!("Failed to write deploy script to SSH stdin: {}", e))?;
362
}
363
345
- let ssh_status = child
346
- .wait()
364
+ // stdin was dropped at the end of the if-let block above, sending EOF to
365
+ // remote bash. wait_with_output() reads stdout/stderr then waits for exit.
366
+ let ssh_output = child
367
+ .wait_with_output()
368
.map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))?;
369
370
drop(ssh_known_hosts_file);
371
351
- if !ssh_status.success() {
352
- restart_spinner.stop_and_persist(
353
- &fail_symbol(),
354
- fail_message("Remote restart failed. See output above."),
355
- );
372
+ if !ssh_output.status.success() {
373
+ restart_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote restart failed."));
374
+ let stderr = String::from_utf8_lossy(&ssh_output.stderr);
375
+ let stdout = String::from_utf8_lossy(&ssh_output.stdout);
376
+ let details = if !stderr.trim().is_empty() {
377
+ stderr
378
+ } else {
379
+ stdout
380
+ };
381
+ if !details.trim().is_empty() {
382
+ eprintln!("{}", details.trim());
383
+ }
384
mark_failed(
385
&deploy_ref,
386
&created_deployment,
@@ -363,7 +391,7 @@ echo "Done."
391
.await;
392
return Err(anyhow!(fail_message(&format!(
393
"SSH deploy script exited with status {}",
366
- ssh_status
394
+ ssh_output.status
395
))));
396
}
397
crates/smbcloud-model/src/project.rs
+3
@@ -68,6 +68,9 @@ pub struct Project {
68
/// PM2 process name to restart after a nextjs-ssr deploy, e.g. "my-app".
69
/// Matches the name passed to `pm2 start` on the server.
70
pub pm2_app: Option<String>,
71
+ /// Port the standalone server binds to (default: 3000). Must match nginx upstream configuration.
72
+ #[serde(default)]
73
+ pub port: Option<u16>,
74
/// Path to a shared lib directory to rsync to the server before deploying,
75
/// e.g. "lib". Used by Rails apps that depend on native gems built from
76
/// monorepo-level source. Relative to the repo root.