'npm is not available' — except npm is right there on PATH


A dashboard service started crash-looping after an overnight reboot. The journal was emphatic:

Frontend not built and npm is not available.
Install Node.js, then run:  npm install && npm run build
dashboard.service: Main process exited, code=exited, status=1/FAILURE
dashboard.service: Scheduled restart job, restart counter is at 5.

Straightforward, apparently. Install Node. Except Node was installed — the app ships its own copy — and both binaries were sitting on the service’s PATH:

$ which npm node
/home/appuser/.dashboard/node/bin/npm
/home/appuser/.dashboard/node/bin/node

The error was flatly false. And the reason it was false turned out to be more interesting than the error itself.

What was actually wrong

Run the binary directly and the real failure surfaces immediately:

$ /home/appuser/.dashboard/node/bin/node --version
node: error while loading shared libraries: libatomic.so.1:
cannot open shared object file: No such file or directory

node could not start. Not “wasn’t found” — found, and unable to execute.

The app resolves npm with something shaped like this:

npm := findNodeExecutable("npm")   // validates by running it
if npm == "" {
    say("Frontend not built and npm is not available.")
}

That validation-by-execution is a good design. It exists because a bare which check is genuinely insufficient — a dangling symlink still resolves, then fails at exec with a confusing 127. So the app runs its candidate and only trusts it if it actually works.

But the failure message never distinguished “I couldn’t find npm” from “I found npm and it wouldn’t run.” Those need completely different fixes, and the one it printed sent me toward installing software that was already there.

Why libatomic was missing

libatomic is the GCC atomics runtime. Node.js is linked against it.

  • On Debian/Ubuntu, it arrives as part of the base GCC runtime. Effectively always present, which is why nobody thinks about it.
  • On RHEL/CentOS/Rocky minimal, it is not installed. It’s one dnf install libatomic away in baseos, but nothing pulls it in by default.

This host had been migrated Debian → CentOS Stream about three weeks earlier. The migration was careful, validated, reboot-tested. And it still planted this.

Why it took three weeks to go off

This is the part worth internalising. dnf history showed libatomic had never been installed — not removed, never present. So the bundled Node had been incapable of running since the migration.

The service didn’t care, because it had a prebuilt dist/ carried over from the old host — built on Debian, where Node worked fine. Serving static assets needs no Node at all. The broken runtime sat there, unexercised, for three weeks.

Then an app update replaced the frontend sources and invalidated that build. Its own log even said so:

Stopping dashboard process: the running backend no longer
matches the updated frontend. Restart it when you're ready.

systemd’s Restart=always brought it straight back, the app tried to rebuild, needed npm for the first time since the migration — and the bomb went off.

A dependency that is only used on a rebuild path is invisible until something forces a rebuild. Your migration validation almost certainly does not force one.

And then a second fault

Installing libatomic got node --version working. It did not get the service working. The error simply changed:

npm install failed

The npm debug log had the real reason:

error notsup Not compatible with your version of node/npm: app@1.0.0
error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
error notsup Actual:   {"node":"v26.5.1","npm":"11.17.0"}

Node was fine. npm was one major version short. Upstream had bumped the requirement to npm 12 the previous day, but the bundled Node 26.5.1 ships npm 11.17. The runtime was internally inconsistent with its own package.json.

One trap while fixing that: npm config get prefix returned ~/.local, but the service resolves npm from .dashboard/node/bin first. A plain npm i -g npm@12 would have installed to a directory nothing reads. The prefix has to be aimed at the runtime you’re actually repairing:

npm install -g --prefix /home/appuser/.dashboard/node npm@12.0.2

Worth taking a backup of the old lib/node_modules/npm first — it’s 17 MB and it’s your rollback if the upgrade goes sideways.

Fixing it for good

The one-off fix is a package install. The durable fix is putting it in whatever converges your hosts, because the next bundled-Node application will hit exactly the same wall. Package names differ, so key it off the OS family:

- name: Ensure baseline packages are present
  ansible.builtin.package:
    name: "{{ baseline_packages }}"
    state: present
  vars:
    libatomic_pkg:
      RedHat: [libatomic]
      Debian: [libatomic1]
    baseline_packages: >-
      {{ ['chrony', 'curl', 'ca-certificates']
         + libatomic_pkg.get(ansible_os_family, []) }}

Unknown families add nothing rather than guess at a name that doesn’t exist.

The takeaway

Two things I’d want to remember.

“X is not available” from a tool that validates by execution means “X is not runnable.” Those are different sentences and only one of them is actionable. If you see this class of message, skip straight to running the binary yourself — ldd and --version will out-diagnose the error text in seconds.

Cross-distro migrations inherit the source distro’s implicit assumptions. Anything shipping a bundled runtime — Node, Python, Electron, a vendored toolchain — was linked on a machine whose base image quietly satisfied its dependencies. The new box may not, and you will not find out during cutover validation, because the thing that needs it hasn’t been asked to run yet. If you migrate a host, it’s worth explicitly exec’ing every bundled interpreter you shipped, before something else does it for you at 03:00.


Hostnames, paths, identifiers and product-specific strings in this post are illustrative. The failure modes, commands and fixes are real.