How to Fix “Cannot Find Module” When Node.js Scripts Run From Cron in 2026
How to Fix “Cannot Find Module” When Node.js Scripts Run From Cron in 2026
The “Cannot Find Module” error under cron is almost never a missing file — it is cron running your script with a different working directory, a different PATH, and a different shell environment than your interactive terminal. The fix is to make the script resolve its own paths instead of trusting the environment it inherits.
The one-liner that resolves most cases:
cd /opt/scripts && /usr/bin/node /opt/scripts/job.js >> /var/log/job.log 2>&1
Now here is what it actually does: it pins the working directory to the directory containing the script, and it calls Node by absolute path so cron’s minimal PATH cannot pick up a different binary. That combination eliminates the two most common causes before you touch a single line of JavaScript.
Why cron and your terminal disagree about where your modules live
Cron starts jobs with a near-empty environment, so relative module resolution and PATH lookups behave differently than they do in an interactive shell. When you run node job.js from your home directory, Node resolves the entry point relative to your current working directory and then walks up from there looking for node_modules. Cron does not start in your home directory — on most distributions it starts in the invoking user’s home, but on hardened images and in containers it can be / or unset entirely.
This is the failure mode that costs people hours: the error message names the module, not the directory, so everyone hunts for a missing package that is installed perfectly well three levels up from where cron is actually standing.
Confirm the theory in one move. Add a diagnostic cron entry that dumps the environment:
* * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1; /bin/pwd >> /tmp/cron-env.txt
Wait a minute, then read /tmp/cron-env.txt. Compare PATH and the printed directory against your shell. On a stock Debian 12 or Ubuntu 24.04 install the cron PATH is typically /usr/bin:/bin, which is why a Node installed via nvm or a version manager is invisible to the job even though node --version works fine when you type it.
The head-to-head comparison: four fixes and which one you should reach for
There is no single correct fix; there are four, and the right one depends on whether the problem is the binary, the directory, or the module tree itself. The table below is the verdict, and the sections after it explain each dimension.
| Fix | Solves | Fails when | Use it if |
|---|---|---|---|
| Absolute Node path in crontab | Wrong binary / nvm Node invisible | Working directory is also wrong | You installed Node via nvm, asdf, or fnm |
cd prefix in crontab |
Relative path resolution | Script uses __dirname assumptions |
Script lives in a project directory |
require() with path.join(__dirname, ...) |
Module resolution inside the script | Module genuinely is not installed | You control the script source |
| Systemd timer instead of cron | Environment, logging, restart policy | You need cron specifically | You are on a modern Linux host |
Pinning the Node binary is the fix that resolves nvm and version-manager setups
If node is installed through nvm, asdf, or fnm, cron cannot find it because those tools work by mutating your shell’s PATH at login, and cron never sources your shell profile. The fix is to reference the resolved binary by absolute path.
Find it from an interactive shell with command -v node. You will get something like /home/deploy/.nvm/versions/node/v22.11.0/bin/node. Put that exact path in the crontab:
0 3 * * * cd /opt/scripts && /home/deploy/.nvm/versions/node/v22.11.0/bin/node ./nightly.js >> /var/log/nightly.log 2>&1
Do not do this: 0 3 * * * nvm use 22 && node nightly.js. Cron runs commands with /bin/sh, not bash, and nvm is a shell function that does not exist in that context. It will fail with nvm: not found before Node is ever reached.
The trade-off is that the version is now hardcoded in the crontab. That is a feature, not a bug — an unattended job should not silently change Node versions because someone ran nvm install. If you upgrade deliberately, edit the crontab deliberately.
Module resolution inside the script needs __dirname, not the current directory
Scripts that call require('./lib/worker') or require('some-local-package') resolve relative to the file’s location for relative specifiers but relative to the nearest node_modules walk for bare specifiers — and that walk starts from the file’s directory, not the process working directory. The distinction matters when your entry point is a wrapper.
This is the pattern that survives every environment:
const path = require('node:path');
// Resolve sibling files relative to this script, never to process.cwd()
const worker = require(path.join(__dirname, 'lib', 'worker.js'));
// Resolve project dependencies from the project root explicitly
const projectRoot = path.resolve(__dirname, '..');
const pkg = require(path.join(projectRoot, 'node_modules', 'dotenv'));
For ES modules the equivalent is import.meta.dirname, which became available without a flag in Node.js 21.2 and is stable in the Node 22 and 24 LTS lines. On Node 20 you still need fileURLToPath(import.meta.url). Name the version you are targeting in a comment so the next reader does not have to guess.
If the module genuinely is not installed, the error is real and no amount of path pinning will help. Check with ls /opt/scripts/node_modules | head before assuming an environment problem.
A working Docker setup removes the whole class of problem
Running the job in a container pins the binary, the working directory, and the module tree in one artifact, which is why it is worth the extra setup for anything that matters. The compose file below is known to work with Docker Engine 27 and the node:22-alpine image on a Linux host in 2026.
services:
nightly-job:
image: node:22-alpine
working_dir: /app
volumes:
- /opt/scripts:/app:ro
- /var/log/nightly:/logs
command: ["node", "/app/nightly.js"]
environment:
NODE_ENV: production
restart: "no"
Invoke it from cron with docker compose -f /opt/stack/compose.yaml run --rm nightly-job. The working_dir directive is the load-bearing line: it makes the container’s working directory deterministic regardless of what cron does on the host.
Systemd timers are the better default on modern Linux hosts
A systemd timer gives you a defined environment, journald logging, and a restart policy that cron does not, and it eliminates the silent-failure problem entirely. The unit file below targets systemd 255, shipped in Debian 13 and Ubuntu 24.04.
# /etc/systemd/system/nightly.service
[Unit]
Description=Nightly Node job
[Service]
Type=oneshot
WorkingDirectory=/opt/scripts
ExecStart=/usr/bin/node /opt/scripts/nightly.js
User=deploy
StandardOutput=journal
StandardError=journal
# /etc/systemd/system/nightly.timer
[Unit]
Description=Run nightly job at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
Enable it with sudo systemctl enable --now nightly.timer and read failures with journalctl -u nightly.service -n 50. Persistent=true means a missed run fires after boot, which cron does not do without extra tooling.
What to do next, and the failure mode to watch for
Start with the diagnostic cron entry and read the actual environment before changing anything — guessing at the cause is how a five-minute fix becomes an afternoon. Then apply the narrowest fix that matches what the diagnostic showed: absolute binary path for version-manager installs, a cd prefix for directory problems, __dirname for in-script resolution, and systemd timers if you want the whole category gone.
The failure mode to watch for is the silent one: cron mails job output to a local mailbox nobody reads, so a job that has been failing for weeks looks identical to one that has been succeeding. Always redirect output to a file or journald with >> /var/log/job.log 2>&1, and check that the log has a fresh timestamp after the first scheduled run. A working job is one whose evidence you can see.