
Cron works—until it doesn’t. On a hosting VPS, the bigger risk is “silent failure.” A backup script can start, then die halfway through. A WordPress cron wrapper may never fire. A job can get skipped during a reboot and nobody notices.
This systemd timer setup guide tutorial shows how to replace cron with systemd timers you can audit. Your schedules survive restarts. Every run logs to the journal.
You’ll build three timers you can use immediately:
- a logrotate verification check
- a WordPress task runner
- a lightweight health report that emails you only when something is off
The examples assume Ubuntu 24.04/26.04 or Debian 12/13 on a VPS.
The same pattern applies on AlmaLinux/Rocky, as long as you’re on systemd.
What you’ll build (and why timers are a better fit than cron)
- Predictable scheduling with
OnCalendar=(cron-like) plus catch-up withPersistent=trueafter reboots. - Per-job logging in
journalctl, plus optional file logs for long-running scripts. - Guardrails: timeouts, retry behavior, and “don’t run two copies at once.”
- Safer operations: timers run under a dedicated user with a tight execution environment.
If you manage multiple sites (or reseller workloads), timers also simplify migrations.
Keep unit files in your repo. Deploy them with your tooling. Reapply them after a move without rebuilding a mystery crontab.
Running customer sites? Start with a stable base. A HostMyCode VPS gives you predictable CPU/RAM for scheduled tasks like backups, rotations, and periodic maintenance—without shared-host caps getting in the way.
Prerequisites and quick checks
Before you write any units, confirm you’re running systemd. Also confirm the system clock is correct:
ps -p 1 -o comm=
# should print: systemd
timedatectl status
# confirm correct timezone and NTP sync
If you’ve seen TLS errors, “ran at the wrong time” behavior, or cron oddities after drift, fix time sync first.
It’s usually quick. Use: VPS time sync troubleshooting.
How systemd timers map to cron (mental model)
A timer triggers a service unit.
Think “schedule file” plus “task file.”
/etc/systemd/system/myjob.servicedefines what runs, as which user, and with what limits./etc/systemd/system/myjob.timerdefines when it runs, and whether it should catch up.
Two timer modes cover most hosting needs:
- Calendar timers (
OnCalendar=) match cron-style schedules. - Monotonic timers (
OnBootSec=,OnUnitActiveSec=) run relative to boot or the last run.
For backups, rotations, and scheduled reports, calendar timers are usually the right choice.
Create a dedicated user for scheduled jobs
Don’t run routine automation as root unless you have a clear reason.
Create a low-privilege account. Then grant only what the job needs.
sudo adduser --system --group --home /var/lib/svc-jobs svc-jobs
sudo install -d -o svc-jobs -g svc-jobs -m 0750 /var/log/svc-jobs
Some tasks do need root (logrotate checks, package updates, firewall reloads).
In those cases, keep the service running as root. Then lock it down with systemd hardening options later in this tutorial.
Example 1: A logged, catch-up daily job (log rotation verification)
“Disk full” incidents are rarely sudden. They usually creep in when logs stop rotating. Or /var grows quietly until it’s too late.
This daily verification timer does not rotate logs.
It warns you when rotation is likely broken.
If you’re already out of space, fix the emergency first. Then put the guardrail in place.
HostMyCode’s cleanup walkthrough is here: disk space troubleshooting.
Step 1: Create the script
Create /usr/local/sbin/logrotate-verify.sh:
sudo install -m 0755 /dev/stdin /usr/local/sbin/logrotate-verify.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
STATE=/var/lib/logrotate/status
CONF=/etc/logrotate.conf
if [[ ! -f "$CONF" ]]; then
echo "ERROR: missing $CONF"; exit 2
fi
# Dry-run with verbose output; exit non-zero on config issues.
/usr/sbin/logrotate -d -v "$CONF" >/tmp/logrotate.verify.out 2>&1 || {
echo "ERROR: logrotate dry-run failed";
tail -n 80 /tmp/logrotate.verify.out;
exit 3;
}
# Basic sanity: state file exists and is writable by root.
if [[ ! -f "$STATE" ]]; then
echo "WARN: $STATE not found yet (first run?)"
fi
# Quick high-signal check: largest logs in /var/log
echo "Top logs in /var/log (size):"
find /var/log -type f -printf '%s %p\n' 2>/dev/null | sort -nr | head -n 15 | awk '{printf "%.1fMiB %s\n", $1/1024/1024, $2}'
echo "OK: logrotate verification completed"
EOF
Step 2: Create the service unit
Create /etc/systemd/system/logrotate-verify.service:
sudo install -m 0644 /dev/stdin /etc/systemd/system/logrotate-verify.service <<'EOF'
[Unit]
Description=Verify logrotate config and spot runaway logs
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/logrotate-verify.sh
# Guardrails
TimeoutStartSec=2min
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
# Basic hardening (safe for oneshot scripts)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log /var/lib/logrotate /tmp
EOF
Step 3: Create the timer
Create /etc/systemd/system/logrotate-verify.timer:
sudo install -m 0644 /dev/stdin /etc/systemd/system/logrotate-verify.timer <<'EOF'
[Unit]
Description=Daily logrotate verification
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
EOF
Step 4: Enable and test
sudo systemctl daemon-reload
sudo systemctl enable --now logrotate-verify.timer
# Run immediately for a first pass
sudo systemctl start logrotate-verify.service
# View output
journalctl -u logrotate-verify.service --no-pager -n 200
Why this helps: if logrotate breaks due to a bad snippet, a permission change, or a filesystem problem, you’ll see it in the journal on the next run.
Cron often “solves” this by emailing output.
That only works if local mail is configured, which on many VPS builds it isn’t.
Example 2: Replace a WordPress “pseudo-cron” with a real timer
WordPress relies on wp-cron.php.
It runs only when someone visits the site.
On low-traffic installs, scheduled posts, backups, and WooCommerce cleanup can drift by hours.
The common fix is disabling WP-Cron and calling it from a scheduler. With systemd, you get cleaner logs and better controls than stuffing more entries into a crontab.
This fits nicely with VPS-hosted WordPress.
If you’re migrating off shared hosting, keep the cutover predictable: WordPress migration tutorial.
Step 1: Disable WP-Cron in wp-config.php
Edit your site’s wp-config.php (example path):
sudo nano /var/www/example.com/public_html/wp-config.php
Add:
define('DISABLE_WP_CRON', true);
Step 2: Create a wp-cron runner script
Create /usr/local/sbin/wp-cron-runner.sh (adjust domain and docroot):
sudo install -m 0755 /dev/stdin /usr/local/sbin/wp-cron-runner.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SITE_URL="https://example.com"
DOCROOT="/var/www/example.com/public_html"
# Prefer WP-CLI if available; otherwise fall back to hitting wp-cron.php
if command -v wp >/dev/null 2>&1; then
cd "$DOCROOT"
wp cron event run --due-now --quiet
echo "OK: ran due WP-Cron events via WP-CLI"
else
curl -fsS "${SITE_URL}/wp-cron.php?doing_wp_cron=1" >/dev/null
echo "OK: triggered wp-cron.php via HTTP"
fi
EOF
Step 3: Create the service with concurrency control
On slower sites, overlapping runs can pile up fast.
Use a lock file so only one run is active at a time.
sudo install -m 0644 /dev/stdin /etc/systemd/system/wp-cron-example.service <<'EOF'
[Unit]
Description=Run WordPress cron for example.com
[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com/public_html
# Prevent overlap
ExecStart=/usr/bin/flock -n /run/wp-cron-example.lock /usr/local/sbin/wp-cron-runner.sh
# Guardrails
TimeoutStartSec=2min
CPUQuota=30%
MemoryMax=256M
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/example.com/public_html /run
EOF
Step 4: Create the timer
Every 5 minutes is a common baseline for WordPress:
sudo install -m 0644 /dev/stdin /etc/systemd/system/wp-cron-example.timer <<'EOF'
[Unit]
Description=Schedule WordPress cron for example.com
[Timer]
OnCalendar=*:0/5
Persistent=true
RandomizedDelaySec=30s
[Install]
WantedBy=timers.target
EOF
Step 5: Enable and verify
sudo systemctl daemon-reload
sudo systemctl enable --now wp-cron-example.timer
systemctl list-timers --all | grep wp-cron-example
Tip for multi-site VPS: copy the unit pair per site, or template it (advanced).
If you host customer sites, keep some jitter (RandomizedDelaySec).
That way ten sites don’t all spike CPU on the same second.
Example 3: A weekly “only email me if something’s wrong” health report
Noisy alerts train you to ignore alerts.
For small hosting stacks, a weekly report that only emails on threshold breaches is a better default.
This example checks disk usage, inode usage, and failed systemd units.
It then mails root (or your ops alias).
If outbound mail matters for your stack, get DNS deliverability basics right first (SPF/DKIM/DMARC, rDNS).
Two good references: MX record setup tutorial and reverse DNS setup.
Step 1: Install a minimal mailer (if needed)
On Ubuntu/Debian, mailutils uses a local MTA.
If you already run Postfix/Exim, skip the install.
sudo apt update
sudo apt install -y mailutils
Step 2: Create the checker script
Create /usr/local/sbin/vps-weekly-health.sh:
sudo install -m 0755 /dev/stdin /usr/local/sbin/vps-weekly-health.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
ALERT=0
HOST=$(hostname -f 2>/dev/null || hostname)
REPORT=$(mktemp)
cleanup() { rm -f "$REPORT"; }
trap cleanup EXIT
{
echo "Weekly VPS health report: $HOST"
echo "Generated: $(date -Is)"
echo
echo "== Disk usage (top filesystems) =="
df -hT -x tmpfs -x devtmpfs | awk 'NR==1 || $6 ~ /^\/$/ || $6 ~ /^\/var/ || $6 ~ /^\/home/ {print}'
echo
echo "== Inode usage (top filesystems) =="
df -i -x tmpfs -x devtmpfs | awk 'NR==1 || $6 ~ /^\/$/ || $6 ~ /^\/var/ || $6 ~ /^\/home/ {print}'
echo
echo "== Failed systemd units =="
systemctl --failed --no-pager || true
echo
} > "$REPORT"
# Threshold checks
# Disk >= 85% on any real filesystem
if df -P -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); if ($5 >= 85) exit 1}' ; then :; else ALERT=1; fi
# Inodes >= 85%
if df -Pi -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); if ($5 >= 85) exit 1}' ; then :; else ALERT=1; fi
# Any failed units
if systemctl --failed --quiet; then ALERT=1; fi
if [[ "$ALERT" -eq 1 ]]; then
SUBJECT="[ALERT] VPS health issues on $HOST"
mail -s "$SUBJECT" root < "$REPORT"
echo "ALERT: mailed report"
else
echo "OK: no issues found"
fi
EOF
Step 3: Create the service + timer
sudo install -m 0644 /dev/stdin /etc/systemd/system/vps-weekly-health.service <<'EOF'
[Unit]
Description=Weekly VPS health report (mail on issues only)
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/vps-weekly-health.sh
TimeoutStartSec=3min
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/tmp
EOF
sudo install -m 0644 /dev/stdin /etc/systemd/system/vps-weekly-health.timer <<'EOF'
[Unit]
Description=Schedule weekly VPS health report
[Timer]
OnCalendar=Sun *-*-* 06:40:00
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now vps-weekly-health.timer
sudo systemctl start vps-weekly-health.service
journalctl -u vps-weekly-health.service --no-pager -n 120
Common pitfalls (and quick diagnostics)
- Timer enabled, but nothing runs: check
systemctl list-timers --alland confirm the next run time. Also confirm the timer hasWantedBy=timers.target. - Service fails with “permission denied”: your hardening settings may be too strict. Remove
ProtectSystem=strictfirst. Confirm it runs. Then add restrictions back carefully. - Jobs overlap: add a lock via
flock(shown above) or setRefuseManualStart=if you want to prevent ad-hoc runs. - Missed runs after reboot: set
Persistent=trueon the timer. - You can’t find logs: use
journalctl -u yourjob.service. If the script logs to files, keep them under/var/log/.... Also include that path inReadWritePaths=.
Operational checklist: migrating from cron to systemd timers safely
- Inventory cron:
crontab -l,sudo crontab -l, and files under/etc/cron.d,/etc/cron.daily, etc. - Move one job at a time. Disable its cron entry only after the timer runs cleanly twice.
- Give each job a dedicated service name. You’ll thank yourself later during incidents.
- Add guardrails: timeouts, memory limits, and a lock.
- Document where the unit files live (
/etc/systemd/system) and keep copies in your config repo.
Summary: use systemd timers as your “auditable cron” layer
On a hosting VPS, reliability comes from boring jobs that don’t skip.
That includes maintenance scripts, WordPress scheduled tasks, periodic verification, and simple reporting.
Systemd timers give you visibility via journalctl.
They also add safety controls like timeouts and locks. And they behave predictably across reboots.
If you want to standardize these patterns on infrastructure you can grow into, start on a managed VPS hosting plan from HostMyCode. You get a stable baseline for scheduled maintenance, plus help as you roll the same job set across multiple sites.
If you’re replacing cron across customer sites or setting up repeatable maintenance on a new server, HostMyCode is a practical place to run it. Pick a HostMyCode VPS for full control, or choose managed VPS hosting if you want a second set of eyes on updates, security, and routine ops.
FAQ
Can systemd timers fully replace cron on a VPS?
Yes. For most hosting automation (maintenance scripts, WordPress tasks, backups, reports), timers are a direct replacement with better logging and reboot catch-up.
Where should I store unit files for server-wide timers?
Use /etc/systemd/system/ for custom services and timers. Avoid editing files under /lib/systemd/system because package updates can overwrite them.
How do I see the exact output from a timer run?
Run journalctl -u yourjob.service. Add -n 200 for the last 200 lines, or --since "today" to filter by time.
How do I prevent two runs from overlapping?
Wrap the command with flock, for example: ExecStart=/usr/bin/flock -n /run/myjob.lock /usr/local/sbin/myjob.sh. This is simple and works well for oneshot jobs.
Do I still need server hardening if I’m using timers?
Yes. Timers don’t secure your server; they just schedule work. If you’re tightening SSH and baseline security, follow a step-by-step hardening plan like this server hardening tutorial.