Systemd Timers: Time to Retire cron

In this post
If you have been managing Debian or Ubuntu servers for any length of time, you probably have a comfort relationship with cron. One line in the crontab, five scheduling fields, and the path to a script — done. cron has worked this way since the 1970s, and that simplicity is exactly what kept it relevant for so long.
The problem is that “it works” and “it works well in 2026” are different things. When a job fails silently at three in the morning, when you need to figure out which of twenty crontabs scattered across the system contains that one specific task, or when the server reboots and simply misses the execution that should have happened during downtime — those are the moments when cron shows it was designed for an era with different expectations around observability and resilience.
Systemd timers have been around since 2014 and are part of every systemd-based distribution. Not an extra package, not a third-party tool — infrastructure that is already running on your server right now. This post explains the reasoning behind the switch, the mental model needed to work with timers, and what they concretely offer over cron.
Why Touch What Already Works#
cron in Historical Context#
cron was born in Unix V7 in 1979. The idea was elegant in its simplicity: a daemon reads a scheduling table, checks every minute whether any entry matches the current time, and if it does, runs the associated command. This model has survived practically unchanged for nearly five decades, which says a lot about the soundness of the original concept.
The five-field syntax — minute, hour, day of month, month, and day of week — became a kind of lingua franca among system administrators. Even people who do not work with Linux daily have encountered some variation of it inside CI/CD pipelines, cloud services, and automation tools that adopted the format as a de facto standard for expressing recurring schedules.
Where cron Shows Its Age#
cron does one thing and does it reliably, but the ecosystem around it has changed. On a modern server with dozens of scheduled tasks, certain limitations accumulate and start to weigh.
The most immediate one is visibility. cron has no logging of its own — job output goes to the user’s local email (if an MTA is configured) or simply disappears. Figuring out whether a job ran, how long it took, and why it failed requires you to implement output redirection, log rotation, and some notification mechanism yourself. It is work that the administrator ends up repeating for each script, in slightly different ways, with no standardization.
Another issue is dispersion. Jobs can live in root’s crontab, in other users’ crontabs, in /etc/crontab, in files inside /etc/cron.d/, and in the cron.daily, cron.hourly directories and so on. The crontab -l command shows only the current user’s crontab, so building a complete map of everything scheduled on the system means poking around several places. There is no equivalent to systemctl list-timers — a consolidated view with the next execution, the last execution, and the status of each job.
Finally, cron has no notion of dependencies or state. If the server was powered off when a backup was supposed to run, cron simply skips that execution — there is no native mechanism to recover missed jobs. Likewise, if a job depends on the network being available or another service being active, it is up to the script to check that on its own. These are solvable problems, but the solution always lives outside of cron, in the form of wrappers, locks with flock, manual checks, and extra layers of complexity that pile up over time.
How Systemd Timers Work#
The Two-File Model: service + timer#
The most important conceptual difference between cron and systemd timers is the separation between “what to run” and “when to run it.” In cron, everything lives on the same line — the schedule and the command sit together in the crontab. With systemd timers, those responsibilities are split into two distinct files: a service unit (.service) and a timer unit (.timer).
The .service file describes the task itself. It defines which command to run, under which user, with which environment variables, what to do on failure, and which dependencies must be satisfied before execution. It is the same kind of unit file that systemd uses to manage every other service on the system — the difference is that instead of running continuously as a daemon, it uses Type=oneshot to indicate that it runs once and exits.
The .timer file handles scheduling exclusively. It defines when the corresponding service should be triggered, with what precision, whether missed executions should be recovered, and how much randomization to apply to the trigger time. By convention, systemd automatically associates a timer with the service of the same name — if the timer is called backup.timer, it will trigger backup.service without any explicit configuration linking the two.
This separation feels bureaucratic at first (two files where one line used to suffice), but the practical gain is significant. The service can be tested in isolation at any time with systemctl start backup.service, without waiting for the scheduled time or touching the timer. The timer can be adjusted, disabled, or replaced without touching the execution logic. And both show up in the standard systemd ecosystem — visible in systemctl status, with logs in journalctl, subject to the same resource and security policies as any other unit.
Calendar Timers (Realtime) vs. Monotonic Timers#
Systemd timers fall into two categories that reflect two fundamentally different ways of thinking about time.
Calendar timers, configured with the OnCalendar directive, fire at absolute dates and times. They are the direct equivalent of what cron does: “every day at 2 AM,” “every Monday at 8 AM,” “on the first day of each month at 3:30 AM.” The reference is the system clock, and the timer repeats according to the defined pattern regardless of when the system was started or how much time has passed since the last execution.
Monotonic timers work with durations relative to some system event. The most common directives are OnBootSec (time after boot), OnStartupSec (time after systemd has started), OnUnitActiveSec (time after the last activation of the associated service), and OnUnitInactiveSec (time after the service last became inactive). A timer configured with OnBootSec=5min and OnUnitActiveSec=1h will run five minutes after boot and then repeat every hour — always measuring from the end of the previous execution, not from a fixed point on the clock.
The distinction matters in practice. Calendar timers are the natural choice for tasks that need to happen at specific times — nightly backups, log rotation, daily reports. Monotonic timers make more sense for tasks that depend on intervals between executions — metrics collection, health checks, periodic cache cleanup — especially on machines that are not powered on 24 hours a day, like laptops and desktops.
The OnCalendar Format and How to Validate It with systemd-analyze#
The OnCalendar syntax follows the pattern DayOfWeek Year-Month-Day Hour:Minute:Second, where the day of the week is optional and any field can use * to mean “any value.” A few examples make the format clearer than any abstract explanation:
*-*-* 02:00:00— every day at 2 AM (equivalent to0 2 * * *in cron)Mon..Fri *-*-* 08:00:00— weekdays at 8 AM*-*-01 03:30:00— first day of each month at 3:30 AM*-*-* *:0/15— every 15 minutes
Systemd also accepts human-readable shortcuts like daily, hourly, weekly, and monthly, which are internally translated into full expressions in the format above.
One concrete advantage over cron syntax is the ability to stack multiple OnCalendar directives in the same timer. A schedule that runs at different times on weekdays and weekends — something that in cron would require two separate entries — can live in a single .timer file with two OnCalendar lines.
Before activating a timer, the systemd-analyze calendar command lets you validate and visualize the expression with no risk. Running systemd-analyze calendar "Mon..Fri *-*-* 08:00:00" makes systemd show the normalized form of the expression and calculate the next trigger dates. It is the kind of tool cron never had — a way to test the schedule before putting it in production, instead of waiting to see if the job fires at the right time.
What Timers Do That cron Does Not#
Integrated Logging with journald#
When a cron job fails, the investigation usually starts with an uncomfortable question: “where did the output of this script go?” Depending on how the job was written, the answer might be a log file in some corner of the filesystem, the local mail spool, or simply nowhere. Every administrator solves this their own way — redirecting stderr to a file, configuring an MTA to deliver local emails, adding logger calls inside scripts — and the result is a patchwork where each job has its own archaeology of logs.
With systemd timers, all standard and error output from the service goes automatically to the journal. No configuration, no redirection, no MTA dependency. The command journalctl -u backup.service shows the complete execution history for that service with precise timestamps, and filters like --since yesterday or --priority err let you slice exactly what matters. The timer’s output and the service’s output live in the same logging system that the rest of systemd uses, so correlating events across different system components stops being an exercise in grepping through multiple files.
In practice, this means the question shifts from “where is the log?” to “what does the log say?” — which is the question that actually matters when something goes wrong at three in the morning.
Persistent=true and Missed Executions#
cron operates on a simple premise: if the system is on at the moment a job is supposed to run, it runs. If it is not, the execution is lost without warning and without recovery. For tasks on servers that stay up around the clock, this is rarely a problem. But for any scenario involving planned reboots, maintenance windows, kernel updates that require a restart, or machines that occasionally get shut down, this is a real gap.
The Persistent=true directive in the .timer file addresses this directly. When enabled, systemd writes the timestamp of the last successful timer execution to disk. On the next boot, it compares that timestamp with the schedule and, if it detects that one or more executions were missed while the system was down, triggers the service as soon as possible. The behavior is deterministic and visible — systemctl list-timers shows both the last and next execution, so confirming that recovery happened is trivial.
Replicating this behavior with cron would require maintaining external control files, checking timestamps at the beginning of each script, and programmatically deciding whether to proceed — logic that every administrator would have to implement, test, and maintain on their own.
Dependencies Between Units#
A common pattern in cron jobs is starting the script with manual checks: testing whether the network is reachable, verifying that a database is responding, checking whether a remote filesystem is mounted. These guards exist because cron has no awareness of system state — it fires the command at the scheduled time and whatever happens next is the script’s problem.
Systemd timers inherit the dependency system of systemd itself. The After=, Requires=, and Wants= directives in the .service file let you declare that the task should only run after certain units are active. A backup that depends on an NFS share can declare After=mnt-backup.mount and Requires=mnt-backup.mount — if the mount point is not available, systemd does not even attempt to run the service, and the reason is recorded in the journal. A job that needs connectivity can use After=network-online.target and Wants=network-online.target instead of looping while trying to resolve DNS — this pattern shows up in practice in the reverse SSH tunnel service I use to reach machines behind NAT.
This does not eliminate every need for checks inside scripts — application dependencies, API states, and business conditions remain the job’s responsibility. But the infrastructure layer (network, mounts, system services) can be handled declaratively, in the same place where the rest of the service configuration lives.
Resource Control and Sandboxing#
Every cron job runs essentially without limits. If a script consumes all available memory, saturates the CPU, or writes until the disk is full, the impact falls on the entire system. Tools like nice, ionice, and ulimit exist, but they need to be invoked explicitly inside each job, and the granularity of control is limited.
Because services triggered by timers are regular systemd units, they have access to the same set of resource controls available to any other service. Directives like CPUQuota=50%, MemoryMax=512M, and IOWeight=100 can be declared directly in the .service file, imposing limits via cgroups without any modification to the script being executed. A backup job that should not consume more than half the CPU and half a gigabyte of RAM expresses that in two lines of configuration, and systemd enforces it.
Beyond resources, systemd offers security directives that restrict what the service can do on the system. ProtectHome=true prevents access to user home directories, ProtectSystem=strict makes the root filesystem read-only (except for paths explicitly allowed with ReadWritePaths=), NoNewPrivileges=true blocks privilege escalation, and PrivateTmp=true gives the service an isolated /tmp. None of these protections exist in the cron model, where every job runs with the full permissions of the user that scheduled it, with no additional isolation.
From Theory to Practice: Anatomy of a Timer#
Minimal .service Structure#
A .service file for use with timers needs very little. The example below shows a unit that runs a script to clean up old logs:
[Unit]
Description=Clean up logs older than 30 days
[Service]
Type=oneshot
ExecStart=/usr/local/bin/clean-logs.sh
The [Unit] section contains only the description, which appears in the output of systemctl list-timers and systemctl status. The [Service] section sets the type to oneshot — indicating that the process runs, exits, and that counts as success — and the absolute path to the command in ExecStart.
There is no [Install] section because the service is not enabled directly. The timer is what gets enabled; the service exists only to be triggered by it.
A few optional directives worth knowing from the start:
[Service]
Type=oneshot
User=backup
Group=backup
ExecStart=/usr/local/bin/clean-logs.sh
TimeoutStartSec=5min
User and Group define which account the process runs under — without them, the default is root. TimeoutStartSec sets a maximum execution time, after which systemd kills the process and marks the service as failed. For scripts that can hang waiting on a network resource or a lock, this limit prevents the task from staying stuck indefinitely.
The file should be saved in /etc/systemd/system/ with the .service extension. Following the example: /etc/systemd/system/clean-logs.service.
Minimal .timer Structure#
The corresponding timer must share the same base name as the service. For clean-logs.service, the file will be clean-logs.timer:
[Unit]
Description=Run log cleanup daily at 3 AM
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
The [Timer] section is where the scheduling logic lives. OnCalendar defines the trigger time — in this case, every day at 3 AM. Persistent=true ensures that if the system is off at 3 AM, the execution happens as soon as possible after boot.
The [Install] section with WantedBy=timers.target is what allows the timer to be enabled with systemctl enable. When enabled, systemd includes this timer in the timers.target, which is automatically activated during boot. Unlike the .service, the [Install] section is mandatory here — without it, the timer works if started manually but does not survive a reboot.
For a monotonic timer, the [Timer] section would look different:
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
This timer fires two minutes after boot and then repeats every hour, measuring from the end of the previous execution. Note that Persistent=true does not apply to monotonic timers — the directive only makes sense with OnCalendar, where there is an absolute time to compare against.
The file goes in the same directory: /etc/systemd/system/clean-logs.timer.
Activating, Verifying, and Monitoring#
With both files in place, the activation sequence is always the same. First, systemd needs to reload its definitions to see the new units:
sudo systemctl daemon-reload
Then, enable and start the timer:
sudo systemctl enable clean-logs.timer
sudo systemctl start clean-logs.timer
The enable creates the symbolic link so the timer activates on every boot. The start puts it into operation immediately without waiting for the next reboot. Both operations can be combined with the --now flag:
sudo systemctl enable --now clean-logs.timer
To confirm the timer is active and check when the next execution will happen:
systemctl list-timers | grep clean-logs
The output shows the next scheduled execution, how much time remains, when the last execution happened, and the name of the associated unit — all on one line. For a more detailed view:
systemctl status clean-logs.timer
Before waiting for the scheduled time, it makes sense to test the service in isolation to make sure it works:
sudo systemctl start clean-logs.service
And then check the output in the journal:
journalctl -u clean-logs.service -n 20
If the service completed without errors, the timer can handle the scheduling from here. If something failed, the journal will show exactly what happened — no hunting for log files across the filesystem, no depending on local email, no guessing whether the script even ran.
Reading Logs with journalctl#
The systemd journal is the automatic destination for everything services write to stdout and stderr. It requires no configuration, does not depend on output redirection in the script, and does not need a working MTA. Knowing how to navigate it is what turns the theoretical advantage of integrated logging into a practical daily gain.
Logs for a Specific Service#
The most common filter is by unit. To see all entries for the log cleanup service used in earlier examples:
journalctl -u clean-logs.service
The output includes timestamps, the process PID, and everything the script sent to standard output and error. Entries appear in chronological order, oldest to newest.
To see only the most recent executions without scrolling through the entire history, the -n flag limits the number of lines:
journalctl -u clean-logs.service -n 30
And to follow output in real time while the service runs — useful when testing with systemctl start — the -f flag works like tail -f:
journalctl -u clean-logs.service -f
Filtering by Time#
On a server with weeks or months of history, filtering by time range is more practical than scrolling through pages of log. journalctl accepts time filters in fairly straightforward language:
journalctl -u clean-logs.service --since today
journalctl -u clean-logs.service --since yesterday --until today
journalctl -u clean-logs.service --since "2026-03-20 03:00:00" --until "2026-03-20 03:05:00"
The last example is particularly useful for inspecting a specific execution — knowing the OnCalendar time, you just open a window of a few minutes around it to see exactly what happened.
Identifying Failures#
When a service exits with a non-zero exit code, systemd marks it as failed. The systemctl status command shows this information in summary form:
systemctl status clean-logs.service
The output includes the current state (inactive (dead) for a oneshot that finished successfully, failed if there was an error), the exit code, and the last few journal lines. To filter only error messages and above:
journalctl -u clean-logs.service -p err
Priority levels follow the syslog standard: emerg, alert, crit, err, warning, notice, info, and debug. The -p err filter shows everything from err upward, which is usually enough to get straight to the problem.
Correlating Timer and Service#
Sometimes the problem is not in the service itself but in how the timer fires — it may not be activating at the expected time, or it may be activating twice. To see entries for both the timer and the service together, pass both units to journalctl:
journalctl -u clean-logs.timer -u clean-logs.service --since today
This produces an interleaved timeline where you can see the exact moment the timer fired and what the service did next. It is the kind of visibility that with cron would require cross-referencing syslog entries with the script’s redirected output — when that output exists.
A Note on Log Persistence#
By default on Debian and Ubuntu, the journal stores logs in /var/log/journal/ persistently across reboots. Disk space is managed automatically by journald, which by default limits the journal to 10% of the filesystem or 4 GB (whichever is smaller). To check how much space the journal is consuming:
journalctl --disk-usage
If the /var/log/journal/ directory does not exist on your system, the journal is running in volatile mode — storing only in memory, with total loss on reboot. Creating the directory and restarting the service fixes this:
sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journald
For most servers the default configuration is sufficient, but it is worth confirming that persistence is active before relying on the journal as a historical record of your timer executions.
Quick Reference: cron → OnCalendar#
The table below maps the most common cron schedules to their OnCalendar equivalents. In every case, the expression can be validated before use with systemd-analyze calendar "expression".
| Description | cron | OnCalendar |
|---|---|---|
| Every minute | * * * * * | *-*-* *:*:00 |
| Every 5 minutes | */5 * * * * | *-*-* *:0/5:00 |
| Every hour (minute zero) | 0 * * * * | hourly |
| Every day at midnight | 0 0 * * * | daily |
| Every day at 2:30 AM | 30 2 * * * | *-*-* 02:30:00 |
| Every Monday at 8 AM | 0 8 * * 1 | Mon *-*-* 08:00:00 |
| Weekdays at 6 PM | 0 18 * * 1-5 | Mon..Fri *-*-* 18:00:00 |
| First day of month at 3 AM | 0 3 1 * * | *-*-01 03:00:00 |
| Every Sunday at midnight | 0 0 * * 0 | Sun *-*-* 00:00:00 |
| Every 6 hours | 0 */6 * * * | 0/6:00:00 |
| Once a week (Sunday, midnight) | 0 0 * * 0 | weekly |
| Once a month (1st, midnight) | 0 0 1 * * | monthly |
The shortcuts hourly, daily, weekly, and monthly are abbreviated forms that systemd expands internally into the corresponding full expression. They work well for simple schedules, but for any variation — a time other than midnight, a specific day of the week — the explicit expression is needed.
A subtle difference worth noting: in cron, the day-of-week field uses 0 or 7 for Sunday and numbers 1 through 6 for the other days. In OnCalendar, days are always three-letter English abbreviations (Mon, Tue, Wed, Thu, Fri, Sat, Sun), and ranges use .. instead of -. The notation is more readable, but it requires an adjustment for those who have cron syntax in their muscle memory.
When cron Still Makes Sense#
It would be dishonest to close this post without acknowledging that cron did not become a bad tool overnight. It remains a perfectly valid option in specific contexts, and migrating everything to systemd timers on principle, without a concrete gain, is trading useful work for busywork.
On systems where the schedule comes down to half a dozen simple tasks — a backup script, log rotation, a temp file cleanup — cron solves in one line what timers solve with two files. If those tasks do not need centralized logging, do not depend on other services, and run on a server that stays up around the clock, the cost-benefit of migrating is questionable. cron works, the team knows it, and the risk of changing things is greater than the gain.
cron also has the advantage of portability. It exists on virtually any Unix-like system — BSDs, Linux distributions without systemd like Alpine and Void, minimal containerized environments where systemd is not present. If you maintain scripts that need to run across heterogeneous environments, the crontab is the most reliable common denominator. Systemd timers are simply not an option where systemd does not exist.
Another case is users without administrative privileges. Any user can edit their own crontab with crontab -e without needing sudo. Timers in /etc/systemd/system/ require root permissions. User timers do exist (in ~/.config/systemd/user/), but they only run while the user has an active session — unless loginctl enable-linger is enabled for that account, which itself requires administrator intervention.
The point is not to pick a side. On the same server, stable legacy cron jobs and systemd timers can coexist without any conflict. The practical recommendation is to adopt timers for new tasks and migrate existing ones as the opportunity comes up — when a job needs better logging, when a silent failure causes a real problem, when a script gains a dependency that justifies explicit declaration. Gradual migration driven by concrete need tends to produce better results than a mass conversion done all at once.
If you want a practical case to start with, the cleanup of old Snap revisions is a good candidate for your first timer. And if your daily routine includes a Mac, I wrote a post about launchd — the native macOS equivalent.
Sysadmin, 53, Brazilian working from home for the world. Manages Linux servers, LXC containers, and cats that won't get off the keyboard.