Task Automation: Cron, At, and Systemd Timers

Scheduling tasks to run automatically is one of the most practical skills in system administration. Whether you need a backup script to run every night, a report to generate on the first of every month, or a one-time job to run at 3 AM, the tools covered on this page have you covered. We will look at cron (the classic scheduler), at and batch for one-time jobs, and systemd timers as a modern alternative with superior logging and reliability.

Cron Basics

Cron is a daemon that runs scheduled commands at specified times. You define your schedule in a crontab file, which you edit with crontab -e.

# Open your crontab for editing
crontab -e

# List your current crontab
crontab -l

# Remove your crontab (be careful!)
crontab -r

Cron Syntax

Each line in a crontab has five time fields followed by the command to run:

# min  hr  dom  mon  dow   command
  0    2   *    *    *     /home/user/backup.sh
Field Range Description
min 0-59 Minute
hr 0-23 Hour
dom 1-31 Day of month
mon 1-12 Month
dow 0-7 Day of week (0 and 7 are Sunday)
# Every day at 2:00 AM
0 2 * * * /home/user/backup.sh

# Every 15 minutes
*/15 * * * * /usr/local/bin/health_check.sh

# Monday through Friday at 9:30 AM
30 9 * * 1-5 /home/user/morning_report.sh

# First day of every month at midnight
0 0 1 * * /home/user/monthly_cleanup.sh

# Every Sunday at 4:00 AM
0 4 * * 0 /home/user/weekly_maintenance.sh

# Twice a day at 8 AM and 8 PM
0 8,20 * * * /home/user/check_disk.sh

Special Strings

Instead of the five-field syntax, cron supports convenient shorthand strings.

@reboot    /home/user/on_startup.sh        # Run once at boot
@daily     /home/user/daily_task.sh         # Same as: 0 0 * * *
@weekly    /home/user/weekly_task.sh        # Same as: 0 0 * * 0
@monthly   /home/user/monthly_task.sh       # Same as: 0 0 1 * *
@yearly    /home/user/yearly_task.sh        # Same as: 0 0 1 1 *
@hourly    /home/user/hourly_task.sh        # Same as: 0 * * * *

Cron PATH Issues

One of the most common cron pitfalls is the PATH environment variable. Cron runs with a minimal PATH (typically just /usr/bin:/bin), so commands that work fine in your interactive shell may fail in cron because their binary locations are not included. This is the number one cause of "it works manually but not in cron" complaints.

# Solution 1: Use absolute paths in your crontab
0 2 * * * /usr/local/bin/restic backup /home

# Solution 2: Set PATH at the top of your crontab
PATH=/usr/local/bin:/usr/bin:/bin:/home/user/.local/bin
0 2 * * * restic backup /home

# Solution 3: Set PATH inside your script
#!/usr/bin/env bash
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
restic backup /home

Logging Cron Output

By default, cron emails output to the user. On systems without a mail transport agent (which is most modern servers), output is silently discarded. Redirect it explicitly to avoid losing error messages.

# Log stdout and stderr to a file
0 2 * * * /home/user/backup.sh >> /var/log/backup.log 2>&1

# Discard output entirely
*/5 * * * * /home/user/ping_check.sh > /dev/null 2>&1

# Log errors only
0 3 * * * /home/user/cleanup.sh > /dev/null 2>> /var/log/cleanup_errors.log

Cron Environment Pitfalls

Beyond PATH, be aware that cron jobs run with a minimal environment. Variables like HOME, SHELL, USER, and LOGNAME are set, but nothing from your .bashrc or .profile is loaded. If your script depends on environment variables, set them explicitly:

# Set environment in the crontab
SHELL=/bin/bash
[email protected]
HOME=/home/deploy

Practical Crontab Example

# /tmp/crontab.example
# Environment
PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
[email protected]

# Backups
0 2 * * * /home/deploy/backup_db.sh >> /var/log/backup_db.log 2>&1
30 2 * * * /home/deploy/backup_files.sh >> /var/log/backup_files.log 2>&1

# Monitoring
*/5 * * * * /home/deploy/health_check.sh > /dev/null 2>&1

# Cleanup
0 4 * * 0 /home/deploy/cleanup_tmp.sh >> /var/log/cleanup.log 2>&1

# Reports
30 9 * * 1 /home/deploy/weekly_report.sh 2>&1 | mail -s "Weekly Report" [email protected]

# SSL certificate renewal check
0 6 * * 1 /home/deploy/check_certs.sh >> /var/log/cert_check.log 2>&1

at and batch: One-Time Jobs

While cron handles recurring schedules, at runs a command once at a specified time. batch runs a command when the system load drops below a threshold (typically 1.5).

# Run a command at a specific time
at 3:00 AM
> /home/user/one_time_migration.sh
> <Ctrl-D>

# Schedule from the command line
echo "/home/user/deploy.sh" | at 14:30

# Relative time
at now + 2 hours <<'EOF'
/home/user/reminder.sh
EOF

at now + 30 minutes <<'EOF'
echo "Check the deployment" | mail -s "Reminder" [email protected]
EOF

# Tomorrow
at 9:00 AM tomorrow <<'EOF'
/home/user/morning_task.sh
EOF

# List pending at jobs
atq

# Remove a pending job (use job number from atq)
atrm 5

# batch: runs when system load is low (default: load < 1.5)
echo "/home/user/heavy_task.sh" | batch

The at command is useful for scheduling one-off maintenance tasks, delayed notifications, or any job that should run exactly once at a specific time.

Systemd Timers

On systems running systemd (most modern Linux distributions), timer units are a powerful alternative to cron. They offer better logging (via journald), dependency management, resource controls, and the ability to run missed jobs.

Anatomy of a Timer

A systemd timer consists of two files: a .timer unit and a corresponding .service unit.

# /etc/systemd/system/backup.service
[Unit]
Description=Daily database backup

[Service]
Type=oneshot
ExecStart=/home/deploy/backup_db.sh
User=deploy
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 2 AM

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target

Key Timer Options

Option Description
OnCalendar= Calendar-based schedule (like cron)
OnBootSec= Time after boot (e.g., 5min)
OnUnitActiveSec= Time after the unit was last activated
Persistent=true Run missed executions (like anacron)
RandomizedDelaySec= Add random delay to prevent thundering herd
AccuracySec= Timer accuracy (default 1min, set to 1s for precision)

OnCalendar Syntax

The OnCalendar syntax is more readable than cron and supports a wider range of expressions.

# Every day at 2 AM
OnCalendar=*-*-* 02:00:00

# Every Monday at 9:30 AM
OnCalendar=Mon *-*-* 09:30:00

# First of every month at midnight
OnCalendar=*-*-01 00:00:00

# Every 15 minutes
OnCalendar=*:0/15

# Twice a day
OnCalendar=*-*-* 08,20:00:00

# Validate your expression
systemd-analyze calendar "*-*-* 02:00:00"

The systemd-analyze calendar command is invaluable for testing. It shows you when the next trigger will occur, eliminating guesswork.

Enabling and Managing Timers

# Reload systemd after creating/modifying unit files
sudo systemctl daemon-reload

# Enable and start the timer
sudo systemctl enable --now backup.timer

# Check timer status
systemctl status backup.timer

# List all active timers
systemctl list-timers --all

# Manually trigger the associated service (for testing)
sudo systemctl start backup.service

# View logs
journalctl -u backup.service -f

# View logs since last boot
journalctl -u backup.service -b

Persistent=true

One of the biggest advantages of systemd timers over cron is the Persistent=true option. If the system was powered off when a scheduled run should have occurred, systemd will run the job immediately upon boot. Cron has no equivalent (you would need anacron for this behavior). This is critical for backup scripts and maintenance tasks on machines that are not always running.

Cron vs Systemd Timers

Feature Cron Systemd Timer
Availability Everywhere systemd-based Linux only
Logging Must configure manually journald (built in)
Missed runs Lost (unless using anacron) Persistent=true
Resource limits None cgroups via [Service]
Dependencies None full systemd dependency graph
Ease of setup One line Two files
Debugging Difficult systemctl status, journalctl
Boot-time jobs @reboot OnBootSec=

For simple recurring tasks on any UNIX system, cron is hard to beat -- it is universal, simple, and well-understood. For production Linux servers where you want logging, reliability, and resource controls, systemd timers are the better choice.

A Complete Systemd Timer Example

Here is a real-world example: a timer that cleans up old Docker images every Sunday at 4 AM with resource limits to prevent the cleanup from impacting production workloads.

# /etc/systemd/system/docker-cleanup.service
[Unit]
Description=Clean up unused Docker images
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/bin/docker image prune -af --filter "until=168h"
CPUQuota=50%
MemoryMax=512M
# /etc/systemd/system/docker-cleanup.timer
[Unit]
Description=Weekly Docker image cleanup

[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=true
RandomizedDelaySec=600

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now docker-cleanup.timer
systemctl list-timers | grep docker

Next Steps

Automated tasks often involve advanced scripting techniques. See Advanced Bash for functions, traps, and error handling that make your scheduled scripts robust. For general command-line workflow improvements, visit CLI Productivity. Return to the Shell Scripting hub for the complete topic list.