systemd timers are the modern replacement for cron on Linux systems. They offer missed-run catch-up (if the system is off when a job was scheduled to run, it runs when the system comes back), structured logging via journald, and the full suite of systemd service features: restart policies, resource limits, user isolation, and dependency management.
TL;DR
Two unit files: a .service that runs your conversion script once and exits, and a .timer that fires the service on schedule. Enable the timer with systemctl enable --now ctf-convert.timer. View logs with journalctl -u ctf-convert.service.
systemctl enable --now ctf-convert.timer
systemctl list-timers ctf-convert.timer # Next run time
journalctl -u ctf-convert.service -n 50 # Recent logs
The use case
You have a web server that accepts document uploads — DOCX, PPTX, XLSX — to /var/uploads/pending/. You want them converted to PDF every 5 minutes and moved to /var/uploads/ready/ for downstream processing. If the server reboots at 3:55am and the 4:00am conversion didn't run, systemd should run it as soon as the server comes back up.
The key difference from cron:
- Missed run catch-up —
Persistent=truein the timer records the last run time on disk. If the system was down when the job should have run, it runs immediately on boot. - Structured logging — journalctl indexes output by unit, timestamp, and priority.
journalctl -u ctf-convert.service --since yesterdaygives you all conversion logs from yesterday. - Dependency management —
After=network-online.targetensures the service doesn't run before the network is up. - Resource limits —
CPUQuotaandMemoryMaxin the service unit prevent a runaway conversion job from starving other services.
Unit files and conversion script
First, the conversion script at /opt/scripts/ctf-upload-convert.sh:
#!/usr/bin/env bash
set -euo pipefail
API_KEY="${CTF_API_KEY:?CTF_API_KEY not set}"
PENDING_DIR="${CTF_PENDING_DIR:-/var/uploads/pending}"
READY_DIR="${CTF_READY_DIR:-/var/uploads/ready}"
FAILED_DIR="${CTF_FAILED_DIR:-/var/uploads/failed}"
TARGET="${CTF_TARGET_FORMAT:-pdf}"
API_URL="https://changethisfile.com/v1/convert"
log() { echo "$*"; } # systemd timestamps via journald
mkdir -p "$READY_DIR" "$FAILED_DIR"
shopt -s nullglob
files=("$PENDING_DIR"/*)
shopt -u nullglob
if [[ ${#files[@]} -eq 0 ]]; then
log "No pending files"
exit 0
fi
log "Processing ${#files[@]} file(s)"
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
filename=$(basename "$f")
stem="${filename%.*}"
out="$READY_DIR/${stem}.$TARGET"
tmp="$out.tmp.$$"
log "Converting: $filename"
http_status=$(curl -sf \
--max-time 120 \
-w "%{http_code}" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@$f" \
-F "target=$TARGET" \
-o "$tmp" \
"$API_URL" 2>&1
) || { log "ERROR: curl failed for $filename"; mv "$f" "$FAILED_DIR/"; rm -f "$tmp"; continue; }
if [[ "$http_status" == "200" ]] && [[ -s "$tmp" ]]; then
mv "$tmp" "$out"
rm "$f" # Remove from pending
log "OK: $filename -> $(basename "$out") ($(stat -c%s "$out") bytes)"
else
log "ERROR: HTTP $http_status for $filename"
mv "$f" "$FAILED_DIR/"
rm -f "$tmp"
fi
done
log "Done"
The service unit at /etc/systemd/system/ctf-convert.service:
[Unit]
Description=ChangeThisFile upload converter
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=www-data
Group=www-data
# Environment from a file — keep API key out of the unit file
EnvironmentFile=/etc/ctf.env
ExecStart=/opt/scripts/ctf-upload-convert.sh
# Resource limits
CPUQuota=50%
MemoryMax=256M
# Timeout — kill if conversion takes more than 10 minutes total
TimeoutStartSec=600
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ctf-convert
The timer unit at /etc/systemd/system/ctf-convert.timer:
[Unit]
Description=Run ChangeThisFile converter every 5 minutes
[Timer]
# Every 5 minutes
OnCalendar=*:0/5
# Catch up on missed runs (e.g., after reboot)
Persistent=true
# Randomize start by up to 30s to avoid thundering herd
RandomizedDelaySec=30
[Install]
WantedBy=timers.target
Error handling and monitoring
Failed files are moved to a separate directory rather than deleted, so nothing is lost. Check the failed directory periodically:
# How many failed conversions are there?
ls -la /var/uploads/failed/ | wc -l
# View conversion logs for the last hour
journalctl -u ctf-convert.service --since '1 hour ago'
# Watch live as conversions run
journalctl -u ctf-convert.service -f
To get alerted on service failures, add an OnFailure handler to the service unit:
[Unit]
Description=ChangeThisFile upload converter
OnFailure=ctf-convert-alert@%n.service
And a corresponding alert service:
[Unit]
Description=Alert on %i failure
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'curl -s -X POST $SLACK_WEBHOOK -d "{\"text\": \"ctf-convert failed on $(hostname)\"}"'
EnvironmentFile=/etc/ctf.env
Timer scheduling options
systemd's OnCalendar syntax is more expressive than cron:
# Every 5 minutes
OnCalendar=*:0/5
# Daily at 2am
OnCalendar=*-*-* 02:00:00
# Weekdays at 8am
OnCalendar=Mon..Fri *-*-* 08:00:00
# Every hour, on the hour
OnCalendar=hourly
# Once per day (systemd shorthand)
OnCalendar=daily
# Validate your expression before deploying:
systemd-analyze calendar '*:0/5'
Always validate calendar expressions with systemd-analyze calendar before deploying. It shows the next 10 scheduled times, which catches syntax errors and off-by-one timezone issues.
For one-shot timers (run once at a specific time, then stop):
[Timer]
OnCalendar=2026-05-01 09:00:00
Unit=ctf-migrate.service
Production tips
- Use Type=oneshot for batch jobs. The service runs, completes its work, and exits with status 0 or 1. systemd tracks the exit code and the timer fires on schedule regardless. Don't use Type=simple for a script that should run and exit.
- EnvironmentFile keeps secrets out of unit files. Unit files are world-readable by default. Store
CTF_API_KEY=ctf_sk_...in/etc/ctf.envwith permissions 600 (root:root). - RandomizedDelaySec prevents thundering herd. If you have multiple servers running the same timer, add RandomizedDelaySec=60 so they don't all hit the API at the same second.
- systemctl list-timers shows next run time. Use this to verify a timer is active and to see when it will next fire:
systemctl list-timers ctf-convert.timer. - Persistent=true is essential for critical jobs. Without it, a missed run during a maintenance window is simply missed. With it, the job runs as soon as the system comes back online.
systemd timers give you everything cron gives you plus missed-run recovery, structured logs, resource limits, and the rest of the systemd service ecosystem — at the cost of two unit files instead of one crontab line. For production servers running critical conversion jobs, that tradeoff is almost always worth it. Get a free API key to start converting.