Your cron job runs in a different environment than you do

⚡ Quick Summary
When you schedule a cron job, it runs in a nearly empty environment, unlike your local machine, which can cause unexpected failures. To ensure your scripts work as expected, test them with the same mi

A scheduled job that works perfectly when you run it by hand, and quietly does the wrong thing at 3am, is one of the most expensive bugs a self-hosted stack can have. Expensive because it doesn't look like a bug. There's no crash, no alert, and often a log line saying the work completed. I've lost count of how many late nights I've spent chasing these phantom failures — the worst one was a backup job that had been "succeeding" for six weeks while writing to a directory that didn't exist.

Running this audit on your own boxes? The Operator's Cockpit is 5 free prompts for exactly this kind of infrastructure review.

Free, no signup: the five prompts are at scholar.0xpi.com/get/cockpit.

Get the 5 free prompts →

The cause is almost always the same: cron does not give your script the environment you have. Your interactive shell has read /etc/profile, your .bashrc, whatever your dotfiles export, and a PATH with a dozen entries. Cron gives you something close to PATH=/usr/bin:/bin, a HOME, and almost nothing else. Anything your script needs that it did not load itself is simply absent. It's like showing up to a potluck where everyone else brought a dish, and you're the only one who didn't get the memo.

What follows are three shapes this takes in practice. They are worth knowing apart, because they fail differently and only one of them is loud.

Shape 1: the fallback that becomes the real value

This is the dangerous one, because the code reads as careful:

const SECRET = process.env.API_SECRET || 'dev-secret-change-me';

Under cron, API_SECRET is undefined, so the fallback is not a fallback. It is the value the job runs with, every night, permanently. If that literal is also committed to a repository, the published default is your production credential, and nothing anywhere will say so. The script works. The logs are clean. I once watched a team debug a "random" data corruption issue for three days before someone noticed the API key in the repo was the same one the cron job had been using since deployment.

The fix is to refuse rather than default. A job that exits non-zero because a secret is missing is a five-minute problem. A job that runs for months on a placeholder is not.

const SECRET = process.env.API_SECRET;
if (!SECRET) { console.error('API_SECRET not set - refusing to run'); process.exit(1); }

Shape 2: the config that silently resolves somewhere else

Applications that pick a database from an environment variable usually have a development default. Run the same script under cron without loading the env file, and it does not fail - it connects to the other database. On one job I traced, a script meant to read from Postgres fell back to a local SQLite file and died with no such table. That error was at least visible. The worse version connects successfully to an empty dev database and reports that it found nothing to do. That's the silent killer — a job that thinks it's working while the production data just sits there, untouched, night after night.

If your script is not the application, it does not inherit the application's environment loading. It has to do its own:

require('dotenv').config({ path: '/srv/app/.env' });   // explicit path, not cwd-relative

Use an absolute path. Cron's working directory is not your project directory, and a cd in the crontab line only helps until someone edits it. I've seen a crontab entry break because a teammate "cleaned up" a line that had a cd in it, assuming it was redundant.

Shape 3: success reported for work that did not happen

The third shape is the reason the first two survive so long. Consider:

doTheWork()
  .then(() => { console.log('done'); process.exit(0); })
  .catch(e => { console.error('failed', e); process.exit(1); });

That looks correct, and it is - if doTheWork() rejects on failure. Plenty of functions do not. They catch internally and resolve with something like { success: false }. The .then() branch fires, the job prints "done", exits 0, and any monitoring you have wrapped around the exit code reports a healthy run forever. As one sysadmin friend put it: "Your script isn't lying to you — it just doesn't know it's wrong."

Check the result, not just the absence of a throw:

const r = await doTheWork();
if (!r || r.success === false) { console.error('FAILED - nothing was written'); process.exit(1); }

Testing a job the way cron will actually run it

Running node /srv/app/job.js in your shell proves almost nothing, because your shell is not the environment the job will get. Strip it:

env -i PATH=/usr/bin:/bin HOME=/root /bin/sh -c 'cd /srv/app && /usr/bin/node job.js'

env -i clears the environment entirely. If the job works under that, it will work under cron. If it needs something, you will find out in a terminal instead of at 3am. This is the single most useful debugging trick I've learned in years of self-hosting — it takes ten seconds and saves you from the worst kind of debugging: the kind where you're half-asleep, squinting at logs that all say "success."

Two related habits are worth the same five minutes. Use absolute paths for the interpreter and the script, because node may not be on cron's PATH even when it is on yours. And redirect both streams to a log - >> /var/log/job.log 2>&1 - because the default behaviour is to mail output to the local user, which on most self-hosted boxes means it goes nowhere anyone reads.

The checklist

  1. Does the script load its own environment, from an absolute path?
  2. Does every process.env.X || 'default' have a default that is safe to actually run with? If not, refuse instead.
  3. Does it exit non-zero when the work did not happen, or only when something threw?
  4. Does it use absolute paths for the interpreter and the script?
  5. Are stdout and stderr both going somewhere you will look?
  6. Have you run it once under env -i?
  7. If the job is supposed to be scheduled - is it actually in crontab -l? A header comment describing a schedule is not a scheduler.

That last one sounds facetious. It is not. A file that documents the exact schedule it should run on, in a codebase where nobody ever installed the crontab line, will sit there looking maintained for as long as you let it. I've inherited codebases with three such files, each more confidently documented than the last.

Why this class is worth being systematic about

Every failure above shares a property: the system reports success. Status codes, exit codes, uptime checks and log-line greps are all blind to a job that ran, did nothing, and said it was fine. The only thing that catches them is asking the question from the other side - did the row change, is the file newer, did the message arrive - rather than asking the job whether it thinks it worked.

If you are auditing your own boxes and want a starting point, the same reasoning applies to backups, which is the place this failure mode costs the most. A backup that silently doesn't happen is worse than no backup at all — because you'll only find out when you need it. Check your restore path, not just your backup log. That's the real test.

Frequently Asked Questions

Why do my cron jobs sometimes fail, but report success in the terminal?

This is often due to the cron environment being nearly empty. When you run commands in the terminal, your shell is pre-loaded with environment variables. However, cron jobs run in a different environment, which can cause issues. To troubleshoot, test your job in a similar environment or use a tool like `env` to inspect the cron environment. Check the article for more details on why cron jobs can report success even when they fail.

How can I test a systemd timer as it would be executed in cron?

To test a systemd timer as it would be executed in cron, use the `systemd-run` command to simulate the environment. You can also modify the timer configuration to run a command that logs its environment or output using `journalctl`. This will give you insight into how the timer would behave in a cron-like environment. For more information, refer to the article or the systemd documentation.

What's the difference between the PATH in my terminal and the cron PATH?

The PATH environment variable in your terminal is usually longer than the one in cron, as it includes the directories for your shell and other installed tools. In cron, the PATH is shorter and may not include some of these directories. This can cause issues when running scripts that rely on specific tools or packages not in the cron PATH. You can modify the cron PATH using the `PATH` directive in the crontab or by setting the `PATH` environment variable in the job itself.

Why do my cron jobs fail when they run in a self-hosted environment, but not when I run them manually?

This is often due to the differences in environment between your manual runs and the cron job executions. When you run a command manually, your shell is pre-loaded with environment variables, whereas cron jobs run in a more minimalist environment. To troubleshoot, inspect the cron environment using `env` or `printenv`, and check the article for more details on how to configure your cron jobs for success in a self-hosted environment.

How can I ensure my cron jobs are configured correctly for my self-hosted stack?

To ensure your cron jobs are configured correctly, test them thoroughly using the methods mentioned in the article, such as simulating the cron environment with `systemd-run` or modifying the cron PATH. You can also consult the documentation for your specific self-hosted stack, such as Homelab or ScholarNet AI's resources on cron and environment variables.

Setting Up Your Cron Environment Right from the Start

One of the most frequent reasons cron jobs fail is a mismatch in environment variables, especially your PATH. When you run a command interactively, your shell has a rich PATH variable that tells it where to find executables like python, node, or custom scripts. Cron, however, starts with a very minimal PATH, often just /usr/bin:/bin. This means if your script calls a command that lives in /usr/local/bin or your user's ~/.local/bin, cron won't find it, leading to a "command not found" error, even if you can run it perfectly fine.

To explicitly define your environment variables for cron, you have a couple of primary options. The simplest is to declare them at the top of your crontab file, before any job definitions. For instance, you could add:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/youruser/.local/bin
HOME=/home/youruser
SHELL=/bin/bash

This ensures that every job defined below these lines inherits these specific settings. Another robust approach, especially for complex scripts, is to hardcode the absolute paths to all executables within your script itself (e.g., /usr/bin/python3 my_script.py instead of just python3 my_script.py). This eliminates any dependency on the cron PATH whatsoever, making your scripts incredibly resilient to environmental differences.

For college students managing personal

Don't Just Know It's Empty, Populate It!

Understanding that cron provides a barebones environment is the first step; the next is actively populating it. The most common culprit for cron job failures is an insufficient PATH variable. While your interactive shell knows where to find python or npm, cron often doesn't. You can explicitly set the PATH at the very top of your crontab file (e.g., PATH=/usr/local/sbin:/usr/local/

The Invisible Variables: Beyond `PATH`

While PATH is a common culprit, cron strips *many* other environment variables that your interactive shell provides, such as HOME, LANG, SHELL, USER, and even DISPLAY. Your scripts often implicitly rely on these being set. For instance, without HOME, a Python script might struggle to find configuration files in ~/.config, or a Node.js script might fail to locate its local node_modules directory.

This minimal environment is by design – it ensures cron jobs

📗 Studying for Security+, CCNA or an AWS cert?

Paste your own notes, a config, or an exam objective and get flashcards and practice questions back. Free, no signup. Built by someone who runs the same stack you do.

Turn my notes into flashcards →

Certifying this year? Study from your own material

Turn exam objectives, lab notes and docs into flashcards and practice questions. Free account saves your decks and unlocks the AI tutor. One email, no password.

Create your free account →
Free download — no signup
The Operator’s Cockpit Sample
5 multi-step LLM prompts for solo homelab operators. Proxmox · Docker · Unraid · TrueNAS. We email the PDF; that’s it.