July 22, 2026
Node installed through nvm and services managed by systemd make a poor pair out of the box. Three traps account for most failed deployments; each is simple to fix once it is understood.
systemd starts services with a deliberately minimal PATH — roughly /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin — and it does not source any login shell, so nvm's initialisation never runs. A unit whose ExecStart is a bare node or npm therefore fails immediately with exit status 127 ("command not found"), and systemd reports only the unhelpful summary Failed with result 'exit-code'.
Two things fix it: give every executable in the unit an absolute path — systemd resolves the ExecStart / ExecStartPre binary against its own PATH, not the unit's Environment=PATH — and also set Environment=PATH to include the nvm bin directory, because child processes inherit it (see the next trap).
ExecStart=/home/USER/.nvm/versions/node/vX.Y.Z/bin/node server/index.jsThis is the subtle one, and the more dangerous: pointing ExecStart at the correct Node binary is not enough. npm, npx, vite, and most Node CLIs are scripts that begin with #!/usr/bin/env node. When they run, env node searches PATH for node — and if a different Node (a system package, or a stale nvm version) comes first, the tool silently runs under the wrong version, or breaks outright. Builds and npm install steps are the usual victims, and the failure rarely names the cause.
The fix is to put the intended nvm bin directory first in the unit's Environment=PATH, so every env node lookup resolves to that version:
Environment=PATH=/home/USER/.nvm/versions/node/vX.Y.Z/bin:/usr/local/bin:/usr/bin:/binOne directory then drives both the ExecStart binary and every env node lookup beneath it.
Service output sometimes fails to surface, with no explanation of why. Make logging explicit and identifiable:
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myappjournalctl -u myapp (or journalctl -t myapp) then reliably shows the output.
Failed with result 'exit-code' is only a summary — the actual failure is one line above it. Look with:
systemctl status <unit> -l --no-pager — shows which step failed and its exit codejournalctl -u <unit> -n 50 --no-pager — shows the command's own outputCommon exit codes: 127 = executable not found (a PATH problem); 1 = the app or its build errored (read the output); an EADDRINUSE message = the port is already taken (an old instance is still running).
myapp.service[Unit] Description=My Node service After=network-online.target Wants=network-online.target [Service] Type=simple User=appuser Environment=HOME=/home/appuser Environment=PATH=/home/appuser/.nvm/versions/node/vX.Y.Z/bin:/usr/local/bin:/usr/bin:/bin WorkingDirectory=/home/appuser/app ExecStartPre=/home/appuser/.nvm/versions/node/vX.Y.Z/bin/npm run build ExecStart=/home/appuser/.nvm/versions/node/vX.Y.Z/bin/node server/index.js Restart=always RestartSec=3 StandardOutput=journal StandardError=journal SyslogIdentifier=myapp [Install] WantedBy=multi-user.target
Pin vX.Y.Z to the exact nvm version the app was built and tested against. If a step shells out to git or another system tool, give that an absolute path too (e.g. /usr/bin/git), since it is subject to the same PATH resolution as the Node binary.