VPS Snaps

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.

11 min readUpdated Tested on Ubuntu 24.04, systemd 255

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:

/usr/local/bin/backup-www.sh
#!/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:

/etc/systemd/system/backup-www.service
[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
SettingWhat it does
Type=oneshotsystemd 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_SEARCHLets the job read every file, root-only ones included, but not write them. Drop it if the user can already read everything.
Nice=19Lowest CPU priority.
IOSchedulingClass=idleDisk time only when nothing else wants it.
TimeoutStartSec=4hStops a hung run. Oneshot services have no timeout otherwise.
UMask=0077Archives readable by the backup user only, not by everyone (the default, 0022).
Wants=, After=network-online.targetWaits for the network, which matters when a missed run starts at boot.

Create the directory the job writes to:

Terminal
sudo install -d -o backup -g backup -m 750 /var/backups/www

In 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

/etc/systemd/system/backup-www.timer
[Unit]
Description=Nightly backup of /var/www

[Timer]
OnCalendar=*-*-* 02:17:00
Persistent=true
RandomizedDelaySec=15min
AccuracySec=1s

[Install]
WantedBy=timers.target
SettingWhat it does
OnCalendar=*-*-* 02:17:00Every day at 02:17, server time. Repeat the line for more times.
Persistent=trueStores the last run time in /var/lib/systemd/timers/. A run missed while the server was off happens once at boot.
RandomizedDelaySec=15minAdds 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=1sFire on time. By default systemd may delay a timer up to a minute to group wake-ups.
WantedBy=timers.targetStarts 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:

Terminal
systemd-analyze calendar --iterations=3 'Mon..Fri 01:00'
Output
  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 left

The 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:

cronOnCalendar=Runs
17 2 * * **-*-* 02:17:00Every day at 02:17
0 */6 * * **-*-* 00/6:00:0000:00, 06:00, 12:00, 18:00
*/15 * * * **:0/15Every 15 minutes
30 3 * * 0Sun 03:30Sundays at 03:30
0 4 1 * **-*-01 04:00The 1st of each month at 04:00
0 1 * * 1-5Mon..Fri 01:00Weekdays at 01:00
(not possible)*-*~01 03:00The 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:

Terminal
sudo chmod 755 /usr/local/bin/backup-www.sh
Terminal
sudo systemd-analyze verify /etc/systemd/system/backup-www.service /etc/systemd/system/backup-www.timer

It 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.

Terminal
sudo systemctl daemon-reload
Terminal
sudo systemctl enable --now backup-www.timer

enable starts the timer at every boot, --now right away. Check the next run:

Terminal
systemctl list-timers backup-www.timer

NEXT and LEFT say when it runs next, LAST and PASSED when it last ran. Ubuntu's own backup timer on our test server:

Output
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:

Terminal
sudo systemctl start backup-www.service

Then 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:

Terminal
journalctl -u backup-www.service --since today

A 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:

Terminal
systemctl show backup-www.service -p Result -p ExecMainStatus -p ExecMainExitTimestamp

After 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:

/etc/systemd/system/[email protected]
[Unit]
Description=Send an alert that %i failed

[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i
/usr/local/bin/notify-failure
#!/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/backups

Then add one line to the [Unit] section of backup-www.service:

backup-www.service, [Unit] section
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:

Terminal
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):

backup-www.service, [Service] section
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null https://monitor.example.com/ping/backup-www

ExecStartPost= 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:

Terminal
sudo install -m 600 -o root -g root /dev/null /etc/backup-www.env
/etc/backup-www.env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
backup-www.service, [Service] section
EnvironmentFile=/etc/backup-www.env

The 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:

backup-www.service, [Service] section
LoadCredential=restic-password:/etc/backup-www/restic-password
Environment=RESTIC_PASSWORD_FILE=%d/restic-password

It 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:

/etc/systemd/system/backup-www.service
[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=strict makes 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 with Read-only file system.
  • ProtectHome=read-only keeps /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 other Protect* 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?

cronsystemd timer
Schedule17 2 * * *OnCalendar=*-*-* 02:17:00, testable with systemd-analyze calendar
Server off at run timeRun skipped (anacron catches up daily, weekly and monthly jobs)Persistent=true runs it once at boot
OutputMailed, or lost unless redirectedIn the journal, timestamped
Two runs at oncePossible; add flockNever two copies of one service
Failure alertMail, if the server can send itOnFailure= starts any unit
Hung runtimeout in the commandTimeoutStartSec=
Spreading loadA sleep in the scriptRandomizedDelaySec=
PATH/usr/bin:/bin on Debian and Ubuntu/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
SetupOne line; cron rereads crontabs itselfTwo 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

MessageCauseFix
Failed to parse calendar specification '17 2 * * *': Invalid argumentCron syntax in OnCalendar= or systemd-analyze calendarConvert 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 directorysystemd-analyze verify cannot find the scriptFix the path in ExecStart=.
status=203/EXECThe 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/USERThe User= account does not existCreate it or fix the name.
status=226/NAMESPACEA sandbox path, such as the ReadWritePaths= directory, does not existCreate 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 serviceEnable 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 reloadedsudo systemctl daemon-reload

Frequently asked questions

Do systemd timers run jobs missed while the server was off?
Only with Persistent=true, and only for OnCalendar= 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 with systemd-analyze calendar.
What timezone does OnCalendar use?
The server's timezone, unless the expression ends with UTC or a zone name such as Europe/Berlin.
How do I stop and remove a systemd timer?
sudo systemctl disable --now backup-www.timer stops it and removes it from boot. Before deleting the files, sudo systemctl clean --what=state backup-www.timer removes the stored last-run time that Persistent= 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: