EricDubé.com

Running Node.js Apps Under systemd When Node Is Managed by nvm

July 22, 2026

systemdNode.jsnvmdevopsdeployment

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.

nvm's Node is not on systemd's PATH

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.js

The env node shebang picks whatever Node is first on PATH

This 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:/bin

One directory then drives both the ExecStart binary and every env node lookup beneath it.

Logs can be invisible

Service output sometimes fails to surface, with no explanation of why. Make logging explicit and identifiable:

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

journalctl -u myapp (or journalctl -t myapp) then reliably shows the output.

Reading the real error

Failed with result 'exit-code' is only a summary — the actual failure is one line above it. Look with:

Common 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).

A working template

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.