skip to content

A backup script that works when you run it from its own directory fails from a crontab entry with "config/settings.conf: No such file or directory". Which working directory does cron give a scheduled command, which one does systemd give a system service, and how should the script locate its own files?

level: juniorimportance: should knowfreq 50%

answer

  1. relative to what, exactly
  2. the scheduler chooses your starting directory
  3. cron starts you in HOME
  4. systemd services start at /
  5. derive it from BASH_SOURCE

basics

~20 s

cron starts the command in the user's home directory and systemd starts a system service in the root directory, so relative paths inside the script resolve somewhere unexpected. Use absolute paths, or derive the script's own directory before opening anything.

solid answer

~40 s

A relative path is resolved against the process's current working directory, and a scheduler picks that directory, not you. cron runs the command with the working directory set to the crontab owner's home (HOME comes from the password database unless the crontab sets it); systemd runs a system service from `/` unless the unit sets `WorkingDirectory=`. So `config/settings.conf` becomes `~/config/settings.conf` or `/config/settings.conf` — neither exists. The fix is to stop depending on the caller's directory: take paths as absolute values from arguments or environment, or resolve the script's own location once at the top with `script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)` and build every path from that. If you do `cd` somewhere, check it — an unchecked `cd` that fails leaves the rest of the script operating on the wrong tree.

code

bash · 10 lines
bash
#!/usr/bin/env bash
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
config="$script_dir/config/settings.conf"

if [[ ! -r $config ]]; then
  echo "cannot read $config" >&2
  exit 1
fi

echo "using config: $config"

go deeper

for a junior

Know that a path without a leading slash is resolved against wherever the process happens to be running, and that a scheduler picks that place. Say plainly that scheduled scripts should use absolute paths.

for a middle

Be ready to state both defaults — cron uses the owner's home, a systemd system service uses / — and to write the BASH_SOURCE idiom correctly, including quoting and checking the cd.

for a senior

Show that you test the assumption before deployment by running the script from an unrelated directory, and explain why an unchecked cd turns a missing-file error into a destructive one.

for a principal

Argue for a convention across the team's jobs: paths come from configuration or an install prefix, never from the invoking directory, so the same script behaves identically from a terminal, a scheduler, and a container image.

## The rule underneath the bug Every process has a current working directory, and every path that does not start with `/` is resolved against it. Nothing in a script pins that directory — it is inherited from whoever started the process. When you test a script by typing `./backup.sh` from `/opt/app`, the working directory happens to be `/opt/app`, so `config/settings.conf` means `/opt/app/config/settings.conf`. That agreement is an accident of how you invoked it, and a scheduler does not reproduce it. ## What each scheduler hands you **cron.** A cron job starts with the working directory set to the crontab owner's home directory. `HOME` is taken from the password database entry for that user, unless the crontab itself sets `HOME=`. So a relative path resolves under `/home/deploy` or `/root`, and the open fails — or, worse, silently succeeds against a stale file that someone once left there. **systemd.** A system service's working directory defaults to the root directory, `/`. A unit can change that with `WorkingDirectory=`, but a script that requires the unit to set it is a script with an undocumented prerequisite; the next person who runs it by hand, or from a container, gets a different answer. The two defaults differ, which is the practical point: there is no single "scheduler directory" to code against. ## Making the script independent of its caller In preference order: 1. **Take absolute paths as input.** A config path passed as an argument or read from an environment variable is explicit and testable. This is the best answer when the data lives outside the code, for example `/etc/myjob/settings.conf`. 2. **Resolve the script's own directory** when the files genuinely ship alongside the script: ```bash script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) source "$script_dir/lib/common.sh" config="$script_dir/config/settings.conf" ``` Three details matter here. `${BASH_SOURCE[0]}` names the file currently being read, so it is still right when the file is sourced into another script, whereas `$0` would name the caller. The `cd ... && pwd` runs inside a command substitution, so it changes the directory of a short-lived subshell rather than the script, and it yields an absolute path with no `..` segments. The `--` stops a path beginning with a dash from being read as an option. 3. **`cd` once, deliberately, and check it.** If the whole script really is meant to operate from one place, say so at the top and treat failure as fatal: ```bash cd -- "$script_dir" || exit 1 ``` An unchecked `cd` is the dangerous form: if it fails the script keeps going in whatever directory it was already in, and the next `rm -rf ./work` or `tar -czf backup.tgz .` operates on the wrong tree. ## The limits of the idiom `dirname` does not resolve symlinks. If the script is installed as `/usr/local/bin/backup` pointing at `/opt/app/backup.sh`, `script_dir` comes out as `/usr/local/bin`, and the sibling `lib/` is not there. When you support that layout, resolve the link explicitly (GNU `readlink -f` does it, and its availability differs by platform) or, better, do not derive paths from the script at all — install the payload to a fixed absolute location and reference it. ## Proving it before the scheduler does The cheapest test is to run the script the way the scheduler will: from a directory it has never seen. ```bash cd / && /opt/app/backup.sh ``` If that passes, the working-directory assumption is gone. It is worth doing for every script you are about to schedule, because this class of failure surfaces at 03:00 on a machine nobody is watching, not on your terminal.

  • Why is `cd $(dirname $0)` on its own a weaker version of that idiom?
    Three reasons. It is unquoted, so a path containing a space breaks into separate words. It ignores the `cd` failing, leaving the script running in the wrong directory instead of stopping. And `$0` names the invoked script, so if the file is sourced into another script it points at the caller rather than at itself, which `${BASH_SOURCE[0]}` gets right.
  • The script is installed as a symlink in /usr/local/bin pointing into /opt/app. What breaks?
    `dirname` operates on the text of the path, not on the link target, so the derived directory is `/usr/local/bin` and any sibling `lib/` or `config/` next to the real script is not found. Either resolve the link explicitly before deriving the directory, or drop the derivation and reference a fixed install prefix.
  • How do you check this assumption before scheduling the script?
    Run it from a directory it has never been run from — `cd / && /opt/app/backup.sh`. If it still works, no relative path is leaning on the caller's location. Doing this once locally is far cheaper than discovering it from a job that failed overnight.

A relative path is like giving directions as "third door on the left" — perfectly clear if the listener is standing where you were standing, and useless otherwise.

saying these in an interview costs you the question

  • Assumes cron runs the job from the script's own directory
  • Thinks cron starts the job in /, like a systemd service
  • Uses cd without checking whether it succeeded
  • Claims HOME is unset under cron so ~ cannot be used
  • Fixes it by hardcoding one machine's home directory

context