Skip to content
shellhacks.dev
Cron and Scheduling

How to Convert a Cron Job to a systemd Timer the Right Way

Convert a cron job to a systemd timer: translate the schedule, write the service, map environment, logging and mail, and run both until you trust the result.

6 min read
Summarize in AI
Illustration for "How to Convert a Cron Job to a systemd Timer the Right Way": a shell script in an editor, a crontab entry and clock, a process monitor.

Cron works, and moving to timers pays off when you want logs in one place, a clear failure state, no overlap and catch-up after downtime. The move is mechanical and each piece has a counterpart, with a few differences that cause surprises. This post converts one real job and lists the mapping. It builds on OnCalendar syntax. The commands assume Debian 13 with systemd 257.

The cron job

Text
[email protected]
PATH=/usr/local/bin:/usr/bin:/bin
30 2 * * * backup /usr/local/bin/backup.sh --full >> /var/log/backup.log 2>&1

It runs as user backup at 02:30 daily, appends output to a log and mails problems to a person. Four things need a home in the timer version: the schedule, the user, the environment and the output.

The service

/etc/systemd/system/backup.serviceINI
[Unit]
Description=Nightly backup
OnFailure=notify-failure@%n.service

[Service]
Type=oneshot
User=backup
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/backup.sh --full
Nice=10

Type=oneshot makes systemd wait for the command to end and treat a non-zero exit as a failure. There is no output redirection: standard output and error go to the journal, tagged with the unit. OnFailure= starts another unit when this one fails; a template unit that sends an email or a message with the failed unit's name replaces MAILTO, and the shell scripts in Slack notifications fit there.

The timer

/etc/systemd/system/backup.timerINI
[Unit]
Description=Nightly backup schedule

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target

Persistent=true is an improvement over cron: if the machine was off at 02:30, the backup runs at boot. Remove it if a late run is worse than a skipped one. Remember to remove the cron line when you enable the timer, or the job runs twice.

Verify and run once

Shell
sudo systemd-analyze verify /etc/systemd/system/backup.service /etc/systemd/system/backup.timer
systemd-analyze calendar '*-*-* 02:30:00'
sudo systemctl daemon-reload
sudo systemctl start backup.service
journalctl -u backup.service -n 20 --no-pager
sudo systemctl enable --now backup.timer

Running the service by hand is the key test. It uses the same environment the timer will, so it shows the missing-PATH and permission problems that cron hid in a mail. Both cron and systemd start jobs with a minimal environment, but not the same one, so check what the script depends on.

The mapping

  • */5 * * * * becomes OnCalendar=*:0/5, or OnUnitActiveSec=5min for an interval since the last run.
  • @reboot becomes OnBootSec=1min on a timer, or WantedBy=multi-user.target on the service for a start-up task.
  • 0 9 * * 1-5 becomes OnCalendar=Mon..Fri *-*-* 09:00.
  • A % in a crontab command, which needs escaping there, is a plain character in ExecStart, though systemd uses % for specifiers, so write %% for a literal one.
  • flock to prevent overlap is unnecessary: a second start of a running oneshot service waits, as in overlapping cron jobs.

Where this does not apply

Per-user jobs without root can use user units in ~/.config/systemd/user with loginctl enable-linger so they run when logged out. Containers and minimal systems without systemd keep cron. A tiny one-liner that already works is not worth moving unless it fails in ways that the journal would explain. For visibility after the move, see listing timers and last runs.

What changes when you leave cron

A cron line is one string that holds both the schedule and the command, run by a daemon that gives the job a minimal environment and sends any output to mail. A systemd timer splits that into a service, which says what to run and how, and a timer, which says when. The split brings real benefits. The service can set the user, the working directory, environment files, resource limits, a timeout and sandboxing options. Its output goes to the journal, where you can read it by unit and time. A job does not overlap with itself, since starting a service that is already running does nothing. And the timer can catch up after downtime with Persistent=true. The cost is two files in place of one line, and a different vocabulary, so the move is worth it for jobs that matter and optional for a trivial one that has run without trouble for years.

Treat the conversion as a chance to fix the job's assumptions. Write down which user it runs as, what it needs on the PATH and which directory it works in, because cron hid those behind defaults that systemd does not provide.

Translating the schedule and the environment

The five fields of a cron schedule map onto OnCalendar with a different syntax: 30 2 * * * becomes *-*-* 02:30:00, and 0 */6 * * * becomes *-*-* 00/6:00:00. Day-of-week is a name in systemd, such as Mon..Fri, instead of numbers. One difference to know: when cron has both a day of month and a day of week, it fires if either matches, while systemd requires both. Environment variables set at the top of a crontab become Environment= lines or an EnvironmentFile= in the service. The MAILTO behavior has no direct equivalent, so replace it with an explicit notification: an OnFailure= unit that sends a message, or a command at the end of the script. Percent signs, which cron treats specially, need no escaping in a unit file, except that systemd has its own specifiers, so a literal percent sign in a command must be doubled.

Running both while you gain confidence

Do the switch in steps. Create the service and start it by hand with systemctl start to check that the command works under systemd's environment, reading the result with journalctl -u name. Then create the timer, check it with systemd-analyze calendar and enable it, leaving the cron line in place but with a guard that makes the second run harmless, or comment it out only after the timer has run once successfully. Compare the effects after the first scheduled run, such as the files written and the log lines. Enable it with systemctl enable --now name.timer, never the service. Keep the unit files in version control and deploy them with the rest of your configuration. Finally, remove the old cron entry so the job does not run twice, and note the change in your runbook, since the next person will look in the crontab first.

What you give up and what to keep from cron

Cron is a single, familiar line that every administrator can read, and it needs nothing but a text editor. Units are more verbose, require knowing the unit file vocabulary and are harder to edit on a machine in a hurry. Keep cron for simple jobs on machines you do not manage with tooling. Move to timers when you want logging in the journal, resource limits, dependencies such as waiting for a mount or the network, or catch-up after downtime. Many teams settle on timers for anything that matters and leave the rest in cron, and that is a perfectly reasonable place to end up.

See systemd.service(5) and OnFailure= in systemd.unit(5).

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.