Systemd Timers: Replace Cron Jobs on a Linux VPS
Cron has scheduled Unix jobs for forty years and it still works, but on a systemd-based VPS it is no longer the only option — and for anything you need to operate rather than just fire-and-forget, systemd timers are the better tool. The difference is not nostalgia versus novelty; it is that a cron job is invisible until it fails silently, while a timer is a first-class systemd unit with status, logs, dependency ordering, and a built-in answer to the classic cron weakness: jobs missed while the machine was off. This guide converts a cron job to a systemd timer step by step and explains, at each point, what the timer gives you that the crontab line could not.
.service that defines what to run and a .timer that defines when. Schedule with OnCalendar= (e.g. OnCalendar=*-*-* 02:30:00 for daily at 02:30), add Persistent=true so a job missed while the server was off runs at next boot, then systemctl enable --now the timer. Inspect everything with systemctl list-timers and read each run's output with journalctl -u.Why a Timer Over a Crontab Line
Before the mechanics, the case for the move — because the extra file is only worth it if you understand what it buys. A cron entry runs your command in a minimal environment and emails output nobody reads; if it fails, you find out when the thing it was supposed to do did not happen. A systemd timer, by contrast, gives you four things cron cannot:
- Real status and logs. Output goes to the journal, queryable per-unit with
journalctl, instead of vanishing into mail.systemctl statusshows whether the last run succeeded. - Missed-run catch-up.
Persistent=truerecords when a job last ran and executes it on next boot if a scheduled time was missed while the server was down — something cron simply cannot do. - Dependencies and ordering. A timer's service can declare
After=network-online.targetso it waits for the network, or depend on a database being up, using the same dependency system as the rest of your services. - No overlap. systemd will not start a service that is still running from the previous trigger, avoiding the pile-up cron causes when a job runs long.
Step 1: Write the Service Unit (the "What")
A timer does not run a command directly — it activates a service. So you write the service first. Suppose you have a backup script at /usr/local/bin/backup.sh. Create /etc/systemd/system/backup.service:
1[Unit]
2Description=Nightly backup job
3After=network-online.target
4Wants=network-online.target
5
6[Service]
7Type=oneshot
8ExecStart=/usr/local/bin/backup.sh
Two details matter. Type=oneshot tells systemd this is a task that runs and exits — not a daemon that stays resident — so it is marked complete when the script finishes rather than treated as crashed. And After=/Wants=network-online.target makes a network-dependent job wait until the network is actually up, a race that bites cron jobs at boot. Note there is no [Install] section: the service is not enabled on its own, because the timer is what will trigger it.
Step 2: Write the Timer Unit (the "When")
Now the schedule, in a file with the same base name and a .timer extension — that shared name is how systemd pairs them. Create /etc/systemd/system/backup.timer:
1[Unit]
2Description=Run the nightly backup at 02:30
3
4[Timer]
5OnCalendar=*-*-* 02:30:00
6Persistent=true
7
8[Install]
9WantedBy=timers.target
The [Timer] section is the heart of it. OnCalendar= defines when to fire, and Persistent=true is the feature that wins the argument against cron: if the scheduled 02:30 run is missed because the VPS was rebooting or powered off, systemd runs it once as soon as the machine is back, instead of silently skipping the day. The [Install] section, with WantedBy=timers.target, is what lets the timer start automatically at boot when you enable it.
Step 3: Master OnCalendar Syntax
OnCalendar= is more readable than cron's five cryptic fields once you know the shape: DayOfWeek Year-Month-Day Hour:Minute:Second. A few practical examples cover most needs:
1OnCalendar=*-*-* 02:30:00 # every day at 02:30
2OnCalendar=Mon *-*-* 09:00:00 # every Monday at 09:00
3OnCalendar=*-*-01 00:00:00 # first of every month, midnight
4OnCalendar=*-*-* *:00/15:00 # every 15 minutes, on the quarter-hour
5OnCalendar=hourly # shorthand for every hour
The * is a wildcard meaning "every," and 00/15 in the minutes field means "starting at 0, every 15." You do not have to decode it by eye, though — systemd ships a calculator that shows the next trigger times for any expression:
1systemd-analyze calendar "Mon *-*-* 09:00:00"
It prints the normalised form and the next several times the expression will fire, so you can verify a schedule before trusting it — the kind of confirmation cron never offered.
Step 4: Enable and Verify
With both files in place, reload systemd so it reads the new units, then enable and start the timer (not the service — the timer triggers the service):
1sudo systemctl daemon-reload
2sudo systemctl enable --now backup.timer
enable makes the timer persist across reboots; --now also starts it immediately so you do not have to wait for the next boot. Confirm it is active and see exactly when it will next fire:
1systemctl list-timers
This is the command you will return to constantly. It lists every active timer with its next run time, its last run time, and the unit it triggers — a single, accurate view of everything scheduled on the machine, which is more than crontab -l has ever given you. To prove the job itself works without waiting for 02:30, trigger the underlying service by hand:
1sudo systemctl start backup.service
Step 5: Read the Logs
This is where the model pays off. Every run's output — stdout, stderr, exit status — is captured in the journal and queryable by unit:
1journalctl -u backup.service
To watch a run live, add -f; to see only the most recent run, add -e. A failed job announces itself in systemctl status backup.service with a red failed state and the error in the log, rather than hiding in unread mail. That visibility — did it run, did it succeed, what did it say — answered with one journalctl command, is the entire reason to prefer timers for jobs you depend on.
When Cron Is Still Fine
None of this means deleting your crontab tonight. For a quick personal one-liner on a machine that is always on and whose failure you would notice anyway, a cron entry is less typing and perfectly adequate. The case for timers is operational maturity: jobs whose success matters, that must survive reboots, that depend on other services, or whose logs you need to read after the fact. For a backup, a certificate renewal, a database maintenance task — anything where "did it actually run, and did it work?" is a question you will one day need answered — the paired service-and-timer is worth the extra file. Write the two units, enable --now the timer, and systemctl list-timers becomes the single honest source of truth for everything scheduled on your VPS.
References
Posts in this series
- Linux VPS Performance Tuning: sysctl & swap 2026
- Systemd Service Hardening: Sandbox a Unit (2026)
- Fail2ban on Ubuntu VPS: Stop SSH Brute Force 2026
- UFW Firewall Rules for a Public VPS: 2026 Setup
- How to Harden SSH on an Ubuntu VPS (2026 Guide)
- Automated Backups with Rsync and Cron on Linux 2026
- TLS Certificates with Certbot on an Ubuntu VPS 2026
- Harden Nginx on Ubuntu: Headers, TLS & Limits 2026
- Automatic Security Updates on Ubuntu: 2026 Setup
- Logrotate for Nginx and App Logs on a Linux VPS
- Systemd Timers: Replace Cron Jobs on a Linux VPS
- Manage Linux Users and sudo Permissions on a VPS
- Linux VPS Disk and Inode Cleanup: du, find, lsof
- journald Logs: Retention, Size Limits and Queries