How to schedule backups with systemd timers
To schedule a backup with systemd, write two units: backup-www.service runs your script once (Type=oneshot), and backup-www.timer starts it on a calendar (OnCalendar=*-*-* 02:17:00, Persistent=true). Enable the timer with sudo systemctl enable --now backup-www.timer. systemd then logs every run to the journal, never starts a second copy while one is running, catches up a run missed while the server was off, and can start an alert when a run fails.
How a timer runs a backup
The timer holds the schedule and the service holds the command. When backup-www.timer elapses, systemd starts the service with the same name, backup-www.service. Your own units go in /etc/systemd/system/; name each pair after its job. Ubuntu backs up its package database this way: systemctl cat dpkg-db-backup.timer shows a timer with just OnCalendar=daily and Persistent=true.
Write the backup service
Start with a script that exits non-zero when anything fails. This is a trimmed version of the one in the cron guide; it prints no times, because the journal stamps every line:
#!/bin/sh
set -eu
BACKUP_DIR=/var/backups/www
ARCHIVE="$BACKUP_DIR/www-$(date +%F).tar.gz"
tar -czf "$ARCHIVE" -C /var www
tar -tzf "$ARCHIVE" > /dev/null
find "$BACKUP_DIR" -name 'www-*.tar.gz' -mtime +14 -delete
echo "done: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"set -eu stops at the first failing command, so a broken archive never reaches the cleanup line, and the non-zero exit marks the run as failed. The service that runs it:
[Unit]
Description=Back up /var/www
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=backup
Group=backup
AmbientCapabilities=CAP_DAC_READ_SEARCH
ExecStart=/usr/local/bin/backup-www.sh
Nice=19
IOSchedulingClass=idle
TimeoutStartSec=4h
UMask=0077
| Setting | What it does |
|---|---|
Type=oneshot | systemd waits for the script to exit; non-zero means the run failed. |
User=, Group= | Run as the backup account Debian and Ubuntu ship (uid 34), not root. |
AmbientCapabilities=CAP_DAC_READ_SEARCH | Lets the job read every file, root-only ones included, but not write them. Drop it if the user can already read everything. |
Nice=19 | Lowest CPU priority. |
IOSchedulingClass=idle | Disk time only when nothing else wants it. |
TimeoutStartSec=4h | Stops a hung run. Oneshot services have no timeout otherwise. |
UMask=0077 | Archives readable by the backup user only, not by everyone (the default, 0022). |
Wants=, After=network-online.target | Waits for the network, which matters when a missed run starts at boot. |
Create the directory the job writes to:
sudo install -d -o backup -g backup -m 750 /var/backups/wwwIn our test as backup, with only that capability and umask, the script archived a root-only file with mode 600, could not create files in a directory it did not own, and the archive came out -rw-------.
IOSchedulingClass=idle works only with the bfq or mq-deadline I/O scheduler (cat /sys/block/vda/queue/scheduler shows the active one in brackets) and, per ioprio_set(2), not for buffered writes. On a busy disk it can also make a run much slower: one more reason to set a timeout.
Write the timer
[Unit]
Description=Nightly backup of /var/www
[Timer]
OnCalendar=*-*-* 02:17:00
Persistent=true
RandomizedDelaySec=15min
AccuracySec=1s
[Install]
WantedBy=timers.target
| Setting | What it does |
|---|---|
OnCalendar=*-*-* 02:17:00 | Every day at 02:17, server time. Repeat the line for more times. |
Persistent=true | Stores the last run time in /var/lib/systemd/timers/. A run missed while the server was off happens once at boot. |
RandomizedDelaySec=15min | Adds a random 0 to 15 minute delay, so servers sharing a bucket don't all upload at once. FixedRandomDelay=true keeps the same delay every night. |
AccuracySec=1s | Fire on time. By default systemd may delay a timer up to a minute to group wake-ups. |
WantedBy=timers.target | Starts the timer at boot once it is enabled. |
The service has no [Install] section: you enable the timer, not the service.
On our test server, Ubuntu's dpkg-db-backup.timer, which keeps the default accuracy, started its last four daily runs between 00:00:02 and 00:00:12.
Check the schedule with systemd-analyze calendar
OnCalendar= is not cron syntax. The form is weekday year-month-day hour:minute:second, with unneeded parts left out, * for any value, , for lists, .. for ranges and / for steps. Check every expression first:
systemd-analyze calendar --iterations=3 'Mon..Fri 01:00' Original form: Mon..Fri 01:00
Normalized form: Mon..Fri *-*-* 01:00:00
Next elapse: Mon 2026-10-05 01:00:00 UTC
From now: 2h 59min left
Iteration #2: Tue 2026-10-06 01:00:00 UTC
From now: 1 day 2h left
Iteration #3: Wed 2026-10-07 01:00:00 UTC
From now: 2 days leftThe normalized form is how systemd reads it; a cron line gets Failed to parse calendar specification '17 2 * * *': Invalid argument. Common schedules, each checked this way:
| cron | OnCalendar= | Runs |
|---|---|---|
17 2 * * * | *-*-* 02:17:00 | Every day at 02:17 |
0 */6 * * * | *-*-* 00/6:00:00 | 00:00, 06:00, 12:00, 18:00 |
*/15 * * * * | *:0/15 | Every 15 minutes |
30 3 * * 0 | Sun 03:30 | Sundays at 03:30 |
0 4 1 * * | *-*-01 04:00 | The 1st of each month at 04:00 |
0 1 * * 1-5 | Mon..Fri 01:00 | Weekdays at 01:00 |
| (not possible) | *-*~01 03:00 | The last day of each month at 03:00 |
Times are server time unless you add a zone: for *-*-* 02:17 Europe/Berlin the next run came out as 00:17 UTC on our UTC server.
Install, enable and test it
Make the script executable, check both units, load them and start the timer:
sudo chmod 755 /usr/local/bin/backup-www.shsudo systemd-analyze verify /etc/systemd/system/backup-www.service /etc/systemd/system/backup-www.timerIt prints nothing when the files are valid. It catches unknown settings, cron-style schedules and a missing script, but in our test not a User= that doesn't exist.
sudo systemctl daemon-reloadsudo systemctl enable --now backup-www.timerenable starts the timer at every boot, --now right away. Check the next run:
systemctl list-timers backup-www.timerNEXT and LEFT say when it runs next, LAST and PASSED when it last ran. Ubuntu's own backup timer on our test server:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-10-05 00:00:00 UTC 1h 51min Sun 2026-10-04 00:00:02 UTC 22h ago dpkg-db-backup.timer dpkg-db-backup.service
1 timers listed.Run the backup once now rather than waiting for 02:17. For a oneshot service the command returns when the script exits (--no-block returns at once), and the run uses the same user, sandbox and logs as a timed one:
sudo systemctl start backup-www.serviceThen restore a file from the archive (testing a restore). We ran systemd-analyze on these files (script paths pointed at a test copy) with systemd 255 on Ubuntu 24.04; daemon-reload, enable and start change a server, so they were checked against the systemctl manual instead.
Read the logs
Everything the script prints lands in the journal under the service's name, timestamped:
journalctl -u backup-www.service --since todayA good run logs Starting backup-www.service - Back up /var/www..., the script's lines, Deactivated successfully and Finished; a failed one ends with Main process exited, code=exited, status=1/FAILURE and Failed with result 'exit-code'. -f follows a run live. For a monitoring check, ask for the last result:
systemctl show backup-www.service -p Result -p ExecMainStatus -p ExecMainExitTimestampAfter a good run it prints Result=success and ExecMainStatus=0.
Get an alert when a run fails
OnFailure= starts other units when this one fails: a non-zero exit, a crash or a timeout. A template unit lets one notifier serve every job:
[Unit]
Description=Send an alert that %i failed
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i
#!/bin/sh
# Started by [email protected] with the name of the unit that failed.
unit="$1"
{
echo "$unit failed on $(hostname) at $(date -Is)"
echo "result: ${MONITOR_SERVICE_RESULT:-?}, exit status: ${MONITOR_EXIT_STATUS:-?}"
echo
journalctl -u "$unit" -n 30 --no-pager -o cat
} | curl -fsS -m 10 --retry 3 -o /dev/null --data-binary @- https://alerts.example.com/hooks/backupsThen add one line to the [Unit] section of backup-www.service:
OnFailure=notify-failure@%n.service%n is the full unit name, so a failure starts [email protected] and %i becomes backup-www.service. From systemd 251 the notifier also gets MONITOR_SERVICE_RESULT and MONITOR_EXIT_STATUS. Point curl at whatever receives your alerts, or use mail if the server can send email, and test it by starting the notifier yourself:
sudo systemctl start [email protected]OnFailure= can't report a run that never happened: a disabled timer, a server that is off, a unit lost in a rebuild. For that, ping a heartbeat monitor after each good run; it alerts when the pings stop (how to know when a backup fails):
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null https://monitor.example.com/ping/backup-wwwExecStartPost= runs only after the script exits 0, and the leading - stops a failed ping from failing the backup.
Overlapping runs
systemd runs at most one copy of a service. If last night's backup is still going when the timer elapses, it is left running and nothing new starts, so you only need cron's flock trick if the script can also start outside systemd (the cron guide shows it). A hung run is the timeout's job: systemd logs start operation timed out. Terminating., stops the run, marks it failed and fires OnFailure=.
Keys and passwords
Keep secrets out of the unit file: unit files are normally world-readable, and any user can read Environment= values with systemctl show (we did, as nobody). An environment file is better. systemd reads it before switching to User=, so it can stay root-only, and systemctl show displays only its path:
sudo install -m 600 -o root -g root /dev/null /etc/backup-www.envAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYEnvironmentFile=/etc/backup-www.envThe values still become environment variables that every child process inherits, so the systemd.exec manual recommends credentials for secrets. LoadCredential= copies a root-only file into a private, read-only directory only the service's user can read, at $CREDENTIALS_DIRECTORY (%d in the unit). It suits tools that read a secret from a file, such as restic with RESTIC_PASSWORD_FILE:
LoadCredential=restic-password:/etc/backup-www/restic-password
Environment=RESTIC_PASSWORD_FILE=%d/restic-passwordIt needs systemd 247 or later. Ubuntu 22.04 has 249, Ubuntu 24.04 has 255 and Debian 12 has 252; systemctl --version shows yours.
Sandbox the job without hiding data
A backup reads nearly everything and writes almost nothing, which suits systemd's sandbox. The finished service, with alert, heartbeat and sandbox:
[Unit]
Description=Back up /var/www
Wants=network-online.target
After=network-online.target
OnFailure=notify-failure@%n.service
[Service]
Type=oneshot
User=backup
Group=backup
AmbientCapabilities=CAP_DAC_READ_SEARCH
ExecStart=/usr/local/bin/backup-www.sh
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null https://monitor.example.com/ping/backup-www
Nice=19
IOSchedulingClass=idle
TimeoutStartSec=4h
UMask=0077
# Read everything, write only to the backup directory
ProtectSystem=strict
ReadWritePaths=/var/backups/www
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
ProtectSystem=strictmakes the filesystem read-only for the job (except /dev, /proc and /sys);ReadWritePaths=reopens the backup directory. A tool that writes elsewhere, such as a cache, needs its path listed too, or fails withRead-only file system.ProtectHome=read-onlykeeps /home, /root and /run/user readable but not writable.PrivateTmp=gives the job its own /tmp and /var/tmp;PrivateDevices=hides physical devices such as /dev/vda.NoNewPrivileges=blocks privilege gains through setuid programs like sudo; the otherProtect*lines make kernel settings and cgroups read-only and block module loading;CapabilityBoundingSet=drops every capability but the one the job uses.
A sandbox can hide data without an error. ProtectHome=yes makes /home, /root and /run/user empty, so a backup that includes them can finish without them; ProtectKernelModules=yes hides /usr/lib/modules, PrivateTmp=yes the real /tmp and /var/tmp, and PrivateDevices=yes breaks disk imaging. Leave those out of a whole-system backup, and after any change restore a file from the next backup.
systemd-analyze security --offline=yes /etc/systemd/system/backup-www.service scores a unit from 0 (locked down) to 10 (wide open). In our test the first version of this service scored 9.1 UNSAFE, the finished one 5.3 MEDIUM.
Cron or a systemd timer?
| cron | systemd timer | |
|---|---|---|
| Schedule | 17 2 * * * | OnCalendar=*-*-* 02:17:00, testable with systemd-analyze calendar |
| Server off at run time | Run skipped (anacron catches up daily, weekly and monthly jobs) | Persistent=true runs it once at boot |
| Output | Mailed, or lost unless redirected | In the journal, timestamped |
| Two runs at once | Possible; add flock | Never two copies of one service |
| Failure alert | Mail, if the server can send it | OnFailure= starts any unit |
| Hung run | timeout in the command | TimeoutStartSec= |
| Spreading load | A sleep in the script | RandomizedDelaySec= |
| PATH | /usr/bin:/bin on Debian and Ubuntu | /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin |
| Setup | One line; cron rereads crontabs itself | Two files and systemctl daemon-reload |
Cron is quicker for a single command. A timer earns its second file when you want catch-up runs, findable logs and failure alerts built in.
Common errors
| Message | Cause | Fix |
|---|---|---|
Failed to parse calendar specification '17 2 * * *': Invalid argument | Cron syntax in OnCalendar= or systemd-analyze calendar | Convert it with the table above. |
Timer unit lacks value setting. Refusing. | No valid schedule; verify shows why first, such as Unknown key name 'OnCalender' in section 'Timer', ignoring. | Fix that line, then daemon-reload. |
Command /usr/local/bin/backup-www.sh is not executable: No such file or directory | systemd-analyze verify cannot find the script | Fix the path in ExecStart=. |
status=203/EXEC | The script could not start: wrong path, no execute bit, or no #! line (Exec format error) | Fix the path, chmod 755, add #!/bin/sh. |
status=217/USER | The User= account does not exist | Create it or fix the name. |
status=226/NAMESPACE | A sandbox path, such as the ReadWritePaths= directory, does not exist | Create it, or prefix it with - to skip it when missing. |
start operation timed out. Terminating. and Failed with result 'timeout'. | The run took longer than TimeoutStartSec= | Find what hangs, or raise the limit. |
The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=, ... | You enabled the service | Enable the timer instead. |
Warning: The unit file, source configuration file or drop-ins of backup-www.timer changed on disk. Run 'systemctl daemon-reload' to reload units. | A unit was edited but not reloaded | sudo systemctl daemon-reload |
Frequently asked questions
- Do systemd timers run jobs missed while the server was off?
- Only with
Persistent=true, and only forOnCalendar=timers. The missed job runs once when the timer starts at boot, however many runs were missed. - How do I run a systemd timer every 15 minutes?
OnCalendar=*:0/15, which systemd normalizes to*-*-* *:00/15:00. Check any expression withsystemd-analyze calendar.- What timezone does OnCalendar use?
- The server's timezone, unless the expression ends with
UTCor a zone name such asEurope/Berlin. - How do I stop and remove a systemd timer?
sudo systemctl disable --now backup-www.timerstops it and removes it from boot. Before deleting the files,sudo systemctl clean --what=state backup-www.timerremoves the stored last-run time thatPersistent=keeps.
How this was checked
The commands were run on Ubuntu 24.04, systemd 255 on October 4, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.
Sources, on October 4, 2026:
- systemd.timer(5), systemd 255 (Ubuntu 24.04)
- systemd.service(5), systemd 255 (Ubuntu 24.04)
- systemd.exec(5), systemd 255 (Ubuntu 24.04)
- systemd.unit(5), systemd 255 (Ubuntu 24.04)
- systemd.time(7), systemd 255 (Ubuntu 24.04)
- systemd.special(7): network-online.target
- systemd-analyze(1), systemd 255 (Ubuntu 24.04)
- systemctl(1), systemd 255 (Ubuntu 24.04)
- journalctl(1), systemd 255 (Ubuntu 24.04)
- freedesktop.org systemd.timer (latest), cross-check
- freedesktop.org systemd.exec (latest), cross-check
- Linux kernel documentation: Block io priorities
- ioprio_set(2)
- capabilities(7): CAP_DAC_READ_SEARCH
- restic documentation: environment variables
- Ubuntu packages: systemd in Ubuntu 22.04
- Debian packages: systemd in Debian 12