cron runs commands on a schedule — every minute, every Tuesday at 3 AM, first day of the month — without you lifting a finger. systemd timers do the same thing but with better logging, dependency management, and the ability to catch up on missed runs. Between these two, you can automate any recurring task on a Linux system.
In short: cron uses a five-field time expression (minute hour day month weekday) in a crontab file to schedule commands. systemd timers use a .timer unit file paired with a .service unit file, offering calendar-based and monotonic (boot-relative) scheduling with built-in journald logging. cron is simpler for quick one-liners; systemd timers are better for anything you'd want to monitor in production.
cron Fundamentals: The Five Fields
Every cron job is one line in a crontab file. The format:
# ┌───────── minute (0-59)
# │ ┌─────── hour (0-23)
# │ │ ┌───── day of month (1-31)
# │ │ │ ┌─── month (1-12 or jan-dec)
# │ │ │ │ ┌─ day of week (0-7, 0 and 7 = Sunday, or sun-sat)
# │ │ │ │ │
* * * * * command-to-execute
Each field accepts:
| Symbol | Meaning | Example |
|---|---|---|
* | Any value | * * * * * = every minute |
, | List | 1,15,30 * * * * = at minute 1, 15, and 30 |
- | Range | 0 9-17 * * * = every hour from 9 AM to 5 PM |
/ | Step | */5 * * * * = every 5 minutes |
Combine them freely: 0 */2 * * mon-fri means "at minute 0, every 2 hours, Monday through Friday."
Here are the patterns I use most often:
# Every day at 3:30 AM
30 3 * * * /home/omitsu/scripts/backup-db.sh
# Every Monday at 9 AM
0 9 * * 1 /home/omitsu/scripts/weekly-report.sh
# Every 15 minutes during business hours
*/15 9-18 * * mon-fri /home/omitsu/scripts/check-uptime.sh
# First day of every month at midnight
0 0 1 * * /home/omitsu/scripts/monthly-cleanup.sh
# Every 6 hours
0 */6 * * * /home/omitsu/scripts/sync-data.sh
Special strings: shortcuts that save typing
Instead of writing out all five fields, cron supports @ shortcuts:
| Shortcut | Equivalent | When it runs |
|---|---|---|
@reboot | — | Once at system boot |
@hourly | 0 * * * * | Top of every hour |
@daily | 0 0 * * * | Midnight every day |
@weekly | 0 0 * * 0 | Midnight every Sunday |
@monthly | 0 0 1 * * | Midnight on the 1st |
@yearly | 0 0 1 1 * | Midnight on January 1st |
@reboot is genuinely useful — I use it to start monitoring scripts and SSH tunnels that don't have proper systemd service files.
Managing cron Jobs in Practice
crontab commands
crontab -e # Edit your crontab (opens $EDITOR)
crontab -l # List current crontab entries
crontab -r # Remove your entire crontab (careful!)
crontab -u omitsu -l # View another user's crontab (root only)
The system-wide crontab lives at /etc/crontab and has an extra field — the username — between the time fields and the command:
# /etc/crontab — system-wide, includes username field
*/5 * * * * root /usr/local/bin/system-health-check.sh
Per-user crontabs (edited with crontab -e) don't need the username field.
Environment variables
cron runs with a minimal environment — way less than your interactive shell. This catches people off guard constantly. Inside a crontab, you can set variables at the top:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=omitsu@32blog.com
# If MAILTO is empty, cron won't send any mail
# MAILTO=""
30 3 * * * /home/omitsu/scripts/backup.sh
Key variables:
SHELL: Which shell runs the command (default:/bin/sh, not bash)PATH: Where to find executables. cron's default PATH is extremely limited — always use absolute paths or set PATH explicitlyMAILTO: Where to send stdout/stderr output. Empty string = no mailCRON_TZ: Override the timezone for this crontab (not available on all distros)
Where cron logs go
On most systems, cron logs to syslog:
# Debian/Ubuntu
grep CRON /var/log/syslog
# RHEL/CentOS/Fedora
grep CRON /var/log/cron
# systemd-based systems (journald)
journalctl -u cron.service --since "1 hour ago"
Common cron pitfalls
1. PATH issues. Your script works in a terminal but fails in cron because cron doesn't source .bashrc or .profile. Use absolute paths for everything — /usr/bin/python3, not python3.
2. Permissions. Scripts need execute permission (chmod +x script.sh). Also check that the cron user can read/write the files the script touches.
3. The "both day fields" trap. When you specify both day-of-month AND day-of-week, cron runs the job when either condition is true, not when both are true. 0 0 15 * fri runs on the 15th AND on every Friday — not "the 15th if it's a Friday."
4. Overlapping runs. If a job takes 10 minutes and you schedule it every 5 minutes, you get overlapping instances. Use flock to prevent this:
*/5 * * * * flock -n /tmp/my-job.lock /home/omitsu/scripts/slow-job.sh
systemd Timers: The Modern Alternative
A systemd timer is two files working together:
- A
.timerunit — defines when to run - A
.serviceunit — defines what to run
By convention, they share the same base name: backup.timer triggers backup.service.
Your first timer: a daily backup
Create the service file — this defines the actual work:
# /etc/systemd/system/backup.service
[Unit]
Description=Daily database backup
[Service]
Type=oneshot
ExecStart=/home/omitsu/scripts/backup-db.sh
User=omitsu
Create the timer file — this defines the schedule:
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 3:30 AM
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
That's it. Persistent=true means if the system was off at 3:30 AM, the backup runs as soon as the system boots — something cron simply can't do.
Monotonic vs realtime timers
systemd timers come in two flavors:
Realtime timers use OnCalendar= to fire at specific wall-clock times (like cron):
[Timer]
OnCalendar=Mon..Fri *-*-* 09:00:00
Monotonic timers fire relative to an event — boot time, timer activation, or last service completion:
[Timer]
# 5 minutes after boot
OnBootSec=5min
# 1 hour after the timer was activated
OnActiveSec=1h
# 30 minutes after the service last finished
OnUnitInactiveSec=30min
Monotonic timers solve the "every 35 minutes" problem that cron can't handle. OnUnitInactiveSec=35min means "35 minutes after the last run finished" — no overlap, no calendar-math headaches.
You can combine monotonic and realtime triggers in the same timer file. For example: OnBootSec=5min plus OnCalendar=daily means "run 5 minutes after boot AND at midnight every day."
OnCalendar Syntax and Real-World Schedules
The OnCalendar format is more expressive than cron's five fields:
DayOfWeek Year-Month-Day Hour:Minute:Second
Every part is optional. Here's how it maps to common schedules:
| cron equivalent | OnCalendar expression | Meaning |
|---|---|---|
0 * * * * | *-*-* *:00:00 or hourly | Every hour |
0 0 * * * | *-*-* 00:00:00 or daily | Every day at midnight |
0 0 * * 0 | weekly or Sun *-*-* 00:00:00 | Every Sunday |
0 0 1 * * | monthly or *-*-01 00:00:00 | First of the month |
0 9 * * 1-5 | Mon..Fri *-*-* 09:00:00 | Weekdays at 9 AM |
*/15 * * * * | *-*-* *:00/15:00 | Every 15 minutes |
0 0 1 1 * | yearly or *-01-01 00:00:00 | New Year's Day |
More complex examples:
# Every weekday at 9 AM and 6 PM
OnCalendar=Mon..Fri *-*-* 09,18:00:00
# Every quarter — first day of Jan, Apr, Jul, Oct
OnCalendar=*-01,04,07,10-01 00:00:00
# Last day of every month? systemd doesn't support "last day" natively.
# Workaround: run on the 28th-31st and check inside the script.
Validate with systemd-analyze
Before deploying a timer, test your expression:
$ systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
Original form: Mon..Fri *-*-* 09:00:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-03-23 09:00:00 JST
(in UTC): Mon 2026-03-23 00:00:00 UTC
From now: 14h left
$ systemd-analyze calendar "hourly" --iterations=5
Original form: hourly
Normalized form: *-*-* *:00:00
Next elapse: Mon 2026-03-23 20:00:00 JST
Iter. 2: Mon 2026-03-23 21:00:00 JST
Iter. 3: Mon 2026-03-23 22:00:00 JST
Iter. 4: Mon 2026-03-23 23:00:00 JST
Iter. 5: Tue 2026-03-24 00:00:00 JST
This is one of systemd timers' killer features — you can verify exactly when a timer will fire before enabling it.
Pro tip: always pass --iterations=5 when testing. Seeing the next five fire times catches off-by-one errors and timezone surprises that a single "next elapse" would miss.
cron vs systemd Timers: When to Use Which
| Feature | cron | systemd timer |
|---|---|---|
| Setup complexity | 1 line in crontab | 2 files (.timer + .service) |
| Logging | syslog/mail | journald (per-unit filtering) |
| Missed runs | Lost forever | Persistent=true catches up |
| Boot-relative | @reboot only | OnBootSec, OnStartupSec |
| Interval-based | Not possible | OnUnitInactiveSec |
| Overlap prevention | Manual (flock) | Type=oneshot built-in |
| Resource limits | None | Full cgroup support (CPU, memory, IO) |
| Dependencies | None | After=, Requires=, Wants= |
| Random jitter | RANDOM_DELAY (limited) | RandomizedDelaySec |
| User timers | crontab -e | ~/.config/systemd/user/ |
Use cron when:
- You need a one-liner that takes 30 seconds to set up
- The system doesn't run systemd (containers, old distros, macOS)
- You're writing a quick-and-dirty automation that doesn't need monitoring
Use systemd timers when:
- You want logs you can actually search (
journalctl -u backup.service) - The job must run even if the system was off at the scheduled time
- You need resource limits (don't let a runaway backup eat all RAM)
- The job depends on other services (network, database, mount points)
- You want interval-based scheduling ("every 30 min after last completion")
For 32blog, I use cron for quick checks (uptime monitoring, cert expiry alerts) and systemd timers for anything that touches the database or deployment pipeline. The deciding factor is usually: "Do I need to debug this at 2 AM?" If yes, systemd timers — because journalctl -u my-service.service beats digging through syslog.
Production Patterns and Debugging
Pattern 1: Preventing overlap with systemd
With cron, you need flock. With systemd, Type=oneshot already prevents overlap — systemd won't start a new instance while the previous one is running. But if you need more control:
[Service]
Type=oneshot
# Kill the job if it runs longer than 1 hour
TimeoutStartSec=3600
# Restart policy for transient failures
Restart=on-failure
RestartSec=60
Pattern 2: Failure notifications
cron sends mail on failure (if mail is configured). systemd gives you more options:
# /etc/systemd/system/backup.service
[Unit]
Description=Daily database backup
OnFailure=notify-failure@%n.service
Create a generic failure notification service:
# /etc/systemd/system/notify-failure@.service
[Unit]
Description=Send failure notification for %i
[Service]
Type=oneshot
ExecStart=/home/omitsu/scripts/notify-slack.sh "Unit %i failed on %H"
User=omitsu
Now any service can send a Slack notification on failure by adding OnFailure=notify-failure@%n.service.
Pattern 3: Randomized delay for distributed systems
When 50 servers all run the same backup at exactly 3:00 AM, your backup server gets hammered. Add jitter:
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=1800
# Fires randomly between 3:00 and 3:30 AM
Pattern 4: User-level timers (no root needed)
You can create timers without root access using user-level systemd:
mkdir -p ~/.config/systemd/user/
# ~/.config/systemd/user/sync-notes.timer
[Unit]
Description=Sync notes every 30 minutes
[Timer]
OnUnitInactiveSec=30min
OnBootSec=5min
[Install]
WantedBy=timers.target
# ~/.config/systemd/user/sync-notes.service
[Unit]
Description=Sync notes with remote
[Service]
Type=oneshot
ExecStart=%h/scripts/sync-notes.sh
systemctl --user daemon-reload
systemctl --user enable --now sync-notes.timer
systemctl --user list-timers
Debugging checklist
When a scheduled job isn't running:
For cron:
- Check cron is running:
systemctl status cron - Check the crontab:
crontab -l - Check syslog:
grep CRON /var/log/syslog | tail -20 - Check the script runs manually:
/path/to/script.sh - Check PATH — cron's PATH is minimal
- Check permissions on the script and any files it touches
For systemd timers:
- Check timer status:
systemctl status backup.timer - Check when it fires next:
systemctl list-timers backup.timer - Check service logs:
journalctl -u backup.service --since "1 hour ago" - Check the service runs manually:
systemctl start backup.service - Check timer file syntax:
systemd-analyze verify backup.timer
FAQ
How do I list all scheduled jobs on a system?
For cron, check multiple locations: crontab -l for the current user, sudo crontab -l -u root for root, and ls /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/ for system-level jobs. For systemd timers: systemctl list-timers --all shows every timer with its next and last fire times.
Can I run a cron job every 30 seconds?
cron's minimum granularity is 1 minute. The classic workaround is two entries:
* * * * * /path/to/script.sh
* * * * * sleep 30 && /path/to/script.sh
With systemd, use a monotonic timer: OnUnitInactiveSec=30s. But if you need sub-minute scheduling, consider whether a long-running daemon would be more appropriate.
How do I migrate a cron job to a systemd timer?
Create two files: a .service file with the command (the ExecStart= line replaces the cron command), and a .timer file with OnCalendar= matching your cron schedule. Add Persistent=true if you want missed-run recovery. Run systemd-analyze calendar to verify the schedule matches, then systemctl enable --now your.timer.
What happens to cron jobs if the system is off?
They're simply skipped — cron has no concept of catching up. If your daily backup was scheduled for 3 AM and the server was down from 2 AM to 5 AM, the backup doesn't run that day. systemd timers with Persistent=true solve this by running the job as soon as the system comes back up.
Can I use cron inside Docker containers?
Technically yes, but it's usually the wrong approach. Containers are designed to run a single process. Instead, use the host's cron or systemd timer to run docker exec container_name command, or use container orchestration scheduling (Kubernetes CronJobs, ECS Scheduled Tasks).
How do I set a timezone for a cron job?
Some cron implementations support CRON_TZ=America/New_York at the top of the crontab. If yours doesn't, wrap the command: TZ=America/New_York date inside your script. For systemd timers, set Environment=TZ=America/New_York in the service file, or use timedatectl set-timezone system-wide.
What's AccuracySec in systemd timers?
AccuracySec= controls how precisely the timer fires. The default is 1 minute — meaning a timer set for 03:00:00 might actually fire at 03:00:45. This is intentional: systemd coalesces multiple timers into fewer wake-ups to save power. Set AccuracySec=1s if you need precision, but the default is fine for most jobs.
How do I prevent a cron job from running if the previous instance is still running?
Use flock: */5 * * * * flock -n /tmp/my-job.lock /path/to/job.sh. The -n flag makes flock exit immediately (instead of waiting) if the lock is held. With systemd, Type=oneshot already prevents overlapping by default — systemd won't start a new instance while the service is active.
Wrapping Up
cron and systemd timers solve the same problem — running commands on a schedule — but they're different tools for different situations.
cron is the 30-second setup: edit crontab, paste a line, done. It's been doing this since the 1970s and it works everywhere. The five-field syntax is worth memorizing because you'll encounter it in CI/CD configs, Kubernetes CronJobs, GitHub Actions, and cloud schedulers everywhere.
systemd timers are the production-grade option: proper logging, missed-run recovery, resource limits, dependency ordering, and interval-based scheduling. The two-file setup (.timer + .service) feels heavier, but systemctl list-timers and journalctl -u my.service make debugging infinitely easier than grepping syslog.
Start with cron when you need something running in 30 seconds. Graduate to systemd timers when you need reliability, observability, or anything beyond fire-and-forget.
For more CLI automation, check make for task runners, xargs for parallel command execution, and the full CLI tools map for an overview of essential command-line tools.