Shore Up
A wall clock wired to a small mechanical arm that runs a task, with a red warning light and an envelope popping out when the task jams.
LinuxSecurity

Replace a Cron Job with a systemd Timer, Logging, and Failure Alerts

Ketan Aagja8 min read
No ratings yet

Before you run this

This guide replaces a cron job with three small systemd units: a service that runs your task, a timer that schedules it, and a small notification service that emails you when the task fails. The point is to get the two things cron does not give you for free — the task's output captured in the journal, and an alert when it exits non-zero.

You need root (via sudo) to write files under /etc/systemd/system/ and to enable units. The failure alert needs a working local mailer: I assume the mailutils package (which provides mail) and a configured MTA or smarthost that can actually deliver mail off the box. If mail is not set up, the units still work — you just won't get the email until you fix delivery.

Test this on a VM or a non-production host first. Read each unit file before you install it, and prove the timer and the alert both work before you remove the cron entry. Nothing here deletes data, but disabling your existing cron job and handing scheduling to systemd is a change to how a live task runs — treat it accordingly. Leave the old cron line commented out, not deleted, until you have watched the timer fire successfully.

Assumed environment: Debian 12 or Ubuntu 22.04, systemd 247 or newer, bash. On RHEL/Alma the unit syntax is identical; only the mail package differs (s-nail/mailx instead of mailutils), and the mail invocation may vary — check your distro's docs for the exact flags.

The task we're moving

Say cron runs this every day at 02:30:

30 2 * * * /usr/local/bin/mytask.sh

Replace /usr/local/bin/mytask.sh throughout with your real script path, and admin@example.com with your real recipient. I'll keep the script itself as-is — the migration is about scheduling, logging, and alerting, not rewriting your task.

One thing worth checking: cron runs jobs with a minimal environment and as a specific user. A systemd service runs as root by default unless you say otherwise, with its own clean environment. If your script relied on a user's PATH or environment, set them explicitly (I show User= and Environment= below).

The service unit

Create /etc/systemd/system/mytask.service:

[Unit]
Description=Run mytask maintenance script
# On failure, start the notification unit and pass this unit's name to it
OnFailure=notify-failure@%n.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/mytask.sh
# Run as an unprivileged account instead of root if your task allows it
User=root
# Make the journal easy to filter later
SyslogIdentifier=mytask

Type=oneshot is the right type for a task that runs, does its work, and exits — as opposed to a long-running daemon. OnFailure= is a real directive: it starts the listed unit(s) whenever this unit enters a failed state (a non-zero exit, a timeout, or a signal). %n expands to this unit's full name (mytask.service) and is passed as the instance to the template unit below.

There is no [Install] section here on purpose. The service is not enabled directly; the timer starts it.

The timer unit

Create /etc/systemd/system/mytask.timer. It must share the base name (mytask) with the service, so systemd knows which service it triggers:

[Unit]
Description=Schedule mytask daily at 02:30

[Timer]
# Same as cron's "30 2 * * *"
OnCalendar=*-*-* 02:30:00
# Run a missed job at next boot if the machine was off at 02:30
Persistent=true
# Optional: spread load by delaying up to 5 minutes past the scheduled time
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

OnCalendar uses systemd's own calendar syntax, which is not cron syntax. *-*-* 02:30:00 means every day at 02:30. To sanity-check any expression before you trust it, use:

systemd-analyze calendar '*-*-* 02:30:00'

That prints the next elapse times so you can confirm you got it right. Persistent=true is the systemd equivalent of anacron behaviour — if the box was asleep at 02:30, the job runs shortly after the next boot instead of being silently skipped.

The failure-notification unit

This is a template unit — the @ in the name means it takes an instance string, which is the name of whatever unit failed. Create /etc/systemd/system/notify-failure@.service:

[Unit]
Description=Send an email when %i fails

[Service]
Type=oneshot
# %i is the failed unit's name, passed in by OnFailure=
ExecStart=/usr/local/bin/systemd-notify-failure.sh %i admin@example.com

And the small script it calls, /usr/local/bin/systemd-notify-failure.sh:

#!/bin/bash
set -euo pipefail

unit="$1"       # e.g. mytask.service
recipient="$2"  # e.g. admin@example.com
host="$(hostname -f)"

# Grab the last 50 journal lines for the failed unit as the mail body
journalctl -u "$unit" -n 50 --no-pager \
  | mail -s "systemd job FAILED: ${unit} on ${host}" "$recipient"

Make it executable:

sudo chmod 0755 /usr/local/bin/systemd-notify-failure.sh

Because the service logs to journald, journalctl -u gives you the failed run's output to put in the email. If your mail command uses different flags (some builds want -a for headers or read the body differently), check man mail on your system rather than assuming.

Install and start

# systemd re-reads unit files
sudo systemctl daemon-reload

# Enable so it survives reboots, and start it now
sudo systemctl enable --now mytask.timer

You enable and start the timer, never the service — the timer pulls in the service on schedule.

Verify it works

Confirm the timer is registered and see when it will next fire:

systemctl list-timers mytask.timer

Run the task now, on demand, without waiting for 02:30:

sudo systemctl start mytask.service

Check the result and read the captured output:

systemctl status mytask.service
journalctl -u mytask.service -n 50 --no-pager

A successful oneshot run shows as inactive (dead) with a SUCCESS result in the status — that is normal, not an error.

Now prove the alert path. The honest way to test it is to make the task fail once. Temporarily point the service at a command that exits non-zero — for example, edit ExecStart to /bin/false, run sudo systemctl daemon-reload, then sudo systemctl start mytask.service. Confirm the notification fired:

journalctl -u notify-failure@mytask.service -n 20 --no-pager

Check that the email arrived, then restore your real ExecStart and daemon-reload again. Do not skip this — an untested alert is the same as no alert.

Retire the cron job

Only after the timer has fired successfully and the alert has been proven: comment out the cron line (don't delete it yet) so you can restore it in one edit if something surprises you.

# Migrated to systemd timer mytask.timer on 2025-01-01
# 30 2 * * * /usr/local/bin/mytask.sh

Watch one real scheduled run land in the journal, then delete the commented line at your leisure.

Undo / rollback

To go back to cron entirely:

# Stop and disable the timer
sudo systemctl disable --now mytask.timer

# Remove the unit files
sudo rm /etc/systemd/system/mytask.timer \
        /etc/systemd/system/mytask.service \
        /etc/systemd/system/notify-failure@.service

# Reload so systemd forgets them
sudo systemctl daemon-reload

Then un-comment your cron line. Leave /usr/local/bin/systemd-notify-failure.sh in place or remove it — it does nothing on its own.

For the exact syntax of any directive here, the systemd man pages are authoritative: systemd.service, systemd.timer, and systemd.time (the last covers OnCalendar). They're on the host via man 5 systemd.timer and on the systemd project's documentation site.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.