scheduled tasks compared head to head
Cron has been the standard for scheduled tasks on Linux for decades, but it hits clear limits around dependencies, logging, and error handling. systemd timers offer a far more observable alternative with OnCalendar syntax, journalctl integration, and automatic retry, and can be introduced step by step through a concrete migration.
Table of Contents
- 1. Cron and systemd timers at a glance
- 2. Classic crontab syntax and its pitfalls
- 3. The structural limits of cron
- 4. systemd timer units: structure and function
- 5. OnCalendar syntax in detail
- 6. Logging and journalctl integration
- 7. Automatic retry and dependencies
- 8. Practical migration: from cron job to timer unit
- 9. Cron and systemd timers head to head
- 10. Summary
- 11. FAQ
1. Cron and systemd timers at a glance
Cron has been the de facto standard for time-based tasks since the early Unix systems. The crond daemon periodically reads crontab files and starts commands at fixed points in time. This simplicity is both its strength and its weakness: a single-line entry is enough, but anything beyond "start command X at time Y" has to be rebuilt inside the script itself, whether that is logging, error handling, or dependencies on other services.
systemd timers have been part of the init system since systemd 197 and do not replace cron technically, but conceptually. A timer is its own unit file that activates a matching .service unit at defined points in time. As a result, every timer job automatically inherits all of systemd's capabilities: resource limits, dependency management, structured logging, and state tracking via systemctl status. Anyone who already uses systemd for services, which is the case on practically every modern Linux distribution, gets these capabilities for scheduled tasks essentially for free.
The switch is not worth it for every trivial one-liner, but as soon as a job is production critical, needs logging, or depends on other services, the extra configuration file pays off quickly. The following sections cover both approaches in detail and walk through a concrete migration from a classic cron job to a systemd timer unit.
2. Classic crontab syntax and its pitfalls
Crontab syntax consists of five time fields followed by the command to run: minute, hour, day of month, month, day of week. The entry 0 3 * * * means daily at 3:00 AM. User crontabs are edited with crontab -e and live under /var/spool/cron/crontabs/, while system-wide jobs sit in /etc/cron.d/ with an extra user field. This split is a common source of errors, because beginners forget that system cron files need one field more than personal crontabs.
One of the best-known traps is the minimal environment in which cron runs commands. Unlike an interactive shell, cron by default sets only a very restricted PATH variable and does not load .bashrc. A script that runs flawlessly locally in a terminal fails in the cron context because a binary like node or composer cannot be found. That is why explicit path declarations or setting PATH at the top of the crontab belong to the basic setup of any production configuration.
# crontab -e, user-level crontab entries
# Explicit PATH, because cron does not load .bashrc or /etc/profile
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=admin@mironsoft.de
# m h dom mon dow command
0 3 * * * /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1
*/15 * * * * /usr/bin/php /var/www/app/bin/cron.php --queue=default
0 2 * * 0 /usr/local/bin/cleanup-logs.sh
# /etc/cron.d/app-jobs, system-wide entry needs an extra user field
0 4 * * * www-data /usr/local/bin/reindex.sh >> /var/log/reindex.log 2>&1
3. The structural limits of cron
The biggest structural drawback of cron is the complete absence of dependency management. Cron only knows times, not conditions. If a backup job must only run after a database export has finished, that logic has to be rebuilt manually inside the script, typically with wait loops or lock files. There is no native way to tell cron "start job B once job A has succeeded". There is also no built-in mechanism to prevent a job from starting while the previous instance is still running, which can lead to overlapping processes for long-running tasks.
The second big problem is weak logging. Cron only logs that a job was started, usually as a short line in /var/log/syslog or /var/log/cron. The actual output of the command is lost unless it is explicitly redirected with >> logfile 2>&1, and even then structured metadata such as exit code, runtime, or resource usage is missing. If a job fails, MAILTO is often the only notification path left, which is practically useless in containerized or mail-less environments.
A third, often overlooked problem: cron offers no retry mechanism. If a job fails due to a brief network glitch, cron stubbornly waits until the next scheduled time, whether that is one minute or one week away. For critical maintenance tasks this can mean days of downtime in the worst case, until someone manually notices and fixes the error.
4. systemd timer units: structure and function
A systemd timer always consists of two files: a .timer unit that defines when something is triggered, and a matching .service unit that defines what gets executed. This separation is deliberate because it fully decouples execution logic from time control. The service can be tested manually and independently of the timer with systemctl start backup-db.service, without waiting for the next scheduled time. This speeds up debugging considerably compared to cron, where an error is often only visible at the next scheduled run.
Both units typically live under /etc/systemd/system/ and are reloaded with systemctl daemon-reload after every change. Only the timer is enabled, with systemctl enable --now backup-db.timer, never the service directly, since that would bypass the time control. The current status, including the next scheduled run, can be checked at any time with systemctl list-timers, which provides an overview that cron does not offer in this form.
# /etc/systemd/system/backup-db.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target mysql.service
[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/backup-db.sh
TimeoutStartSec=1800
# /etc/systemd/system/backup-db.timer
[Unit]
Description=Run backup-db.service nightly at 03:00
[Timer]
# Equivalent to cron "0 3 * * *"
OnCalendar=*-*-* 03:00:00
# Catch up missed runs after downtime (e.g. server was off)
Persistent=true
# Spread load across a random window to avoid thundering herd
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
5. OnCalendar syntax in detail
The OnCalendar directive replaces cron's five cryptic fields with a more readable syntax following the pattern weekday year-month-day hour:minute:second, where every part is optional and supports wildcards like *. OnCalendar=*-*-* 03:00:00 is equivalent to the cron expression 0 3 * * *, but is understandable without looking anything up. More complex patterns such as "every first Monday of the month" or "every 15 minutes between 8 AM and 6 PM on weekdays" can be expressed with Mon *-*-1..7 08..18:00/15:00, which would be barely readable in pure cron syntax.
A practical advantage over cron is OnBootSec and OnUnitActiveSec for relative time specifications, such as "10 minutes after boot" or "every 6 hours since the last successful run", independent of fixed clock times. With systemd-analyze calendar "Mon *-*-1..7 08:00:00" any OnCalendar expression can be tested before deployment, showing the actual next execution time, a tool for which cron has no official equivalent.
6. Logging and journalctl integration
The decisive observability advantage of systemd timers lies in the automatic integration with journald. Every output of a service started by a timer, both stdout and stderr, lands in the journal in structured form without any extra configuration, including timestamp, exit code, process ID, and the associated unit. There is no need to append >> logfile 2>&1 to every command anymore, and no log file rotation that an admin has to manage manually, because journald handles retention.
With journalctl -u backup-db.service --since today all runs of a given day can be filtered, and with journalctl -u backup-db.service -f logs can be followed live. Particularly valuable is systemctl status backup-db.service, which shows at a glance whether the last run was successful, how long it took, and which exit code it returned, information that would otherwise have to be laboriously reconstructed from scattered log files with cron. For monitoring integrations, journald can also be forwarded to central log aggregators such as Loki or ELK via ForwardToSyslog or systemd-journal-remote.
# Inspect the last run and its exit code at a glance
systemctl status backup-db.service
# Full log output of the service, newest first
journalctl -u backup-db.service --since today --no-pager
# Follow logs live while the service runs
journalctl -u backup-db.service -f
# List all timers with their next and last trigger time
systemctl list-timers --all
7. Automatic retry and dependencies
Where cron simply waits until the next scheduled time when a job fails, the service unit behind a timer has full access to systemd's restart logic. With Restart=on-failure, RestartSec=30, and StartLimitBurst=3, systemd automatically attempts up to three restarts at 30-second intervals before the job is finally marked as failed. This catches exactly the class of errors that occurs most often in practice: brief network problems, a database container that is not yet ready, or a temporarily locked lock file.
Dependencies are expressed through the standard unit directives Requires, After, and Wants. A job that is only allowed to start once MySQL is reachable gets After=mysql.service and Requires=mysql.service in its service unit, and systemd automatically ensures the correct start order. For chained processing, where job B should only run after job A has succeeded, OnSuccess=job-b.service can be set in job A's service unit, a native feature for which cron users would otherwise need external orchestration tools such as Airflow.
# /etc/systemd/system/backup-db.service (with retry and dependency handling)
[Unit]
Description=Nightly database backup with retry
Requires=mysql.service
After=network-online.target mysql.service
OnFailure=notify-admin@%n.service
[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/backup-db.sh
Restart=on-failure
RestartSec=30
StartLimitBurst=3
StartLimitIntervalSec=600
TimeoutStartSec=1800
8. Practical migration: from cron job to timer unit
The switch works best job by job, not as one big leap for the entire crontab. As an example, take a database backup that previously ran daily at 3:00 AM via cron. First, the existing cron entry is identified and documented, then the service unit is created that runs exactly the same command the cron job used to run. It is important to test the job in parallel first, before removing the old cron entry, to make sure permissions, environment variables, and working directory have been carried over correctly.
Once both unit files exist, the actual test run follows: systemctl daemon-reload loads the new units, systemctl start backup-db.service runs the service immediately by hand, independent of the timer, and journalctl -u backup-db.service shows right away whether everything worked as expected. Only once this manual test run succeeds reliably is the timer enabled with systemctl enable --now backup-db.timer, and the old cron entry is removed or commented out with crontab -e so the job does not run twice.
For an entire crontab with many entries, a gradual rollout is recommended: migrate uncritical jobs first to build up experience with the new structure, and save critical production jobs for last, once the team is comfortable with systemctl list-timers, journalctl filters, and debugging timer units. An accompanying migration log that lists the old cron line next to the new timer unit makes later troubleshooting considerably easier.
# 1. Document the existing cron entry before touching anything
crontab -l | grep backup-db
# 0 3 * * * /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1
# 2. Create service + timer unit (see sections above), then reload
sudo systemctl daemon-reload
# 3. Test the service manually, independent of the timer schedule
sudo systemctl start backup-db.service
journalctl -u backup-db.service --since "5 minutes ago" --no-pager
# 4. Only after a successful manual run: enable the timer
sudo systemctl enable --now backup-db.timer
systemctl list-timers backup-db.timer
# 5. Remove the now-redundant cron entry
crontab -e # comment out or delete the old line
9. Cron and systemd timers head to head
Both approaches have their place, but for production-critical tasks with error handling, logging, and dependencies, the difference is substantial. The following table lays out the key characteristics side by side.
| Characteristic | Cron | systemd Timer |
|---|---|---|
| Logging | Manual via redirection, unstructured | Automatically structured in journald |
| Retry on failure | No mechanism, waits until next slot | Restart=on-failure with configurable interval |
| Dependencies | Not available, must be rebuilt in the script | After, Requires, OnSuccess natively available |
| Missed runs after downtime | Ignored, no catch-up | Persistent=true catches up missed runs |
| Status inspection | No built-in overview | systemctl list-timers, systemctl status |
| Resource limits | Not available | MemoryMax, CPUQuota via the service unit |
| Entry barrier | One line, instantly understandable | Two unit files, more configuration effort |
For simple, uncritical maintenance jobs, cron remains a legitimate choice thanks to its simplicity. But as soon as observability, fault tolerance, or dependencies on other services are required, the advantages of systemd timers clearly outweigh the effort, especially since systemd already runs on practically every modern Linux server and no additional software needs to be installed.
Mironsoft
Server automation, monitoring, and deployment infrastructure
Scheduled tasks that run reliably and stay observable?
We analyze existing cron jobs, migrate production-critical tasks to systemd timers, and set up logging and monitoring so failures no longer go unnoticed.
Cron audit
Reviewing existing crontabs for critical jobs and migration needs
Timer migration
Moving cron jobs step by step to systemd timer units with retry logic
Monitoring setup
Connecting journald to central log systems and alerting on failures
10. Summary
Cron remains a pragmatic tool for simple, uncritical tasks: one entry, no extra configuration, instantly understandable. But as soon as logging, error handling, dependencies on other services, or automatic retry are required, cron shows its structural limits clearly. systemd timers solve these problems natively: OnCalendar replaces the cryptic five-field syntax with readable expressions, journald delivers structured logging with no extra effort, and Restart=on-failure automatically catches transient errors without a job having to wait until the next scheduled time.
The migration does not have to happen all at once. A gradual switch, starting with uncritical jobs and a clean test phase per service unit, minimizes risk while building trust and experience with the new structure at the same time. systemd is already available on practically every modern Linux server, so no additional software is needed, just a deliberate decision about which jobs benefit most from better observability.
Cron vs. systemd Timers: the essentials at a glance
Cron limitations
No dependency management, weak logging, no retry on failure. Errors often go unnoticed.
OnCalendar syntax
Readable time expressions instead of cryptic five-field syntax, testable with systemd-analyze calendar.
journalctl integration
Structured logging without manual redirection, instant status overview via systemctl status.
Retry & dependencies
Restart=on-failure, After, and Requires solve problems cron does not even know exist.