@smbcloud / cli / commits / 83dc018

feat(deploy): support systemd --user process manager for nextjs-ssr

Add an optional `process_manager` field to the nextjs-ssr deploy config. When set to "systemd", the deploy restarts the app via a git-owned `systemctl --user restart <pm2_app>.service` (no sudo, XDG_RUNTIME_DIR set, aborts if the unit is missing) instead of pm2. Default/"pm2" is unchanged. This replaces the pm2 daemon model that could leave an orphaned next-server holding the port after a restart (the karokowe.com gateway-timeout outage); a systemd --user unit is cgroup-supervised so restart reaps the whole tree, and linger makes it boot-persistent with no `pm2 save`. - model: process_manager: Option<String> on DeployConfig and Project - config: merge deploy_config.process_manager into project - deploy: restart_stanza() helper branches pm2 vs systemd, injected into the deploy script; unit tests for both branches - docs: smbcloud-deploy-nextjs skill "Process manager: systemd --user" Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

keypair34 committed Jul 31, 2026 at 00:23 UTC 83dc018fc01a531df2c1e8883e2a8a6c9e42798e
6 files changed +159 -21
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+47 -2
@@ -1,6 +1,6 @@
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.
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 the app over SSH (PM2 by default, or a git-owned `systemctl --user` service when `process_manager = "systemd"`), and serves traffic behind Nginx.
4 ---
5
6 # smbCloud Deploy Next.js
@@ -83,6 +83,7 @@ package_manager = "pnpm"
83 pm2_app = "my-app"
84 path = "apps/web/my-app"
85 port = 3028
86 +# process_manager = "systemd" # optional; default is pm2 (see "systemd --user" below)
87 ```
88
89 Important rules:
@@ -93,8 +94,13 @@ Important rules:
94 - `deploy_repo_id` identifies the DeployRepo record backing this app — include it when available
95 - `source` is the local Next.js app directory
96 - `path` is the remote destination directory on the server
96 -- `pm2_app` is mandatory because the CLI restarts PM2 by name
97 +- `pm2_app` is mandatory because the CLI restarts PM2 by name (with
98 + `process_manager = "systemd"` this same value is the systemd `--user` unit
99 + name, `<pm2_app>.service`)
100 - `port` should be set explicitly and must match nginx
101 +- `process_manager` is optional. Omit it (or set `"pm2"`) for the default PM2
102 + path; set `"systemd"` to restart via a git-owned `systemctl --user` service
103 + instead (see "Process manager: systemd --user" below)
104 - `deployment_method = 1` may still be present, but the `kind` routing is what matters most
105
106 ## Expected remote layout
@@ -147,6 +153,45 @@ This has two consequences:
153 - the configured `port` in `.smb/config.toml` is used for the fallback start path
154 - the live server `ecosystem.config.cjs` (or `.js`) is the runtime source of truth when present
155
156 +## Process manager: systemd --user (alternative to PM2)
157 +
158 +Set `process_manager = "systemd"` in `[project]` to have the deploy restart the
159 +app through a **git-owned `systemctl --user` service** instead of PM2. The unit
160 +is named after `pm2_app` (`<pm2_app>.service`). This is the recommended target
161 +for the nbg box — a cgroup-supervised unit kills the whole process tree on
162 +`restart` (no orphaned `next-server` holding the port, the failure mode that
163 +caused the karokowe.com gateway-timeout outage) and survives reboot via linger,
164 +with no `pm2 save` to forget.
165 +
166 +The deploy restart step becomes (no sudo — the CLI SSHes as the git app-owner
167 +and drives that user's own manager):
168 +
169 +```sh
170 +export XDG_RUNTIME_DIR="/run/user/$(id -u)"
171 +UNIT="$PM2_APP.service"
172 +# fails loudly if the unit was never created
173 +systemctl --user cat "$UNIT" >/dev/null || exit 1
174 +systemctl --user restart "$UNIT"
175 +systemctl --user is-active "$UNIT" # verified after a short sleep
176 +```
177 +
178 +Prerequisites (one-time per app, done out-of-band before the first
179 +`process_manager = "systemd"` deploy):
180 +
181 +1. Enable linger for the app-owner so its units run without an active login and
182 + start at boot: `sudo loginctl enable-linger git`.
183 +2. Create the `~/.config/systemd/user/<pm2_app>.service` (+ a mode-600
184 + `<pm2_app>.env`) and cut over off PM2. Use the migration generator
185 + `pm2-to-systemd.sh <app> --apply`, which reads the live `pm2 jlist`,
186 + reconstructs `ExecStart`/env faithfully, enables the unit, and `pm2 delete`s
187 + the app.
188 +3. Set `process_manager = "systemd"` in the app's `.smb/config.toml`.
189 +
190 +After that, every `smb deploy` restarts via `systemctl --user` and the deploy
191 +aborts if the unit is missing (so a misconfigured `process_manager` can't
192 +silently leave the app down).
193 +
194 +
195 ### Environment variables — ecosystem file (standard pattern)
196
197 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).
crates/cli/src/cloud-deploy/config.rs
+3
@@ -124,6 +124,9 @@ pub(crate) async fn overlay_server_config(
124 if let Some(remote_pm2_env) = deploy_config.pm2_env {
125 config.project.pm2_env = Some(remote_pm2_env);
126 }
127 + if let Some(remote_process_manager) = deploy_config.process_manager {
128 + config.project.process_manager = Some(remote_process_manager);
129 + }
130 if let Some(remote_port) = deploy_config.port {
131 config.project.port = Some(remote_port);
132 }
crates/cli/src/cloud-deploy/process_deploy_nextjs_ssr.rs
+96 -19
@@ -85,6 +85,67 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
85 Ok(removed)
86 }
87
88 +/// Build the shell snippet that (re)starts the app after the standalone bundle
89 +/// is on the server.
90 +///
91 +/// Two supervisors are supported, selected by `process_manager`:
92 +/// - `None` / anything but `"systemd"` → **pm2** (default): delete + start
93 +/// fresh, preferring an operator-managed `ecosystem.config.cjs`/`.js`, else
94 +/// `pm2 start node -- server.js` with `PORT`/`HOSTNAME` inline.
95 +/// - `"systemd"` → a **git-owned `systemctl --user` service** named
96 +/// `<pm2_app>.service` (created out-of-band by the pm2-to-systemd migration).
97 +/// Restarted with no sudo — the deploy SSHes as the git app-owner, and
98 +/// `systemctl --user` (with `XDG_RUNTIME_DIR` set) drives that user's own
99 +/// manager. Fails loudly if the unit does not exist.
100 +///
101 +/// The returned snippet references the shell var `$PM2_APP` (set earlier in the
102 +/// deploy script). `port` is only used by the pm2 fallback path.
103 +fn restart_stanza(process_manager: Option<&str>, port: u16) -> String {
104 + let use_systemd = process_manager
105 + .map(|m| m.eq_ignore_ascii_case("systemd"))
106 + .unwrap_or(false);
107 +
108 + if use_systemd {
109 + r#" echo "Restarting $PM2_APP via systemd --user…"
110 + export XDG_RUNTIME_DIR="/run/user/$(id -u)"
111 + UNIT="$PM2_APP.service"
112 + if ! systemctl --user cat "$UNIT" >/dev/null 2>&1; then
113 + echo "Error: systemd --user unit $UNIT not found on the server."
114 + echo "Create it first with the pm2-to-systemd migration, then re-deploy."
115 + exit 1
116 + fi
117 + systemctl --user restart "$UNIT"
118 + sleep 3
119 + if ! systemctl --user is-active "$UNIT" >/dev/null 2>&1; then
120 + echo "Error: $UNIT failed to become active after restart."
121 + systemctl --user status "$UNIT" --no-pager 2>&1 | head -20 || true
122 + exit 1
123 + fi
124 + echo "Done."
125 +"#
126 + .to_string()
127 + } else {
128 + format!(
129 + r#" echo "Starting $PM2_APP with pm2..."
130 + if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
131 + pm2 delete "$PM2_APP"
132 + fi
133 +
134 + if [ -f ecosystem.config.cjs ]; then
135 + pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
136 + elif [ -f ecosystem.config.js ]; then
137 + pm2 start ecosystem.config.js --only "$PM2_APP" --env production
138 + else
139 + NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
140 + fi
141 +
142 + pm2 save
143 + echo "Done."
144 +"#
145 + )
146 + }
147 +}
148 +
149 /// Deploys a Next.js SSR app using standalone output mode.
150 ///
151 /// Requires `output: 'standalone'` in next.config.js. This produces a
@@ -99,7 +160,8 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
160 /// 4. rsync .next/standalone/ → server:path/ (server + bundled deps)
161 /// 5. rsync .next/static/ → server:path/.next/static/ (static chunks)
162 /// 6. rsync public/ → server:path/public/ (public assets)
102 -/// 7. SSH: pm2 delete + start fresh (prefer server `ecosystem.config.cjs` or `.js` if present)
163 +/// 7. SSH: restart the app — pm2 (default) or a git-owned systemd --user
164 +/// service when `process_manager = "systemd"` (see `restart_stanza`)
165 /// 8. PATCH deployment record as Done
166 pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Result<CommandResult> {
167 let source = config.project.source.as_deref().unwrap_or(".");
@@ -530,6 +592,9 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
592 )
593 };
594
595 + // Runtime supervisor: pm2 (default) or a git-owned systemd --user service.
596 + let restart_stanza = restart_stanza(config.project.process_manager.as_deref(), port);
597 +
598 let deploy_script = format!(
599 r#"set -e
600 APP_PATH="{remote_path}"
@@ -662,26 +727,11 @@ EOF
727 ln -sfn ../../server.js .next/standalone/server.js
728 fi
729
665 - echo "Starting $PM2_APP with pm2..."
666 - if pm2 describe "$PM2_APP" > /dev/null 2>&1; then
667 - pm2 delete "$PM2_APP"
668 - fi
669 -
670 - if [ -f ecosystem.config.cjs ]; then
671 - pm2 start ecosystem.config.cjs --only "$PM2_APP" --env production
672 - elif [ -f ecosystem.config.js ]; then
673 - pm2 start ecosystem.config.js --only "$PM2_APP" --env production
674 - else
675 - NODE_ENV=production PORT={port} HOSTNAME=127.0.0.1 pm2 start node --name "$PM2_APP" -- server.js
676 - fi
677 -
678 - pm2 save
679 - echo "Done."
680 - "#,
730 +{restart} "#,
731 remote_path = remote_path,
732 pm2_app = pm2_app,
733 runtime_subdir = runtime_subdir_for_shell,
684 - port = port,
734 + restart = restart_stanza,
735 ecosystem_config = ecosystem_config_content,
736 );
737
@@ -828,7 +878,11 @@ async fn mark_failed(
878
879 #[cfg(all(test, unix))]
880 mod tests {
831 - use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir};
881 + use {
882 + super::{prune_dangling_symlinks, restart_stanza},
883 + std::os::unix::fs::symlink,
884 + tempfile::tempdir,
885 + };
886
887 /// Mirrors the real failure: a pnpm compat link points at a version dir that
888 /// was never traced into the bundle, so it dangles and must be pruned, while
@@ -915,4 +969,27 @@ mod tests {
969 "the dangling root symlink is gone"
970 );
971 }
972 +
973 + #[test]
974 + fn restart_stanza_defaults_to_pm2() {
975 + for pm in [None, Some("pm2"), Some("PM2"), Some("other")] {
976 + let s = restart_stanza(pm, 3025);
977 + assert!(s.contains("pm2 start"), "pm={pm:?} should use pm2");
978 + assert!(s.contains("PORT=3025"), "pm={pm:?} injects the port");
979 + assert!(!s.contains("systemctl"), "pm={pm:?} must not touch systemd");
980 + }
981 + }
982 +
983 + #[test]
984 + fn restart_stanza_systemd_uses_user_unit_no_sudo() {
985 + let s = restart_stanza(Some("systemd"), 3025);
986 + assert!(s.contains("systemctl --user restart \"$UNIT\""));
987 + assert!(s.contains("UNIT=\"$PM2_APP.service\""));
988 + assert!(s.contains("XDG_RUNTIME_DIR=\"/run/user/$(id -u)\""));
989 + // No sudo (git drives its own user manager) and no pm2 fallback.
990 + assert!(!s.contains("sudo"), "must not require sudo");
991 + assert!(!s.contains("pm2 "), "systemd path must not call pm2");
992 + // Case-insensitive selector.
993 + assert_eq!(s, restart_stanza(Some("SystemD"), 3025));
994 + }
995 }
crates/cli/src/cloud-deploy/process_migrate.rs
+1
@@ -197,6 +197,7 @@ fn strip_project(project: &Project) -> Project {
197 package_manager: None,
198 pm2_app: None,
199 pm2_env: None,
200 + process_manager: None,
201 port: None,
202 shared_lib: None,
203 compile_cmd: None,
crates/smbcloud-model/src/deploy_config.rs
+5
@@ -35,6 +35,11 @@ pub struct DeployConfig {
35 pub pm2_app: Option<String>,
36 #[serde(default)]
37 pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
38 + /// Runtime supervisor for `nextjs-ssr` apps: `"pm2"` (default) or
39 + /// `"systemd"`. When `systemd`, deploys restart via a git-owned
40 + /// `systemctl --user restart <pm2_app>.service` instead of `pm2`.
41 + #[serde(default)]
42 + pub process_manager: Option<String>,
43 #[serde(default)]
44 pub port: Option<u16>,
45 #[serde(default)]
crates/smbcloud-model/src/project.rs
+7
@@ -99,6 +99,13 @@ pub struct Project {
99 /// Populated from the server-side App record; not written to `.smb/config.toml`.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub pm2_env: Option<std::collections::HashMap<String, serde_json::Value>>,
102 + /// Runtime supervisor for `nextjs-ssr` apps: `"pm2"` (default) or `"systemd"`.
103 + /// When `"systemd"`, the deploy restart step drives a git-owned
104 + /// `systemctl --user restart <pm2_app>.service` (created by the
105 + /// pm2-to-systemd migration) instead of `pm2 delete`/`pm2 start`.
106 + /// `pm2_app` is still used as the unit name.
107 + #[serde(default)]
108 + pub process_manager: Option<String>,
109 /// Port the standalone server binds to (default: 3000). Must match nginx upstream configuration.
110 #[serde(default)]
111 pub port: Option<u16>,