Configuring systemd timers: where classic cron breaks down
Task scheduling in Linux most often comes down to a line in crontab: cron runs a command on schedule and that is where it ends. It does not know how the previous run ended, what to do on failure, or whether the machine was rebooted during the task window.
That is why the question “why are scheduled tasks not running” usually starts with manually sifting through system logs in search of the single failed line.
Next are four typical failures that surface on any server fleet. Errors go nowhere: without a configured recipient, command output settles in local root mail, which no one checks for months.
A reboot or node unavailability at launch time means the task simply will not run — cron does not store state and does not catch up on missed runs. There are no links between jobs: an export starts before data preparation has finished, and neither process is aware of it. Logs are scattered — some in mail, some in system files, some lost forever.
Configuring systemd timers removes this at the architecture level: the timer and payload are described by a pair of unit files, not a single line.
systemd itself stores run state, the result of the previous run is visible for a specific unit, the OnFailure= directive sets a failure handler, and After= and Requires= establish order between jobs.
The Persistent=true key returns a missed run immediately after the machine is powered on, so a nightly reboot no longer “eats” the task.
Replacing cron with systemd gives a single point of truth: you can see what ran and when, how it ended, and what exactly broke. Below we explain how a specific schedule is migrated and what to check before disabling the old lines.
What changes after migrating tasks: measurable effect
In cron, the only sign of failure is a line in root mail or a syslog entry without an exit status.
systemd tracks each task as a separate unit with its own state, so the share of lost runs goes to zero: Persistent=true catches up on a missed window after the machine is powered on instead of silently losing it.
Already at this step it is clear that the task either ran with exit code 0 or left a trace in the log — there is no third state.
Dependencies between jobs stop being a “wait two minutes” agreement inside a script.
After= and Requires= pairs provide an explicit startup order: an export does not start until data preparation has finished, and a failure of one step does not allow the next one to work with incomplete data.
Retry on failure is configured natively — Restart=on-failure with a pause between attempts and OnFailure= for notification, instead of homemade loops in bash.
A unified run log eliminates searching for the cause across different logs. journalctl -u name.timer returns the exit code, stderr output, and start time in seconds, so a failure is discovered not in the morning from a complaint, but within minutes after the window. In practice, time to failure detection drops from several hours to tens of minutes.
Predictability of the execution window is set by OnCalendar and RandomizedDelaySec: the schedule reads like a regular string, and start jitter removes simultaneous load on disk and network. The drift that accumulated with cron during long tasks does not occur.
In total, the number of manual interventions per month drops from eight to ten to one or two — usually this is troubleshooting an external cause, not restarting a task by hand.
Schedule options: comparing approaches and limitations
Choosing a schedule comes down not to syntax but to the guarantees the task needs: dependence on the previous run's result, behavior after node downtime, a unified log, and start-time jitter.
cron has no state between runs — a run missed because the machine was off is simply lost, and an error goes out as an email to nowhere.
systemd timers have calendar expressions, random start delay, and catch-up of missed runs, but no distributed lock: if a task must run once across several nodes, the scheduler will duplicate it.
| Option | When it fits | Limitations |
|---|---|---|
| Keep the current scheduler | Simple tasks without dependencies and log requirements | No catch-up of missed runs, no links to other units, and no state |
| Full migration to the system scheduler | Dependencies, a unified log, and catch-up of missed runs are needed | Each task must be described as a unit; duplication is possible across several nodes |
| Hybrid scheme | We migrate critical tasks; the rest works as before | Two points of truth — a consolidated schedule registry is needed |
| External job orchestrator | Distributed execution, order and retry on failure matter | A separate system that must be maintained and updated |
A hybrid schedule is a workable compromise during migration: we move tasks one by one, compare the log with previous behavior, and only after that remove the line from the old schedule file.
This way, configuring systemd timers does not turn into a one-time risky migration of the entire node.
Implementation stages: how we migrate tasks to systemd
Before replacing the scheduler, you need to see the full picture: how many jobs actually work, which ones depend on each other, and what will happen if a launch window is missed.
That is why migration follows a fixed order — from inventory to handover to operations, with verification at every step.
- Inventory of the task schedule. We export entries from system and user scheduler tables, add launches from application scripts, and compare everything with logs. Some tasks have been failing for a long time, some are duplicated, and some have lost their owner — this is exactly where that comes to light.
- Task dependency map. We build a graph: what strictly follows what, what can be parallelized, and where a missed window is acceptable. This captures links such as “backup after export,” which cannot be migrated one by one.
- Migration of descriptions. A task is split into “what we run” and “when”: a separate service unit and timer. The schedule is described with a calendar expression, and catch-up of missed windows after downtime is enabled — if the task allows it.
- Permission setup. We specify the user, working directory, environment variables, and resource limits. The task no longer depends on someone's session and will no longer drag down the entire host with it.
- Failure testing. Forced runs, log analysis, simulating reboot and long downtime, behavior when the database is unavailable. You need to make sure that the task either completes or explicitly reports a failure.
- Handover of the schedule to operations. We provide a task map, logging and notifications for unsuccessful runs, and instructions for adding and disabling tasks. The schedule is visible in one place and changes without editing other people's scripts.
Configurations, diagrams, and policies that remain with you
After migrating schedules to systemd timers, the team retains not only a working configuration but also a set of documents that make it possible to maintain it without the author.
Without them, in six months no one will remember why a job starts at 03:17, under whose name, and what to do if it fails.
- Job and unit descriptions — what each timer does, which script it calls, what parameters it receives, and how the calendar expression differs from the previous cron line.
- Task dependency map — what must finish first: storage mount, database startup, export from an external system.
- Access rules — under whose name runs are executed, which groups and permissions are needed, where credentials are stored, and how the unit's capabilities are limited.
- Task schedule documentation — a summary table “task — frequency — execution window — owner,” including rare and calendar runs.
- Failure response policy — how notification arrives, who checks the log and within what time, when a task is restarted automatically, and when it is blocked until investigation.
- Schedule change procedure — how to add or disable a task so the edit does not affect neighboring timers or create a double run.
- Control checks — commands the on-call engineer uses to confirm: the timer is active, the next run is scheduled, and the previous one completed successfully.
We format the materials alongside the configuration so that the description and the unit change with one edit rather than living in different places.
The supporting team gets an entry point: deploy the schedule from scratch, find the task owner, and troubleshoot a failure from the log.
Case study: how scheduled tasks stopped getting lost
What it was like before
Nightly scheduled jobs — exports, data mart recalculation, index maintenance — ran on classic cron in three environments. No one tracked the runs: if the machine rebooted during the window, the task was skipped silently, and morning reports were built on outdated data.
Each month, 6-9 % of nightly runs were lost, finding the cause of a failure took an average of 45 minutes, and the on-call engineer restarted tasks by hand up to 15 times a week.
What we changed
Each task was described as a pair of units — timer and service — and rolled out from a common template via Ansible, so the schedule was versioned together with the configuration instead of living in someone else's table on the server.
The key was Persistent=true: a window missed because the machine was off is caught up immediately after power-on, but there is no duplication during normal operation.
The OnFailure= pairing with a handler unit sends a notification to the on-call channel, and task output is written to the system journal tied to the unit — the run history is visible without a separate agent.
What became
| Metric | Before | After |
|---|---|---|
| Missed nightly runs | 6-9 % | 0 |
| Failure cause investigation | 45 minutes | 2-3 minutes |
| Manual restarts | up to 15 per week | isolated cases |
Missed runs were closed by mechanics, not by an appeal to “look more carefully”: a missed run is caught up, and a failed one is immediately visible in the journal and notification.
Diagnostics came down to filtering by unit name, so an incident is investigated before the workday starts. The migration is reversible: tasks remain separate units, and any of them can be returned to its previous schedule without affecting the rest.
How a scheduled run differs from a permanent service
A systemd unit is a unit of control, and its type determines the lifecycle: one process holds state continuously, another runs and exits.
Hence the difference between a service and a timer — a service answers the question “what should run,” while a timer answers “when to launch what terminates.” When configuring systemd timers, this separation matters more than schedule syntax.
A permanent service manages a process: monitors it, brings it up after a crash, and collects output in the journal. A timer does not keep a process in memory — it activates the bound unit on a calendar schedule or after an interval following boot.
The work itself is implemented as a oneshot service: the code does not run in the background but is invoked exactly for the duration of execution.
Behavior after reboot is the key difference from the old minute-based launch method. With catch-up of missed runs enabled, the system executes the task immediately after startup if the machine was off at that moment; without it, the missed run is lost silently.
For a permanent service, a reboot is a process restart, and state not saved to disk has to be restored manually.
There are two more parameters that classic cron lacks: random jitter window and launch accuracy. They spread the load when dozens of similar tasks start in the same minute.
The choice is simple: if a process lives and holds a connection, cache, or queue, it is a service; if the task is finite (export, backup, log rotation), it is a timer plus oneshot.
A mixed option also works: a permanent service that itself activates a timer based on its own event.
Technical concerns before migrating schedules
Migrating schedules does not require stopping current jobs: cron and timers run in parallel while we migrate scripts one by one.
The main work is not rewriting logic but reproducing the launch environment: cron starts a job with a stripped-down PATH and without a working directory, so in the unit we explicitly set Environment=, WorkingDirectory=, and User=.
As long as the launch line produces the same result in both schedulers, the old job remains enabled.
Scripts were written for cron — will they have to be rewritten?
No. The script is called as is; only the wrapper changes. A common cause of “it broke after migration” is not logic but a different working directory and environment variables, so we migrate a low-risk task first and compare the result with the previous one.
Jobs run under different users — how is that migrated?
Roles are described in the unit: User= and Group=, plus permissions for output and log directories. This is also where the “script wrote to the home directory” case is resolved — the path is set explicitly rather than inherited from whoever launched the job.
How do you debug nightly failures if the team is asleep?
Every run goes to the journal with a timestamp, return code, and output — we look at the history for a specific task instead of guessing. Catch-up mode executes a missed run after the server is powered on, so a nightly failure does not turn into a day without data.
What if a task depends on the network or mounted resources?
Dependencies are described explicitly, so the run does not start before the network is up or the storage is connected. For tasks hitting the same resource, we add time jitter — peak load on the server is spread out.
Let's discuss the task: what you need to know before work starts
To make the schedule migration estimate accurate rather than “by analogy,” we need an inventory of current tasks and an understanding of the environment. If some data is already at hand, the assessment goes faster, and we discuss contentious points concretely.
- where schedules currently live: user crontabs, files in
/etc/cron.dand/etc/cron.*, tasks in applications' internal schedulers; - task list: what runs, how often, how long it usually takes and how long at peak;
- dependencies and order: what must finish before the next starts, where locks against parallel runs are needed;
- environment: distributions, permissions to create unit files, running under an unprivileged user, access to secrets;
- logs and notifications: where output is written now, who reads it, where error messages should go;
- behavior on a missed window: if the node was off at launch time — catch up on the task or skip it;
- work windows: when migration does not affect business processes.
This is enough for us to come back with a schedule implementation plan and a layout of unit files and timers.
Our engineers will separately cover special cases — time overlaps, runs under different users, migration without losing missed runs — and propose a scheme with rollback to the previous schedule.
Let's discuss the scheduling task: describe the items above, and we will come back with a work estimate and plan.







