Skip to content

guide

Scheduled jobs with systemd timers

Cron still works. Timers give you logs, dependencies, resource limits and catch-up after downtime, for two small files instead of one line.

Why bother switching

A crontab line is short until it fails. Then you find out the output went to a local mail spool nobody reads, the job ran with a different PATH, and the server was rebooting at 03:00 so last night's run never happened. Timers fix each of those:

Concerncronsystemd timer
OutputMailed, or lostIn the journal, per unit
Missed run during downtimeSkippedPersistent=true runs it at boot
Overlapping runsPossible; needs flockNever, by default
Resource limitsManual nice/ioniceCPUQuota=, MemoryMax=, IOWeight=
DependenciesNoneAfter=, Requires=
Failure hookScript it yourselfOnFailure=

Every image we offer, from Ubuntu 24.04 to Rocky Linux 9, runs systemd, so the same units work everywhere.

A complete example

Say you want to prune old uploads nightly. Two files. The service says what to run:

# /etc/systemd/system/prune-uploads.service
[Unit]
Description=Delete uploads older than 30 days
After=network-online.target

[Service]
Type=oneshot
User=app
ExecStart=/usr/bin/find /srv/app/uploads/tmp -type f -mtime +30 -delete
Nice=10
IOSchedulingClass=idle

The timer says when:

# /etc/systemd/system/prune-uploads.timer
[Unit]
Description=Nightly upload prune

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10m
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer, not the service:

sudo systemctl daemon-reload
sudo systemctl enable --now prune-uploads.timer
systemctl list-timers prune-uploads.timer
NEXT                        LEFT     LAST PASSED UNIT                 ACTIVATES
Tue 2026-10-28 02:34:12 GMT 11h left -    -      prune-uploads.timer  prune-uploads.service

Run the job now to test it, without waiting for 02:30: sudo systemctl start prune-uploads.service.

OnCalendar syntax

The format is DayOfWeek Year-Month-Day Hour:Minute:Second, with * for any and ranges or lists allowed.

CrontabOnCalendar
*/15 * * * **:0/15
0 * * * *hourly
30 2 * * **-*-* 02:30:00
0 9 * * 1-5Mon..Fri 09:00
0 4 1 * **-*-01 04:00

Never guess. systemd-analyze calendar parses an expression and prints the next runs:

$ systemd-analyze calendar --iterations=3 'Mon..Fri 09:00'
  Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
    Next elapse: Tue 2026-10-28 09:00:00 GMT
       Iter. #2: Wed 2026-10-29 09:00:00 GMT
       Iter. #3: Thu 2026-10-30 09:00:00 GMT

Time zone: timers use the server's zone unless you append one, e.g. *-*-* 02:30 Europe/London. Pinning it avoids surprises if someone later sets the box to UTC. Around the clock change in late March and late October, a job scheduled between 01:00 and 02:00 local time can be skipped or run twice. Schedule outside that hour, or use UTC.

For intervals measured from the last run rather than the wall clock, use monotonic timers: OnBootSec=5min and OnUnitActiveSec=1h.

Reading the output

Anything the job prints goes to the journal under the service's name:

journalctl -u prune-uploads.service -n 50
journalctl -u prune-uploads.service --since yesterday
systemctl status prune-uploads.service

On Ubuntu and Debian the journal may not persist across reboots by default on minimal images. Check ls /var/log/journal; if it is missing, sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald. Cap its size with SystemMaxUse=500M in /etc/systemd/journald.conf so logs never fill a 20 GB Spark disk.

Get told when a job fails

OnFailure= starts another unit when the service exits non-zero. A template unit can notify for any job:

# /etc/systemd/system/[email protected]
[Unit]
Description=Alert on failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/bin/alert "%i failed on %H"

Then add OnFailure=notify-fail@%n.service to the [Unit] section of any job. /usr/local/bin/alert can be a curl to a chat webhook or a push service; the monitoring guide has an example.

Limits for heavy jobs

A backup or report job should not starve the web server. Add to [Service]:

CPUQuota=50%
MemoryMax=1G
IOWeight=20

On a two-vCPU Boost, CPUQuota=50% means half of one core. Use 100% for one full core.

Migrating a crontab

  1. Dump the current jobs: crontab -l -u app and ls /etc/cron.d.
  2. Write one service and timer per line. Put the full command in ExecStart with absolute paths; there is no shell, so pipes need /bin/sh -c '...'.
  3. Set User= and any Environment= or EnvironmentFile= the job relied on.
  4. Start the service manually, read the journal, then enable the timer.
  5. Comment out the crontab line. Delete it after a week of clean runs.

Questions

Do I need to remove cron to use timers?

No. Both run side by side. Move jobs one at a time and delete each crontab line once the timer has run successfully a few times.

Why did my timer not run after a reboot?

Without Persistent=true, a timer only fires at the next matching time. With it, systemd records the last run and starts the job at boot if a scheduled run was missed.

Can a normal user run timers?

Yes. Put the units in ~/.config/systemd/user/ and use systemctl --user. Run loginctl enable-linger for that user so the timers keep firing when nobody is logged in.

How do I stop two runs overlapping?

A timer will not start a service that is still running, so overlap cannot happen with the default settings. That alone removes the need for flock wrappers common in crontabs.