How to Replace Cron Jobs with Systemd Timers on Ubuntu
For years, cron was the trusty workhorse for anyone who wanted a Linux box to repeat a task on a schedule. The syntax is famously terse, the daemon is everywhere, and many Ubuntu admins in Australia still have crontab entries passed down from an older system at a university lab in Brisbane or a small business in Perth. Yet cron has a quiet list of annoyances: it does not log in a useful way by default, it cannot express dependencies, and it has no native concept of "run this if the machine was off at the scheduled time." Ubuntu's move toward systemd over the last several releases has matured to a point where systemd timers are a more capable, more observable, and often simpler replacement for the old crontab.
This guide walks through the practical side of moving recurring jobs off cron and onto systemd timer units. You will write a service file, pair it with a timer, learn the OnCalendar syntax, and pick up the small set of commands that make new timers easy to manage. The examples assume a stock Ubuntu 24.04 or 25.04 box, but the same steps apply to earlier releases that ship systemd, and to derivatives like Linux Mint, Pop!_OS, and Ubuntu Touch devices that still run a full systemd stack.
Why systemd timers make sense on modern Ubuntu
Systemd timers are not just cron with a different name. They are proper systemd units, which means they participate in the same lifecycle as services, sockets, and mount points. A timer unit can be ordered after another unit, can be triggered when a different service finishes, and can be inspected with the same tools. For Australians running servers in data centres in Sydney or on small NBN connections in regional Queensland, that observability is the real upgrade.
The journal comes for free. Every time a timer fires, the corresponding service runs as a real unit, and its stdout, stderr, and exit code end up in the journal. You can read it with journalctl -u my-job.service without configuring any log redirection. Cron, by contrast, sends mail to a local MTA that is often not even running on a default Ubuntu install, and silently swallows output otherwise.
Timers also handle missed runs. If a desktop at a university in Melbourne was suspended overnight because the user closed the lid, a Persistent=true timer will catch up on the missed schedule the next time the system wakes. Cron simply skips that run, and you may not notice until your backup script complains.
Understanding the service and timer unit pair
A timer does not run a command directly. Instead, the timer unit names a service unit, and the service unit is what actually executes your script or binary. Splitting the two means the job can be run on demand, by the timer, or by hand during testing, without changing anything else.
Service units live under /etc/systemd/system/ or /usr/lib/systemd/system/, and timer units live next to them. The naming convention is to give them the same stem, so my-backup.service and my-backup.timer sit together. A minimal service unit for a shell script looks like this:
[Unit]
Description=Nightly backup wrapper
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=root
Nice=10
The matching timer might look like:
[Unit]
Description=Run the nightly backup wrapper daily
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
Unit=my-backup.service
[Install]
WantedBy=timers.target
The timer pulls in the service by name, and the service is a one-shot because it is expected to run, finish, and exit. Once both files are in place, you reload systemd with systemctl daemon-reload and start the timer with systemctl enable --now my-backup.timer.
Writing your first timer that runs a backup script
Suppose you maintain a small office in Adelaide that keeps client documents on a local NAS. You have written a script at /usr/local/bin/backup.sh that tars /srv/client-data and pushes it to an offsite bucket. With cron, you would have added a line to /etc/crontab and hoped the path was correct. With systemd, the workflow is a touch more verbose but far easier to audit.
First, make sure the script is executable and has a shebang. Then create /etc/systemd/system/client-backup.service using the template from the previous section, adjusting the description and ExecStart. Create the matching /etc/systemd/system/client-backup.timer and pick a time. A common choice in Australia is 02:30 AEST, but remember that most of the country does not observe daylight saving in the same way. South Australia, the ACT, New South Wales, Victoria, and Tasmania jump forward an hour in October, while Queensland, the Northern Territory, and Western Australia stay put. If your server lives in Sydney and you write 02:30, you are scheduling in local time; if you write 02:30:00 UTC, the wall-clock time will drift by an hour for half the year. Pick a timezone and stick to it.
Run systemctl daemon-reload, then systemctl enable --now client-backup.timer. The --now flag starts the timer immediately and also enables it for future boots. List active timers with systemctl list-timers --all to confirm your new entry appears at the right time.
Time expressions and the OnCalendar directive
The OnCalendar directive looks like a calendar string and accepts a wide range of formats. The classic cron form translates almost one-to-one. The systemd.time man page is exhaustive, and a few patterns cover most real needs.
*-*-* 02:30:00 fires every day at 02:30. Mon..Fri 08:00:00 fires on weekdays at 8am. *:00/15 fires every fifteen minutes. For monthly jobs, you might write Monthly or 1-*-* 00:00:00. Systemd also understands human expressions like daily, weekly, and hourly, which are convenient shorthand.
Two settings make timers behave nicely. AccuracySec controls how precisely the kernel needs to wake the job, and defaults to one minute. If you do not need sub-minute precision, leaving it alone keeps power consumption down on laptops. RandomizedDelaySec spreads the start time by a random number of seconds, which is useful when several machines in the same office run the same job and you do not want them hammering a shared resource at the same instant.
For the example above, you might add AccuracySec=5min and RandomizedDelaySec=10min to soften the impact. Combined with Persistent=true, the timer will still run once per day even if the machine was off, and will not wake it precisely on the second.
Managing, monitoring and troubleshooting your timers
Once a timer is enabled, the day-to-day commands are short. systemctl status my-backup.timer shows when the timer last fired, when it will fire next, and which service it triggers. systemctl list-timers lists every active timer on the box, ordered by their next run. To stop a timer without disabling it, use systemctl stop; to remove it from the boot sequence, use systemctl disable.
When a job fails, the journal is your first stop. journalctl -u my-backup.service --since today shows the recent output of the service, and the --since flag accepts shortcuts like "yesterday" or "1 hour ago". The exit code is preserved, so a failing tar or rsync shows up with a clear status line. Cron never gave you that for free, and the difference is noticeable when you are debugging a script that runs at 3am and you only find out the next morning.
A common pitfall is the working directory. Cron runs commands with $HOME set to the user's home, while systemd services start in /. If your script uses relative paths, it will fail. Either cd into the right directory inside the script, or set WorkingDirectory= in the service unit. Another is environment variables: cron carries a minimal environment, and so does a systemd service unless you set Environment= or load a file with EnvironmentFile=. Anything your script expects, such as PATH for finding binaries or API tokens for a remote service, must be declared explicitly.
Migrating existing cron jobs without breaking workflows
The most common worry when moving to systemd timers is breaking the dozen cron jobs that have been quietly running for years. The good news is that you do not have to do it all at once. Cron and systemd timers can run side by side without conflict, so a staged migration is safe.
A reasonable approach is to list your current crontab with crontab -l for each user, and your system-wide /etc/crontab and /etc/cron.d/ entries. Pick the least critical job, write a service and timer for it, and run both for a week. Once you are confident the systemd version fires at the right time and does the same thing, disable the cron entry with crontab -e or by commenting the line in /etc/cron.d/. Repeat until the crontab is empty.
For users who maintain shared documents alongside automated scripts, this is also a good moment to standardise the tools. Many small businesses in Australia have moved to OnlyOffice for spreadsheet and document work, and pairing scheduled exports with that stack is a tidy win. The OnlyOffice review on UbuntuNext is a useful starting point if you are weighing options for the office half of the equation.
Real-world scheduling patterns for Australian users
A few patterns come up again and again in the local community. Backups timed to run outside business hours, with Persistent=true to catch any laptop that was asleep. Log rotation kicked off at midnight AEST so that fresh logs are available by the time the first support call lands at 8am. A weekly mirror of a local apt cache for the lab machines in a school, scheduled for Saturday morning when NBN upload speeds in regional WA are at their quietest. A daily sync of an SQLite database to an S3 bucket in ap-southeast-2, written with a short AccuracySec to keep the network blip short.
A useful habit is to add a small wrapper script that records the start and end time to the journal, even when the actual work is one line. That gives you a clear record of how long a job actually took, which is invaluable when a script that ran in two seconds for years suddenly takes ten minutes because the database has grown. The journal already captures this if you set Type=oneshot and let systemd handle the lifecycle, so a wrapper is only needed when you want extra context.
Once a timer is in place, the natural next step is to run systemctl list-timers on your own machine, pick the entry that matters most to you, and rewrite it as a systemd timer before the day is out. The shift from cron to systemd is rarely a single afternoon's work, but the first migration is usually the one that sells the rest of the team on the change.