Schedule Bash Scripts with Cron and systemd Timers

Tested on RHEL 10.2 (Coughlan) — vm1.lab.example
Package bash-5.2.26-6.el10.x86_64
cronie-1.7.0-14.el10.x86_64
systemd-257-23.el10_2.2.x86_64
util-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
Short on time?Beta Get the gist of this 15-minute read in seconds, then ask follow-up questions

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:

bash
crontab -e

Each line is five time fields followed by the command. Read them left to right:

text
minute hour day-of-month month day-of-week command

Common 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:

text
0 2 * * * /home/golinuxcloud/schedule-lab/bin/health-check.sh

For lab testing, run every minute (change the schedule before production):

text
* * * * * /usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh

The 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:

bash
crontab -l
output
* * * * * /usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh

After 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:

bash
mkdir -p ~/schedule-lab/bin ~/schedule-lab/logs ~/schedule-lab/state

The script needs a shebang so the kernel can exec it directly, an explicit PATH, and absolute paths for logs and state files:

bash
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.sh

Run it once from the lab directory to confirm the log line appears:

bash
cd ~/schedule-lab && ./bin/health-check.sh

The command exits silently on success. Check the log file:

bash
tail -1 ~/schedule-lab/logs/health-check.log
output
2026-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:

bash
tail -3 ~/schedule-lab/logs/health-check.log
output
2026-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:

text
PATH=/usr/local/bin:/usr/bin:/bin
* * * * * /home/golinuxcloud/schedule-lab/bin/health-check.sh

To force a working directory in cron without editing the script, wrap the command:

text
* * * * * cd /home/golinuxcloud/schedule-lab && ./bin/health-check.sh

systemd 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:

bash
env -i PATH="/usr/bin:/bin" /bin/bash -c "$HOME/schedule-lab/bin/health-check.sh"
output
/home/golinuxcloud/schedule-lab/bin/health-check.sh: line 4: HOME: unbound variable

Use -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:

bash
env -i HOME="$HOME" PATH="/usr/bin:/bin" /bin/bash -c \
'echo "$HOME"; command -v date; "$HOME/schedule-lab/bin/health-check.sh"'
output
/home/golinuxcloud
/usr/bin/date

HOME 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:

text
* * * * * /home/golinuxcloud/schedule-lab/bin/health-check.sh >> /home/golinuxcloud/schedule-lab/logs/cron.log 2>&1

Or 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:

bash
cat > ~/schedule-lab/health-check.env <<'EOF'
NOTE=schedule-lab
EOF
chmod 600 ~/schedule-lab/health-check.env

Reference 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:

text
* * * * * /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:

bash
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: $?"
output
second run exit code: 1

In 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:

ini
ExecStart=/usr/bin/flock -n /home/golinuxcloud/schedule-lab/state/health-check.lock /home/golinuxcloud/schedule-lab/bin/health-check.sh

Why 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 cd explicitly.
  • PATH too short — docker, kubectl, or /usr/local/bin tools are invisible unless you set PATH.
  • 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.
  • .bashrc not loaded — cron is non-interactive; aliases and nvm hooks 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:

bash
mkdir -p ~/.config/systemd/user

Write ~/.config/systemd/user/health-check.service:

ini
[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=journal

Type=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:

ini
[Unit]
Description=Run health-check every minute
Documentation=man:systemd.timer(5)

[Timer]
OnCalendar=*:*:00
Persistent=true
Unit=health-check.service

[Install]
WantedBy=timers.target

Reload user systemd and test the service before enabling the timer. User units are managed with the systemctl command and the --user flag:

bash
systemctl --user daemon-reload
systemctl --user start health-check.service

The start command exits silently on success for a oneshot unit. Confirm the run and the exact command systemd executed:

bash
systemctl --user status health-check.service
output
○ 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:

bash
systemctl --user enable --now health-check.timer
output
Created 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:

bash
systemctl --user status health-check.timer
output
● 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.service

After the trigger fires, the log shows systemd honored WorkingDirectory=:

bash
tail -1 ~/schedule-lab/logs/health-check.log
output
2026-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:

bash
systemd-analyze calendar '*:*:00'
output
  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 left

For an hourly run at minute zero, use a readable expression:

ini
OnCalendar=hourly

Validate it:

bash
systemd-analyze calendar hourly
output
  Original form: hourly
Normalized form: *-*-* *:00:00
    Next elapse: Fri 2026-09-11 21:00:00 IST

Calendar 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:

ini
OnCalendar=*-*-* 09:00:00 Asia/Kolkata

Validate that expression before enabling the timer:

bash
systemd-analyze calendar '*-*-* 09:00:00 Asia/Kolkata'

List active timers and their next run:

bash
systemctl --user list-timers --all | grep health
output
Fri 2026-09-11 20:23:00 IST      42s Fri 2026-09-11 20:22:16 IST      - health-check.timer health-check.service

Catch 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:

bash
journalctl --user -u health-check.service -n 5 --no-pager
output
Sep 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:

bash
systemctl --user disable --now health-check.timer

Add 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:

ini
[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=15m

AccuracySec= 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:

ini
[Unit]
After=network-online.target
Wants=network-online.target

network-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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)