Why automate tasks on a VPS?
A VPS is a server running continuously, often without direct human supervision. That is precisely why automating recurring tasks is essential: nobody will be around at 3 AM to trigger a backup or renew a Let's Encrypt certificate. A forgotten task can mean data loss, an expired certificate taking the site offline, or a disk full because log rotation never ran.
In practice, five categories of tasks come up on every serious Linux VPS: Nginx log rotation (logrotate or a dedicated script) to prevent /var/log/nginx/ from growing into gigabytes over a few weeks; orphaned Docker image cleanup (docker image prune -af) to reclaim disk space without manual intervention; PostgreSQL backups (pg_dump to remote storage) to guarantee an acceptable RPO in case of failure; SSL certificate checking before expiry, to trigger a certbot renewal rather than discover a broken HTTPS site; and scheduled lightweight scraping or indexing scripts that need to run at a fixed time. The two main tools available on Linux — cron and systemd timers — allow you to schedule these operations reliably.
Typical use cases on a VPS
- Backups — MySQL or PostgreSQL dumps to remote storage (
pg_dump -Fc mydb | gzip > /backup/mydb_$(date +%F).sql.gz), file archiving withrsyncorrcloneto an S3 bucket - TLS certificate renewal —
certbot renew --quietoracme.sh --cronscheduled twice a day, with automatic Nginx reload on success - Log rotation and cleanup — deleting files older than 30 days (
find /var/log -mtime +30 -delete), weekly compression, purging application logs in production - Docker cleanup —
docker image prune -af, removing orphaned volumes, rotating container logs to free up/var/lib/docker - Health checks — verifying a service is listening (
curl -sf http://localhost:3000/health), auto-restarting if needed, alerting via webhook - Data synchronization —
rsyncto a second node, cache refresh, syncing static files to a CDN - Scheduled application tasks — sending reminder emails, computing periodic reports, content indexing, lightweight scraping scripts
cron: a quick refresher
Cron is present on virtually every Linux system. To schedule a task, edit the current user's crontab with crontab -e. The syntax uses five fields separated by spaces, followed by the command:
# ┌── minute (0-59)
# │ ┌── hour (0-23)
# │ │ ┌── day of month (1-31)
# │ │ │ ┌── month (1-12)
# │ │ │ │ ┌── day of week (0-7, 0=Sunday)
# │ │ │ │ │
0 3 * * * /usr/local/bin/backup.shShortcuts @daily, @weekly, @monthly and @reboot simplify common cases. Files placed in /etc/cron.d/ belong to the system and can specify the execution user directly on the line. To review past executions, two paths: /var/log/syslog (Debian/Ubuntu) filtered with grep CRON /var/log/syslog, or journalctl -t CRON on systems with journald. One often-underestimated detail: cron's default PATH is minimal — /usr/bin:/bin — which forces the use of absolute paths in scripts, or redefining PATH= at the top of the crontab. The MAILTO variable controls output mailing; setting it to empty (MAILTO="") silently disables it. Cron keeps no native history of past runs and does not handle missed jobs if the machine was off.
systemd timers: an introduction
A systemd timer is a pair of two unit files: a .service file describing what to run, and a .timer file describing when to trigger it. The timer is activated with systemctl enable --now myservice.timer and managed by the service manager, exactly like any other systemd service.
The OnCalendar= directive accepts rich, readable syntax:
- daily — every day at midnight
- Mon *-*-* 03:00:00 — every Monday at 3 AM
- *-*-* 02:30:00 — every day at 2:30 AM
- *:0/15 — every 15 minutes
The AccuracySec= directive (default: 1 minute) lets systemd group nearby timers to save CPU wakeups — on a shared or low-power server, reducing this to 1s ensures precise trigger times. The Persistent=true directive in the [Timer] block is the feature that sets systemd timers apart from cron: if the machine was off at the scheduled time, the timer catches up the missed execution at next boot. All output goes through journald and is accessible via journalctl -u myservice.service. The command systemctl list-timers shows all active timers, their next deadline and last activation date.
cron vs systemd timers: comparison table
Scroll the table
| Criterion | cron | systemd timer |
|---|---|---|
| Schedule syntax | 5-field `* * * * *` | `OnCalendar=` readable (`daily`, `Mon 03:00`) |
| Logs | Mail output or manual redirect, `/var/log/syslog` | Native journald, `journalctl -u` |
| Missed jobs (machine off) | Lost (unless `anacron`) | `Persistent=true` replays after reboot |
| Inter-service dependencies | None | `After=`, `Requires=`, `Wants=` |
| Isolation and resource limits | Inherits shell env, minimal PATH | Cgroups, `MemoryMax=`, `CPUQuota=` |
| Failure notification | None natively | `OnFailure=notify.service` triggers an alert |
| Run as specific user | User field in `/etc/cron.d/` | `User=` and `Group=` in `.service` |
| Debugging | Hard without logs | `systemctl status`, `journalctl -xe` |
| Availability | All Unix systems (containers, BSD, Alpine) | Systems with systemd (Debian, Ubuntu, RHEL…) |
When to keep cron?
Cron remains the right choice in several concrete situations. On older or minimal systems without systemd — some lightweight Docker containers, Alpine images, BSD systems — cron is often the only tool available and there is no reason to move away from it. In a multi-user environment where each developer manages their own tasks via crontab -e without root rights, cron is simpler to delegate: creating a systemd .service requires access to /etc/systemd/system/.
For simple one-liner scripts — a weekly certbot renew, a nightly rsync with no dependencies — the crontab stays readable and maintainable without multiplying files. Cron is also the right choice for onboarding a non-Linux team: the five-field syntax, documented everywhere, is more accessible than a systemd unit for someone new to system administration. Finally, if your cron tasks have been working for years without issues, migration brings no immediate value. The deciding criterion is the need for structured logs, inter-service dependencies, or resource constraints — if you don't need them, cron does the job.
When to switch to systemd timers?
Systemd timers become the clear choice as soon as the task goes beyond a simple isolated command. You need journald to trace every execution, its output and return code without manual plumbing? Systemd timers. Your backup script must only run after PostgreSQL is ready, declaring After=postgresql.service in the [Unit] block? Systemd timers. You want to cap the memory of a sync job so it does not starve other processes (MemoryMax=512M)? Systemd timers.
Inter-service dependencies are the strongest argument: a backup launched while the database is not yet available after a reboot produces an empty file with no visible error under cron. Systemd guarantees the order. Integrated logging via journald removes all the noise from manual redirections — journalctl -u backup.service --since yesterday gives the full history with return codes. Error handling with OnFailure= lets you trigger a notification (email, webhook) without polluting the script itself. And if the server restarts at 2:58 AM when the 3 AM backup was due, Persistent=true guarantees it will run at next boot — something cron cannot do without anacron.
Create a systemd timer from scratch (example: daily backup)
Create the service file
Open
/etc/systemd/system/backup.serviceand enter:[Unit] Description=Daily PostgreSQL backup After=postgresql.service [Service] Type=oneshot User=postgres ExecStart=/usr/local/bin/backup.shThe
oneshottype indicates the service exits after the script completes — the standard type for scheduled tasks.Create the timer file
Create
/etc/systemd/system/backup.timerwith:[Unit] Description=Daily trigger for backup [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true [Install] WantedBy=timers.targetPersistent=trueensures that if the machine was off at 3 AM, the backup runs at next boot.Reload systemd and enable the timer
Run
systemctl daemon-reloadso systemd picks up the new unit files, then activate and immediately start the timer:systemctl enable --now backup.timerCheck with systemctl status
Check the timer state with
systemctl status backup.timer— the output must showactive (waiting)and display the next trigger date. Also check the service state after the first run withsystemctl status backup.service.List all active timers
The command
systemctl list-timers --alldisplays a table of all system timers with three key columns:NEXT(next execution),LEFT(time remaining) andLAST(last execution). This is your dashboard for verifying that your timers fire as expected.Read the logs
Access all service output with
journalctl -u backup.servicefor the full history, orjournalctl -u backup.service -n 50 --since todayfor the last 50 lines today. The script's return code is visible in the journald output.Add a failure notification (optional)
To be alerted on failure, add in the
[Unit]block of the.servicefile:OnFailure=notify-failure@%n.serviceThis service can send an email, a Slack webhook, or an ntfy ping depending on your infrastructure. This is what cron cannot do natively without plumbing inside the script itself.
Test the service manually
Before waiting for the scheduled trigger, test immediate execution with
systemctl start backup.service(without.timer). Check the result immediately withjournalctl -u backup.service -n 20. If the script succeeds, the setup is validated.
Migrate an existing cron job to systemd
Identify the cron job to migrate
List your crontabs with
crontab -l(current user) andcat /etc/cron.d/*(system). Note the exact command, the execution user, and the five-field schedule. For example:30 2 * * 1 root /usr/bin/certbot renew --quietmeans every Monday at 2:30 AM as root.Translate the schedule to OnCalendar
The
OnCalendar=format readsDayOfWeek Year-Month-Day Hour:Minute:Second.30 2 * * 1becomesMon *-*-* 02:30:00.To validate the translation before applying it, use
systemd-analyze calendar:systemd-analyze calendar 'Mon *-*-* 02:30:00'The command displays the next 10 calculated occurrences, eliminating any ambiguity about the actual behavior.
Create the .service and .timer files
Create
/etc/systemd/system/certbot-renew.servicewithType=oneshot,ExecStart=/usr/bin/certbot renew --quiet, andUser=root. Then create/etc/systemd/system/certbot-renew.timerwithOnCalendar=Mon *-*-* 02:30:00andPersistent=true. Runsystemctl daemon-reload && systemctl enable --now certbot-renew.timer.Test the service before disabling cron
Test execution immediately with
systemctl start certbot-renew.serviceand check the result viajournalctl -u certbot-renew.service -n 20. Verify the timer appears insystemctl list-timerswith a coherent next date. Only disable the cron line after validation.Disable the cron line
Once the timer is validated, comment out or delete the line in the original crontab. For a file in
/etc/cron.d/, rename it with a.disabledextension or delete it. Leave both running in parallel for a few days if the task is critical — cron and systemd timers do not interfere.
Validate and debug your OnCalendar expressions
Before activating a timer in production, always validate your OnCalendar= expression with systemd-analyze calendar:
systemd-analyze calendar "daily"
systemd-analyze calendar "Mon *-*-* 02:30:00"
systemd-analyze calendar "*:0/15"Each command displays the next occurrence and the following ten — making a malformed expression or unexpected time offset immediately visible. For an overview of all your active timers with their next trigger date and time remaining, systemctl list-timers --all is your daily operational dashboard.
Common systemd timer troubleshooting
Five problems come up regularly when setting up timers.
1. "Unit not found" on systemctl enable — the .timer or .service file is not in /etc/systemd/system/ or has an incorrect name. Check the exact spelling with ls /etc/systemd/system/*.timer. A daemon-reload is mandatory before any enable or start on a new file.
2. Timer active but never triggers — does systemctl list-timers show a coherent date in the NEXT column? If NEXT is empty or in the past, a missing Persistent=true combined with a machine that hasn't rebooted since the timer was created is a common cause. Check systemctl status backup.timer: active (waiting) is the normal state.
3. ExecStart not found or permission denied — the PATH in a .service is minimal like cron. Always use the absolute path in ExecStart= (/usr/bin/certbot not certbot). Verify the script is executable (chmod +x /usr/local/bin/backup.sh) and that the user declared in User= has access to it.
4. Permissions on the script — if User=postgres but the script is owned by root without execute permission for postgres, the service will fail with code 203/EXEC. Check journalctl -u backup.service -n 20 to see the exact message.
5. Conflict between cron and systemd on the same task — if you are migrating progressively, make sure both are not active simultaneously for a destructive task (log purge, file rotation). Both will execute independently and double the operations. The check is simple: crontab -l | grep backup and systemctl is-active backup.timer.
Conclusion
Cron and systemd timers coexist without issue on the same server — you do not have to migrate everything at once. Keep cron for your simple tasks and existing user scripts, and adopt systemd timers for new automations that benefit from journald logs, inter-service dependencies or cgroup isolation. The systemd-analyze calendar command and systemctl list-timers are your best allies for validating and monitoring your schedules.