Automating recurring server tasks with Cron Jobs and Systemd Timers: the five-column cron syntax, redirecting output to log files, to the service + timer unit pairing for a more robust schedule. Complete with a daily backup script scheduling case study and the traps admins often hit.

After episode 21 where we covered system monitoring — how to observe resource usage, running processes, and logs to know the server's condition at any time — in this episode we'll shift perspective: from merely seeing problems to preventing problems through automation.
Maintaining a server is like managing an apartment. There are routines that must be done every day: taking out the trash, checking the lights, making sure the doors are locked, turning on the heater before the residents wake up. If all of that is done manually by one person, sooner or later something gets missed — and what gets missed is usually the most important thing. This is where task automation comes in: cron and systemd timers are the "assistants" that never forget, never get sick, and never take a day off. They work in the background at exactly the times you specify, and record what they did.
In the DevOps world, almost all routine operations — database backups, log rotation, TLS certificate renewal, cleaning the tmp directory, and data sync between servers — are run by schedulers like these. Mastering them isn't just learning two commands, but understanding the mental model of scheduling work correctly: when to run it, how to track its results, and what happens if it fails.
In this episode we'll cover cron first as the classic approach that still dominates, then systemd timers as a more robust modern alternative. We'll compare both in a table, practice by scheduling a daily backup script, and close with the common mistakes that most often trap new admins.
Before discussing the tools, let's understand why automation isn't just convenience, but an operational necessity.
First, consistency. A script run by a scheduler every day produces identical results over time — unlike human hands that can slip. Second, punctuality. A log-cleaning script run at 3.00 AM when traffic is quiet has a different impact than one run manually during peak hours. Third, auditability. The scheduler records when a task ran and what its results were — a trail that's invaluable when you have to answer the question "why was data lost on date X?" later on.
Imagine a real scenario: a production database backs itself up with mysqldump every night. One day the disk fails and the team must restore data from backups. Without automation, the last backup might be a week old because "we forgot yesterday". With a scheduler, the backup always exists, always on time, and is just a restore away. That's the difference between an admin who calmly faces disasters and one who panics.
Cron is the classic scheduling daemon that has accompanied Linux since the early Unix era. It works by reading a file called crontab (cron table), containing a list of lines that each determine when a command is run.
There are several crontab locations you should know:
| Location | User | When Executed |
|---|---|---|
crontab -e | Per-user | The standard cron command for a specific user |
/etc/crontab | Root (system) | System-wide, may add a user column |
/etc/cron.d/ | Root | Separate crontab files, often used by packages |
/etc/cron.hourly/, /etc/cron.daily/ | Root | Scripts inside these folders run on their folder's schedule |
To manage your own crontab, there are just three most-used commands:
crontab -e # open the current user's crontab in an editor
crontab -l # display the current crontab contents
crontab -r # delete the entire crontab (careful!)The heart of cron is the five-column syntax. Each crontab line has five time columns followed by the command to run:
* * * * * /usr/bin/backup.sh
┬ ┬ ┬ ┬ ┬
│ │ │ │ └──── day of week (0-7, 0 & 7 = Sunday)
│ │ │ └────── month (1-12)
│ │ └──────── day of month (1-31)
│ └────────── hour (0-23)
└──────────── minute (0-59)The * sign means "every value in that column". You can also write comma-separated value lists (1,15,30), ranges (1-5), and intervals (*/15 = every 15). Here are the translations of the most-used patterns:
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/15 * * * * | Every 15 minutes |
0 3 * * * | Every day at 3.00 AM |
30 5 * * 1 | Every Monday at 5.30 AM |
0 2 1 * * | Every 1st of the month at 2.00 AM |
0 0 * * 1-5 | Every workday (Mon–Fri) at midnight |
One of the facts that most often confuses admins: cron doesn't run commands with an environment like your terminal. It doesn't load ~/.bashrc, doesn't have a full PATH, and doesn't inherit environment variables from a login session. Cron only executes commands with a minimal PATH — usually enough for sh, rm, cp, and similar, but not for binaries living in /usr/local/bin/.
The result: writing crontab -e and filling in 0 3 * * * mybackup produces a command not found error in the log — not because the command is wrong, but because cron doesn't know where mybackup is. The solution is always: use absolute paths, or set PATH explicitly at the top of the crontab.
The second problem is output that's never seen. When a cron script prints output, cron collects it and sends it via local email — which on minimalist servers is often not installed (postfix isn't), so the output silently disappears. But that output is the only trace that the script ran. The solution: explicitly direct the output to a log file.
0 3 * * * /usr/local/bin/backup.sh
0 3 * * * /usr/local/bin/backup.sh >> /var/log/mycron.log 2>&1Let's dissect the marked line. >> is append — adding output to the end of a file without deleting old content (remember the redirection operators from episode 5). 2>&1 merges stderr into stdout, so errors are also recorded in the same file. The order matters: >> /var/log/mycron.log 2>&1 means "stdout goes into the file, then stderr follows the same direction". The result: one log file containing the entire execution trail — easy to grep and no output lost.
Warning
Always write absolute paths for both commands and files inside the crontab. Cron doesn't load the PATH from a login shell, so pg_dump or restic living in /usr/local/bin/ won't be found. Write /usr/local/bin/pg_dump — or, more tidily, define PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin on the first line of your crontab.
Cron is decades old and still works — but it has structural limitations that are felt on modern servers. This is where systemd timers come in as a more robust replacement.
What are the advantages? First, integration with journald. Script output is directly recorded in the journal (journalctl), no need to manually redirect output. Second, explicit dependencies. You can ensure a timer only runs after the network is online, or after another service is up. Third, flexible triggers — not only calendar-based (OnCalendar), but also monotonic ones like OnBootSec (15 minutes after boot) or OnUnitActiveSec (every 1 day after the last execution). Fourth, missed-run handling: with Persistent=true, if the server is off when the schedule arrives, the timer will run the task the moment the server comes back — behavior cron doesn't have.
The analogy: cron is like an alarm clock that still rings even when you're not home. A systemd timer is like an assistant who knows the schedule, and when you come back, he reports "something was missed yesterday, I've taken care of it".
Like all systemd units, a timer works in pairs: one .service file defining what is done, and one .timer file defining when it's done. Both go in /etc/systemd/system/:
[Unit]
Description=Daily backup service
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.shNote several important details. Type=oneshot tells systemd this service runs briefly and finishes — not a daemon that keeps running. OnCalendar=*-*-* 03:00:00 is systemd's time expression equivalent to 0 3 * * * in cron. Persistent=true handles the case of a server being off when the schedule arrives. And WantedBy=timers.target binds the timer to the special timer target.
After both files are created, enable and verify:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timerNEXT LEFT LAST PASSED UNIT ACTIVATES
Sat 2026-08-03 03:00:00 WIB 15h 19min left Fri 2026-08-02 03:00:00 WIB 8h ago backup.timer backup.service
Sat 2026-08-03 06:55:45 WIB 19h left n/a n/a fstrim.timer fstrim.service
1 timers listed.The systemctl list-timers output will show the next schedule (NEXT) and the last execution (LAST). To see the execution trail of the service, just read the journal:
journalctl -u backup.service -bTip
systemd timers excel because the execution trail is automatically recorded in journald with tidy structure and timestamps. For cron, you must set up redirection yourself (>> log 2>&1) and guess if you forget. If your server is already systemd-based — and all modern distros have used it since episode 14 — there's no strong reason to refuse timers.
Here's a concise comparison that can guide decisions:
| Aspect | Cron | Systemd Timers |
|---|---|---|
| Time syntax | 5 columns (0 3 * * *) | OnCalendar / OnBootSec / OnUnitActiveSec |
| Script output | Must redirect manually | Automatically in journald |
| Dependencies | None | Declared in [Unit] |
| Missed schedule at shutdown | Not caught up | Can catch up with Persistent=true |
| Gap between executions | None (calendar time only) | OnUnitActiveSec — easy |
| Environment | Minimal PATH | Unit environment that can be configured |
| Compatibility | All systems | Only systemd-based systems |
When to use which? If you just need "run this every midnight" on a non-systemd system (e.g. Alpine, or a minimal container), cron is still perfect. If you manage a modern server and want tidy log trails, clear dependencies, and schedules that don't easily get missed, systemd timers are the better choice.
Time to combine everything. We'll create a simple backup script, then schedule it with both approaches.
First, the backup script. This script takes a PostgreSQL database dump, compresses it, saves it in a dated directory, and cleans up files older than 7 days — so the disk doesn't fill up with old backups.
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/srv/backups/daily
mkdir -p "$BACKUP_DIR"
pg_dump -U myapp myapp_db | gzip > "$BACKUP_DIR/myapp-$(date +%F).sql.gz"
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -deleteNote set -euo pipefail on the second line — this ensures the script stops and returns a failure status if any step errors. Without it, a pg_dump failure could slip through unnoticed because the | gzip pipeline still "succeeds". Make sure the script file is executable: chmod +x /usr/local/bin/backup.sh.
Once the script is ready, schedule it. The cron version — add to crontab -e:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1The systemd timer version — two files exactly like in the previous section: backup.service runs the script, backup.timer schedules it at 3.00 AM with Persistent=true. Both achieve the same thing, but the systemd version gives you a journal trail and guaranteed execution if the server was briefly down.
Caution
Test the script manually before scheduling it. Run sudo /usr/local/bin/backup.sh once in the terminal and see if the dump forms. A script never run manually that goes straight into cron is an invitation to problems discovered only weeks later — exactly the "backup ran but the data is empty" pattern so common in the real world.
1. Forgetting absolute paths. Already discussed: cron doesn't load the full PATH. pg_dump, restic, or scripts in /usr/local/bin won't be found. Use absolute paths or set PATH at the start of the crontab.
2. Minute column errors. 0 3 * * * means exactly 3.00 AM; * 3 * * * means every minute during hour 3 (60 executions!). The one-character difference between * and 0 is the difference between "once a day" and "60 times per hour". Always verify with crontab -l after writing.
3. Output not redirected. Without >> log 2>&1, cron output is sent to a local email that isn't installed, then lost. Every cron entry on a production server must direct output to a log file.
4. Forgetting daemon-reload after creating a systemd unit. systemd caches already-known units. After writing or changing a .service/.timer file, you must run systemctl daemon-reload before the enable command processes correctly.
5. Not checking whether the timer is actually active. systemctl enable backup.timer without --now only enables it for the next boot — the timer isn't running now. Use enable --now and verify with systemctl list-timers.
In this episode 22, you've mastered the two approaches to Linux task automation. We started with cron: reading and managing the crontab with crontab -e, crontab -l, and crontab -r, understanding the five-column syntax, and redirecting output with >> /var/log/mycron.log 2>&1 so every execution is recorded. Then we moved to systemd timers: the .service + .timer unit pairing, the OnCalendar expression, Persistent=true, and the advantage of journald integration. We compared both in a table, scheduled a daily backup script, and closed with the traps admins most often hit.
Key points to take with you:
minute hour day-of-month month day-of-week — pay attention to the difference between 0 and * in the minute column.>> ... 2>&1).Persistent=true.systemctl daemon-reload then enable --now.With the ability to schedule tasks, you're ready to discuss the raw material of all that automation: logs. In the next episode 23 we'll dismantle System Logging, Rotation & Auditing — understanding the /var/log/ architecture, getting to know important logs like syslog, auth.log, and boot.log, managing rotation with logrotate so the disk doesn't fill up, and an introduction to rsyslog for centralized logging and auditd for security trails. Stay sharp!