| Tested on | RHEL 10.2 (Coughlan) — vm1.lab.example |
|---|---|
| Package | bash-5.2.26-6.el10.x86_64cronie-1.7.0-14.el10.x86_64systemd-257-23.el10_2.2.x86_64util-linux-2.40.2-18.el10.x86_64 (flock) |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user for user cron and user systemd units; sudo for system-wide units under /etc/systemd/system |
| Scope | Scheduler-ready Bash scripts, user crontab, PATH and working directory, cron mail and log redirection, flock overlap protection, debugging cron-like environments, systemd .service + .timer pairs, OnCalendar, Persistent=true, timer inspection with journalctl, and a cron-vs-timer comparison. Does not replace the full crontab syntax reference or the complete systemd timers guide. |
| Related guides | Shell scripting course hub |
To run a Bash script on a recurring schedule, add it to your user crontab or create a systemd timer. Cron is simpler for basic schedules; systemd timers add service-level logging, environment control, dependencies, and optional missed-run catch-up.
Scheduled jobs do not inherit your interactive shell environment. Cron runs commands with a limited environment, while systemd services use the environment and working directory defined by the unit and service manager.
Cron vs systemd Timer — Quick Decision
Both cron and systemd timers can run a Bash script on a schedule. Pick based on how much control you need over environment, logging, and missed runs.
| Choose cron when | Choose a systemd timer when |
|---|---|
You want a one-line schedule in crontab -e |
You already manage services with systemd |
| The job is a simple user script on one host | You want journald capture without extra mail setup |
| Root or user crontab is enough | You need Persistent=true catch-up after downtime |
Overlap protection is a one-line flock wrapper |
You want explicit WorkingDirectory= and EnvironmentFile= |
For a one-time future run rather than a recurring schedule, use at if it is installed — for example echo '/path/script.sh' | at now + 1 hour.
Neither scheduler reads your interactive Bash startup files the way an SSH login does. Treat PATH, working directory, and configuration as explicit settings in the script, crontab line, or unit file.
For full unit syntax and calendar expressions beyond this Bash-focused comparison, use the dedicated systemd timers guide and the crontab command reference.
Schedule a Bash Script with Cron
User cron runs jobs as your account without root. Open the crontab editor — see the crontab command for field syntax and editing options:
crontab -eEach line is five time fields followed by the command. Read them left to right:
minute hour day-of-month month day-of-week commandCommon schedules use absolute paths to the script — do not depend on cron's initial working directory:
| Schedule | Cron |
|---|---|
| Every 5 minutes | */5 * * * * /path/script.sh |
| Every hour | 0 * * * * /path/script.sh |
| Every day at 2 AM | 0 2 * * * /path/script.sh |
| Monday at 9 AM | 0 9 * * 1 /path/script.sh |
| At reboot | @reboot /path/script.sh |
Replace /path/script.sh with the full path on your system. A daily backup at 2 AM is a typical starting point:
0 2 * * * /home/golinuxcloud/schedule-lab/bin/health-check.shFor lab testing, run every minute (change the schedule before production):
* * * * * /usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.shThe flock wrapper prevents overlap when a run outlasts the interval. Replace /home/golinuxcloud with your home directory.
List the installed crontab to verify the line saved:
crontab -l* * * * * /usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.shAfter the script exists and cron fires, log lines appear under ~/schedule-lab/logs/. The next section builds that script and shows lab output.
When you finish the lab, run crontab -e and delete only the test line. Do not use crontab -r merely to remove this example — it deletes every entry in your user crontab.
Make the Script Scheduler-Ready
Start with a script that does not depend on where you ran it from or what your terminal exported. The lab script lives under ~/schedule-lab/ and appends one timestamped line to a log file.
Create the lab directories:
mkdir -p ~/schedule-lab/bin ~/schedule-lab/logs ~/schedule-lab/stateThe script needs a shebang so the kernel can exec it directly, an explicit PATH, and absolute paths for logs and state files:
cat > ~/schedule-lab/bin/health-check.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export PATH="/usr/local/bin:/usr/bin:/bin"
WORKDIR="${HOME}/schedule-lab"
LOG="${WORKDIR}/logs/health-check.log"
mkdir -p "$(dirname "$LOG")"
printf "%s health-check OK (PWD=%s)\n" "$(date -Is)" "$PWD" >> "$LOG"
EOF
chmod +x ~/schedule-lab/bin/health-check.shRun it once from the lab directory to confirm the log line appears:
cd ~/schedule-lab && ./bin/health-check.shThe command exits silently on success. Check the log file:
tail -1 ~/schedule-lab/logs/health-check.log2026-09-11T20:20:48+05:30 health-check OK (PWD=/home/golinuxcloud/schedule-lab)Install the every-minute crontab line from the previous section, wait about seventy seconds, then read the log:
tail -3 ~/schedule-lab/logs/health-check.log2026-09-11T20:20:48+05:30 health-check OK (PWD=/home/golinuxcloud/schedule-lab)
2026-09-11T20:21:01+05:30 health-check OK (PWD=/home/golinuxcloud)
2026-09-11T20:22:02+05:30 health-check OK (PWD=/home/golinuxcloud)Cron fired successfully. On this host, scheduled runs recorded PWD=$HOME, not ~/schedule-lab. Do not depend on cron's initial working directory — use absolute paths or cd explicitly in the crontab line.
The PWD= field in each log line is useful when comparing cron to systemd later.
Set PATH and Working Directory Explicitly
Cron sets a limited environment. On this host, scheduled runs saw PWD=$HOME even though the script uses absolute log paths. Scripts that rely on ./config.ini or ../data/ break unless you fix the environment.
Set PATH inside the script (as above) or at the top of the crontab:
PATH=/usr/local/bin:/usr/bin:/bin
* * * * * /home/golinuxcloud/schedule-lab/bin/health-check.shTo force a working directory in cron without editing the script, wrap the command:
* * * * * cd /home/golinuxcloud/schedule-lab && ./bin/health-check.shsystemd timers set WorkingDirectory= in the service unit instead.
Reproduce cron's reduced environment when debugging. The script uses set -u, so a missing HOME fails immediately:
env -i PATH="/usr/bin:/bin" /bin/bash -c "$HOME/schedule-lab/bin/health-check.sh"/home/golinuxcloud/schedule-lab/bin/health-check.sh: line 4: HOME: unbound variableUse -c, not -l — a login shell can load startup files and undermine the test. Cron usually sets HOME, but schedulers do not load your full interactive environment variables from .bashrc.
Print what survives in a trimmed environment before running the script:
env -i HOME="$HOME" PATH="/usr/bin:/bin" /bin/bash -c \
'echo "$HOME"; command -v date; "$HOME/schedule-lab/bin/health-check.sh"'/home/golinuxcloud
/usr/bin/dateHOME and date on PATH are enough for this script to append another log line under the reduced environment.
Capture Cron stdout/stderr
Cron normally sends job stdout and stderr by mail when mail delivery is configured. On systems without a working local mail setup, redirect output to a file or let the script handle its own logging.
Append both streams to a dedicated cron log:
* * * * * /home/golinuxcloud/schedule-lab/bin/health-check.sh >> /home/golinuxcloud/schedule-lab/logs/cron.log 2>&1Or rely on the script's internal log file (as in this lab) and keep cron stdout empty on purpose.
For structured operational logging inside the script — levels, tee, and journald tags — see Bash script logging. Cron email and file redirects are the transport; the script still decides what to print.
Use a permissions-restricted environment file for configuration that should not be embedded in the unit or crontab. EnvironmentFile= is convenient for configuration, but systemd does not treat environment variables as a secure secret-storage mechanism. For sensitive credentials, follow the application's or platform's secret-management mechanism where available.
Create a lab env file with restrictive permissions:
cat > ~/schedule-lab/health-check.env <<'EOF'
NOTE=schedule-lab
EOF
chmod 600 ~/schedule-lab/health-check.envReference it from systemd with EnvironmentFile=; from cron, use set -a; source /path/file; set +a at the top of the script when you must load variables at runtime.
In many cron implementations, an unescaped % in the command field is special: the first % terminates the command portion and remaining % characters are converted to newlines for standard input. Escape literal percent signs as \% — for example when passing date +%F to a command.
Prevent Overlap with flock
A job that runs every minute but takes two minutes can overlap itself. Two copies may write the same file or send duplicate alerts.
A systemd timer will not normally start another instance of the same active service unit. However, that does not prevent the underlying script from being started independently from cron, another unit, or the command line. Use flock when you need mutual exclusion across all invocation paths.
Wrap the crontab command so a second start exits immediately when the lock is held:
* * * * * /usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh-n makes lock acquisition non-blocking. If the lock is already held, flock exits immediately; util-linux uses status 1 by default, configurable with -E.
Demonstrate overlap on the lab host:
cd ~/schedule-lab
/usr/bin/flock -n "$HOME/schedule-lab/state/flock-demo.lock" bash -c "sleep 2; $HOME/schedule-lab/bin/health-check.sh" &
/usr/bin/flock -n "$HOME/schedule-lab/state/flock-demo.lock" "$HOME/schedule-lab/bin/health-check.sh"
echo "second run exit code: $?"second run exit code: 1In this lab, exit status 1 from flock -n means the second run skipped while the first still held the lock. Use the same wrapper in systemd when multiple entry points can run the same script:
ExecStart=/usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.shWhy a Script Works Manually but Fails in Cron
Most "works in terminal, fails in cron" reports come from a short list of causes:
- Missing shebang or execute permission — cron evaluates the crontab command using its configured shell, typically
/bin/sh. If the command directly executes a script (/path/script.sh), that file must be executable and its shebang selects the interpreter. Alternatively, invoke Bash explicitly with/usr/bin/bash /path/script.sh. - Relative paths — do not depend on cron's initial working directory; use absolute paths or
cdexplicitly. PATHtoo short —docker,kubectl, or/usr/local/bintools are invisible unless you setPATH.- Assumed environment variables — cron provides only a limited set of variables. Custom application variables, SDK settings, and tokens from your interactive shell will not appear unless you configure them explicitly.
.bashrcnot loaded — cron is non-interactive; aliases andnvmhooks are absent.%in the crontab command field without\%escaping.
Debug with the same absolute path you will schedule, then shrink the environment with env -i HOME="$HOME" PATH="/usr/bin:/bin" bash -c 'your command' until it breaks. Fix what breaks inside the script or unit file rather than expecting cron to behave like your login shell.
If the script calls bash script.sh instead of executing ./script.sh, the shebang is bypassed — that is fine when intentional, but remember cron evaluates the crontab line through its configured shell. Prefer executing the script path directly or calling /usr/bin/bash /full/path/script.sh so you control the interpreter version.
Schedule a Bash Script with a systemd Timer
A timer activates a service unit that runs your script. Split scheduling (.timer) from execution (.service).
For a user timer without root, create the unit directory:
mkdir -p ~/.config/systemd/userWrite ~/.config/systemd/user/health-check.service:
[Unit]
Description=Schedule lab health check (oneshot)
Documentation=man:systemd.service(5)
[Service]
Type=oneshot
WorkingDirectory=/home/golinuxcloud/schedule-lab
EnvironmentFile=-/home/golinuxcloud/schedule-lab/health-check.env
ExecStart=/usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh
StandardOutput=journal
StandardError=journalType=oneshot fits scripts that run and exit. WorkingDirectory= sets PWD before ExecStart. The leading - on EnvironmentFile= ignores a missing file. Replace paths with your username.
A user timer may stop when the user's systemd manager stops after logout. If the timer must run while the user is logged out, verify the host's user-manager behavior and enable lingering where appropriate — for example loginctl enable-linger "$USER". Enabling linger may require administrator policy on some systems.
Create ~/.config/systemd/user/health-check.timer:
[Unit]
Description=Run health-check every minute
Documentation=man:systemd.timer(5)
[Timer]
OnCalendar=*:*:00
Persistent=true
Unit=health-check.service
[Install]
WantedBy=timers.targetReload user systemd and test the service before enabling the timer. User units are managed with the systemctl command and the --user flag:
systemctl --user daemon-reload
systemctl --user start health-check.serviceThe start command exits silently on success for a oneshot unit. Confirm the run and the exact command systemd executed:
systemctl --user status health-check.service○ health-check.service - Schedule lab health check (oneshot)
Active: inactive (dead) since Fri 2026-09-11 20:23:12 IST
Process: 51682 ExecStart=/usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh (code=exited, status=0/SUCCESS)Manual start exercises the same ExecStart=, WorkingDirectory=, and EnvironmentFile= the timer will use — without waiting for the schedule.
Enable and start the timer:
systemctl --user enable --now health-check.timerCreated symlink '/home/golinuxcloud/.config/systemd/user/timers.target.wants/health-check.timer' → '/home/golinuxcloud/.config/systemd/user/health-check.timer'.Check that systemd registered the next trigger:
systemctl --user status health-check.timer● health-check.timer - Run health-check every minute
Loaded: loaded (/home/golinuxcloud/.config/systemd/user/health-check.timer; enabled; preset: disabled)
Active: active (waiting) since Fri 2026-09-11 20:22:16 IST
Trigger: Fri 2026-09-11 20:23:00 IST; 43s left
Triggers: ● health-check.serviceAfter the trigger fires, the log shows systemd honored WorkingDirectory=:
tail -1 ~/schedule-lab/logs/health-check.log2026-09-11T20:23:12+05:30 health-check OK (PWD=/home/golinuxcloud/schedule-lab)PWD matches WorkingDirectory= — unlike the cron lines in the same file.
System-wide jobs use the same unit shape under /etc/systemd/system/ with sudo systemctl enable --now foo.timer instead of --user.
Use OnCalendar and Verify It
OnCalendar= uses systemd calendar syntax, not five-field cron. Validate expressions before you wait for a trigger:
systemd-analyze calendar '*:*:00' Original form: *:*:00
Normalized form: *-*-* *:*:00
Next elapse: Fri 2026-09-11 20:24:00 IST
(in UTC): Fri 2026-09-11 14:54:00 UTC
From now: 42s leftFor an hourly run at minute zero, use a readable expression:
OnCalendar=hourlyValidate it:
systemd-analyze calendar hourly Original form: hourly
Normalized form: *-*-* *:00:00
Next elapse: Fri 2026-09-11 21:00:00 ISTCalendar timers use the system's local timezone unless the OnCalendar= expression specifies another timezone explicitly. Append an IANA zone name to run at a fixed local time in another region:
OnCalendar=*-*-* 09:00:00 Asia/KolkataValidate that expression before enabling the timer:
systemd-analyze calendar '*-*-* 09:00:00 Asia/Kolkata'List active timers and their next run:
systemctl --user list-timers --all | grep healthFri 2026-09-11 20:23:00 IST 42s Fri 2026-09-11 20:22:16 IST - health-check.timer health-check.serviceCatch Missed Runs with Persistent=true
With Persistent=true, systemd tracks the timer's last trigger on disk. When the timer becomes active again, it immediately triggers the service if an OnCalendar= event was missed while the timer was inactive. Multiple missed events result in one catch-up activation.
The common case is a machine or service manager that was inactive across a scheduled wall-clock event — for example after a reboot — and then becomes active again.
Cron has no built-in equivalent. Anacron covers some daily system jobs on laptops, but user cron does not catch up missed minute-level runs. Choose timers when one catch-up run after downtime matters.
Inspect Timer Runs and Logs
Service stdout and stderr go to the journal when StandardOutput=journal and StandardError=journal are set.
Recent activations for the user unit:
journalctl --user -u health-check.service -n 5 --no-pagerSep 11 20:23:12 vm1.lab.example systemd[1300]: Starting health-check.service - Schedule lab health check (oneshot)...
Sep 11 20:23:12 vm1.lab.example systemd[1300]: Finished health-check.service - Schedule lab health check (oneshot).For deeper journal filters and follow mode, see view logs with journalctl. The script's own log records its application output, while the journal records service activation, exit status, and any stdout/stderr emitted by the unit.
Disable the lab timer when finished:
systemctl --user disable --now health-check.timerAdd Randomization / Dependencies When Needed
If many hosts run the same job at exactly :00, they can create a burst of load on shared APIs or databases. Add jitter in the timer unit:
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=15mAccuracySec= controls the window within which systemd may coalesce timer events. The default for timers is normally one minute; lower it only when the job genuinely requires tighter scheduling.
Order startup after network or mounts only when the script truly depends on them:
[Unit]
After=network-online.target
Wants=network-online.targetnetwork-online.target does not guarantee that a particular remote service is reachable; the script should still handle connection failures and retries where appropriate.
Do not add After= chains "just in case" — they delay the first run and complicate debugging. Use flock when the same script can be started from cron, a timer, and the command line.
Cron vs systemd Timers — Feature Table
| Topic | User cron | systemd user timer |
|---|---|---|
| Schedule syntax | Five fields in crontab | OnCalendar= in .timer |
| Working directory | Do not depend on it; use absolute paths or cd |
WorkingDirectory= in .service |
| Environment | Limited; set in script or crontab | Environment=, EnvironmentFile= |
| Logs | Mail, file redirect, or script log | journald + optional script log |
| Missed run while disabled | Not run | Persistent=true catch-up (one activation) |
| Overlap | Add flock if needed |
Same active service unit is not started again; use flock if the script has other entry points |
| Verify schedule | crontab -l |
systemd-analyze calendar, list-timers |
| Enable | crontab line exists | systemctl --user enable --now foo.timer |
Both are viable for Bash maintenance scripts. Timers integrate cleanly with journald and unit-managed environment; cron stays the quickest path for a single line on hosts without systemd user sessions.
Troubleshooting Checklist
When ExecStart= points at the wrong file, confirm the path with the which command before editing the unit.
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing in log after cron time | No redirect and no local mail delivery | Redirect >> log 2>&1 or log inside script |
command not found under cron |
Short PATH |
Export PATH in script or crontab |
| Works only from project directory | Relative paths | Absolute paths or cd in wrapper |
HOME: unbound variable |
Cron env + set -u |
Set defaults or use absolute paths under /home/user/... |
| Timer never fires | Timer not enabled or user manager stopped | systemctl --user enable --now name.timer; check linger for logged-out users |
| Timer fires, script not found | Wrong ExecStart path |
Match path from which or an absolute lab path |
| Duplicate rows in log | Overlap | flock -n on lock file |
% eaten in cron |
Crontab special char | Escape as \% in command field |
| Missed minute-level cron runs | Machine off at trigger | Use timer with Persistent=true or accept gap |
Summary
Use cron for simple recurring Bash jobs: edit crontab -e, use absolute paths, set the required PATH, and redirect or log output explicitly. Use flock when the same script must not run concurrently across cron, timers, and manual starts.
Use a systemd timer when you want the job represented as a service with WorkingDirectory=, environment configuration, journald logs, dependencies, or Persistent=true catch-up. Test the .service directly with systemctl --user start before enabling its .timer.
References
- crontab(5) — crontab file format and
%rules - systemd.timer(5) —
OnCalendar,Persistent=,RandomizedDelaySec= - systemd.service(5) —
Type=,ExecStart=,WorkingDirectory= - systemd-analyze(1) — calendar expression verification
Frequently Asked Questions
Does cron read .bashrc?
No. Cron does not normally start your command through an interactive or login Bash shell, so you should not expect .bashrc, .bash_profile, aliases, nvm setup, or other interactive-shell initialization to be available. Put required variables and PATH in the script or crontab.
Why is PATH different under cron?
Cron jobs often run with a smaller PATH than your interactive shell. Commands installed in /usr/local/bin or custom directories may therefore be unavailable unless you set PATH explicitly or use absolute command paths.
What does Persistent=true do?
With Persistent=true, systemd tracks the timer's last trigger on disk. When the timer becomes active again, it immediately triggers the service if an OnCalendar= event was missed while the timer was inactive. Multiple missed events result in one catch-up activation.
Do I need a .service and .timer?
Yes for systemd scheduling. The timer decides when to activate; the service unit holds ExecStart= and environment. You enable the timer unit, not the service alone.
How do I prevent overlapping runs?
Wrap the command with flock -n on a lock file, or use an in-script file-descriptor lock. A timer will not normally overlap activations of the same active service unit, but cron, manual runs, and other units can still start the same script unless you use the same lock everywhere.

