Replace Cron with Reliable systemd Timers on Linux

Cron is wonderfully small, but production jobs often need more than “run this command at this time.” You may need structured logs, missed-run recovery after a reboot, controlled credentials, resource limits, and a clear answer when someone asks why yesterday’s backup did not run.

On a systemd-based Linux server, a timer activates a service unit. That separation is the useful part: the timer owns the schedule, while the service owns the command, environment, user, security boundaries, timeout, and logs. This tutorial builds a daily application-backup timer that is easy to test and operate.

The examples use directives available across commonly deployed systemd versions. As of September 2026, the current stable systemd release is v261.2, but you do not need that release to follow the core workflow.

When a systemd timer is a better fit than cron

Keep cron when the job is simple, portable, and already observable. Prefer a systemd timer when the job belongs to one Linux host and benefits from service management features.

A timer-service pair gives you:

  • one journal for standard output, standard error, start time, exit status, and service metadata;
  • Persistent=true to run once after the machine returns if a calendar activation was missed;
  • an explicit runtime user, working directory, environment file, and dependencies;
  • service hardening such as filesystem protection and privilege restrictions;
  • familiar operations through systemctl, journalctl, and systemd-analyze;
  • randomized delay so a fleet does not start the same job simultaneously.

Timers do not turn a script into a distributed job system. If a task must run exactly once across several hosts, coordinate with a database lock, queue, or scheduler designed for distributed execution.

Build a production-friendly job

The example writes a compressed PostgreSQL dump to /var/backups/myapp. Adapt the script to your database or replace it with any non-interactive command.

1. Create a dedicated account and directories

Run these commands as root:

useradd --system --home /var/lib/myapp-backup --create-home \
  --shell /usr/sbin/nologin backup

install -d -o backup -g backup -m 0750 /var/backups/myapp
install -d -o root -g backup -m 0750 /etc/myapp
install -d -o root -g root -m 0755 /usr/local/libexec

A dedicated account limits the damage a compromised command can cause. Do not run a backup as root merely because cron used to do so.

2. Put secrets in a protected environment file

Create /etc/myapp/backup.env:

PGHOST=127.0.0.1
PGPORT=5432
PGDATABASE=myapp
PGUSER=myapp_backup
PGPASSWORD=replace-with-a-secret

Then restrict it:

chown root:backup /etc/myapp/backup.env
chmod 0640 /etc/myapp/backup.env

An environment file is convenient, but it is not a secret manager. On a larger deployment, inject short-lived credentials or use systemd credentials if your distribution supports your desired workflow. Never put a password directly in a unit’s ExecStart= line because command lines and unit definitions are easy to inspect.

3. Write a deterministic script

Save this as /usr/local/libexec/myapp-backup:

#!/bin/sh
set -eu

backup_dir=/var/backups/myapp
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
destination="$backup_dir/myapp-$timestamp.sql.gz"
temporary="$destination.tmp"

cleanup() {
  rm -f "$temporary"
}
trap cleanup EXIT HUP INT TERM

/usr/bin/pg_dump --format=plain --no-owner --no-privileges \
  | /usr/bin/gzip -9 > "$temporary"

test -s "$temporary"
mv "$temporary" "$destination"
trap - EXIT HUP INT TERM

# Retain 14 days of completed backups.
/usr/bin/find "$backup_dir" -type f -name 'myapp-*.sql.gz' \
  -mtime +14 -delete

Install it with root ownership so the service account cannot alter the code it runs:

chown root:root /usr/local/libexec/myapp-backup
chmod 0755 /usr/local/libexec/myapp-backup

Use absolute command paths. Scheduled jobs receive a smaller environment than an interactive shell, so relying on aliases, a shell profile, or an assumed PATH creates fragile failures. Writing to a temporary file before mv also keeps an interrupted dump from looking complete.

Define the service unit

Create /etc/systemd/system/myapp-backup.service:

[Unit]
Description=Create a daily backup of the MyApp database
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=backup
Group=backup
EnvironmentFile=-/etc/myapp/backup.env
ExecStart=/usr/local/libexec/myapp-backup
TimeoutStartSec=30min
Nice=10

# Conservative hardening for this workload
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/backups/myapp

Type=oneshot means the service is considered active while the command runs and inactive after it exits. TimeoutStartSec prevents a stalled dump from running forever. ProtectSystem=strict makes most of the filesystem read-only, while ReadWritePaths opens only the backup destination for writing.

Test hardening against the real program. A job may also need writable access to a Unix socket, cache directory, or mounted backup target. Add only the path it actually requires.

The leading hyphen in EnvironmentFile=-... makes a missing file non-fatal. Remove the hyphen if absence should stop the unit immediately; for credentials, failing closed is often preferable.

Define and validate the timer

Create /etc/systemd/system/myapp-backup.timer:

[Unit]
Description=Run the MyApp backup every day

[Timer]
OnCalendar=*-*-* 06:15:00
Persistent=true
RandomizedDelaySec=10m
AccuracySec=1m
Unit=myapp-backup.service

[Install]
WantedBy=timers.target

The nominal schedule is 06:15 in the system’s local time. RandomizedDelaySec=10m spreads actual starts across the following ten minutes—useful when many hosts call the same database or API. AccuracySec=1m allows systemd to coalesce wakeups within a one-minute window.

Persistent=true records the last activation. If the timer was inactive while the scheduled time passed, systemd activates the service after the timer starts again. It does not replay every missed day; it performs one catch-up activation.

Before enabling anything, ask systemd to parse both the calendar and the unit files:

systemd-analyze calendar '*-*-* 06:15:00'
systemd-analyze verify \
  /etc/systemd/system/myapp-backup.service \
  /etc/systemd/system/myapp-backup.timer

The calendar command prints the normalized expression and upcoming runs. It is the fastest way to catch a schedule that means something different from what you intended.

Test before enabling the schedule

Reload unit definitions, then start the service itself—not the timer—to exercise the job immediately:

systemctl daemon-reload
systemctl start myapp-backup.service
systemctl status myapp-backup.service
journalctl -u myapp-backup.service --since today --no-pager
ls -lh /var/backups/myapp

A successful oneshot unit normally becomes inactive (dead) after completion. The important result is status=0/SUCCESS, a valid backup file, and a restore test. A file existing is not proof that it can be restored.

Only after the manual run succeeds should you enable the timer:

systemctl enable --now myapp-backup.timer
systemctl list-timers --all myapp-backup.timer
systemctl status myapp-backup.timer

Editing a unit later requires another systemctl daemon-reload. Restart the timer after changing its schedule:

systemctl daemon-reload
systemctl restart myapp-backup.timer

Monitoring and failure handling

Start with the journal:

journalctl -u myapp-backup.service --since '7 days ago'
systemctl show myapp-backup.service \
  -p Result -p ExecMainStatus -p ActiveEnterTimestamp
systemctl list-timers --all myapp-backup.timer

For real production use, alert on service failure rather than assuming someone will read the journal. You can connect OnFailure= to a notification unit or have your monitoring agent inspect unit state. Keep notification credentials outside the backup script.

systemd will not start a second instance of the same service while it is already active. That prevents duplicate instances on one host, but a slow job can cause an activation to be skipped rather than queued. Choose a schedule longer than the normal runtime, set a meaningful timeout, and monitor duration.

Common problems and fixes

The command works in a shell but fails as a service

Inspect the journal for command not found, permission errors, or missing variables. Use absolute paths, put required variables in EnvironmentFile, set WorkingDirectory= if the program uses relative paths, and test as the service user:

sudo -u backup /usr/local/libexec/myapp-backup

The timer shows the wrong next run

Check the host clock and timezone:

timedatectl status
systemd-analyze calendar '*-*-* 06:15:00'

Use an explicit timezone in the calendar expression only after checking that the systemd version on every target supports the syntax you plan to deploy.

A job ran immediately after boot

That is expected with Persistent=true when a calendar activation was missed. Remove persistence only if catching up would be harmful. For jobs such as monthly billing, design idempotency instead of relying on the clock alone.

The timer is active but the job failed

Timer state and service state are separate. A healthy timer can activate a failing service. Check both units and the service journal; do not treat active (waiting) on the timer as proof of job success.

Hardening blocked a required path

The journal usually reports a read-only filesystem or permission error. Add a narrow ReadWritePaths= entry or adjust ownership. Avoid disabling all hardening to solve one missing path.

Production checklist

  • Run the job as a dedicated, least-privileged account.
  • Use absolute paths and a non-interactive script.
  • Protect secrets and keep them out of ExecStart=.
  • Write output atomically and verify the artifact.
  • Validate schedules with systemd-analyze calendar.
  • Validate units with systemd-analyze verify.
  • Test the service manually before enabling the timer.
  • Decide deliberately whether missed runs should catch up.
  • Add jitter for fleet-wide or API-heavy jobs.
  • Set a timeout and monitor failures and runtime.
  • Test recovery or restoration, not just creation.
  • Document the timezone and retention policy.

Official references