VPS Snaps

How to make incremental backups with tar

GNU tar makes incremental backups with --listed-incremental (-g) and a snapshot file. The first run with a new snapshot file archives everything; each later run archives only new and changed files, plus a record of every directory so deletions and renames can be replayed. To restore, extract the full archive and then each incremental in order into an empty directory, with --listed-incremental=/dev/null. We ran every command here through a simulated week of edits, deletions and renames, and the restored copy matched the original exactly.

12 min readUpdated Tested on Ubuntu 24.04 LTS, GNU tar 1.35

How --listed-incremental works

For tar basics (flags, excludes, checking an archive, ownership on restore) see how to back up files with tar. Here is what incremental mode adds:

  • The snapshot file (.snar by convention) records when the run started and, for every directory, its device number, inode number and the names it contained.
  • On the next run, tar archives a file if its name is new in its directory, or if its modification time or change time (ctime) is later than the previous run. In our test a chmod 600 on an unchanged file, and a file replaced by an older copy with cp -p, were both picked up.
  • Every directory goes into every incremental, as a record listing the names it held at that moment, even when nothing in it changed. That record is how a restore learns what was deleted.
  • A renamed directory is recognized by its device and inode number and stored as a rename record; its files are not archived again. A renamed file is archived again under its new name.
  • Incremental archives use GNU extensions. The GNU manual warns that other tar programs may not read them correctly.

Make the full (level 0) backup

Terminal
sudo mkdir -p /var/backups/www
Terminal
sudo tar --create --gzip --verbose --file=/var/backups/www/www-2026-09-27-full.tar.gz --listed-incremental=/var/backups/www/www.snar --level=0 -C /srv www
OptionWhat it does
--create --gzipWrite a new gzip-compressed archive (-cz).
--verbosePrint each name. Leave it out in cron.
--file=…The archive to write (-f).
--listed-incremental=FILERead and then update the snapshot file (-g FILE). It is created if missing, and a missing file means a full backup.
--level=0Empty the snapshot file first, so this run is always a full backup. GNU tar 1.35 honors only level 0; other values are ignored.
-C /srv wwwChange to /srv and archive www, so names in the archive start with www/.
Output
tar: www: Directory is new
tar: www/css: Directory is new
tar: www/uploads: Directory is new
www/
www/css/
www/uploads/
www/config.php
www/index.html
www/css/site.css
www/uploads/photo1.jpg
www/uploads/photo2.jpg

Directory is new means the snapshot file had no record of that directory yet, which is what a full run should say.

Run exactly the same command every time: the same -C directory and the same names. When we pointed the same snapshot file at /srv/www instead of -C /srv www, tar printed /srv/www: Directory has been renamed from ‘www’ and stored the files again under names that no longer fit the chain.

Make the daily incrementals

Terminal
sudo tar --create --gzip --file=/var/backups/www/www-$(date +%F)-inc.tar.gz --listed-incremental=/var/backups/www/www.snar -C /srv www

Each run writes what changed since the snapshot, then updates it. We ran this nightly for a week while changing the site. Directories, present in every archive, are left out below; the last column is the differential scheme from the next section.

NightChanged that dayIncremental heldDifferential held
MonEdited index.html, added uploads/photo3.jpgindex.html, photo3.jpgindex.html, photo3.jpg
TueDeleted uploads/photo1.jpgDirectory records onlyindex.html, photo3.jpg
WedRenamed css/ to assets/, photo2.jpg to team.jpgteam.jpg, plus a rename recordindex.html, photo3.jpg, team.jpg
ThuAdded uploads/2026/10/report.pdf and legacy.txt, chmod 600 config.phpconfig.php, legacy.txt, report.pdfThose 3 plus the 3 above
FriNothingDirectory records onlySame 6 files
SatOverwrote team.jpgteam.jpgSame 6 files

Incremental or differential

With incrementals, each daily archive holds only one day's changes, so archives stay small, but a restore needs the full archive and every incremental after it, in order. With differentials, each daily archive holds everything changed since the full one, so a restore needs just two archives, but the dailies grow through the week. The GNU manual's suggested scheme is a weekly full and a daily level 1, which is a differential.

tar has no differential switch. You get one by starting every daily run from an untouched copy of the level-0 snapshot, as the manual describes. Right after the full run, save a copy:

Terminal
sudo cp /var/backups/www/www.snar /var/backups/www/www-level0.snar

Then each day, copy it to a working file and point tar at the copy:

Terminal
sudo mkdir -p /var/backups/www-diff
Terminal
sudo cp /var/backups/www/www-level0.snar /var/backups/www-diff/www-diff.snar
Terminal
sudo tar --create --gzip --file=/var/backups/www-diff/www-$(date +%F)-diff.tar.gz --listed-incremental=/var/backups/www-diff/www-diff.snar -C /srv www
SchemeArchives a restore needsIf one daily archive is lost
IncrementalFull + every incremental since, in orderEvery restore after it is missing that day's changes
DifferentialFull + the newest differentialOnly that day's restore point is gone

See what an incremental archive recorded

--list with --incremental and two --verbose flags prints each directory record. This is Wednesday's archive, the night of the renames:

Terminal
tar --list --incremental --verbose --verbose --file=/var/backups/www/www-2026-09-30-inc.tar.gz
Output
drwxr-xr-x root/root        63 2026-09-30 11:20 www/
D assets
N config.php
N index.html
D uploads
R www/css
T www/assets

drwxr-xr-x root/root        11 2026-09-20 10:00 www/assets/
N site.css

drwxr-xr-x root/root        23 2026-09-30 11:21 www/uploads/
N photo3.jpg
Y team.jpg

-rw-r--r-- root/root      3000 2026-09-20 10:00 www/uploads/team.jpg
CodeMeaning
YThe file is in this archive.
NThe file existed but was not archived, because it had not changed (or was excluded).
DA subdirectory.
R then TRename the first path to the second on restore.

photo1.jpg and photo2.jpg are not listed under www/uploads/, so a restore of this archive removes them. site.css is N under www/assets/: it arrives through the rename, not as a new copy.

Restore the chain

Extract into an empty directory, full archive first, then each incremental in date order. The archive names sort by date, so a glob gets the order right:

Terminal
sudo mkdir -p /restore/www-test
Terminal
for f in /var/backups/www/www-*.tar.gz; do
  echo "$f"
  sudo tar --extract --gzip --listed-incremental=/dev/null --file="$f" -C /restore/www-test || break
done
Terminal
sudo diff -r /restore/www-test/www /srv/www && echo identical
Output
identical

tar does not need the snapshot file to extract; everything is in the archives. --listed-incremental=/dev/null (or -G) only switches on incremental handling. To restore to a given day, stop after that day's archive. For differentials, extract the full archive and then the newest differential.

Extracting an incremental deletes whatever is in a restored directory that was not there when the archive was made, including whole subdirectories, without asking. It prints nothing unless you add --verbose, as below. Never extract a chain over a live directory.

Output
www/
www/assets/
www/uploads/
tar: Deleting ‘www/uploads/notes.txt’
tar: Deleting ‘www/uploads/newdir’
www/uploads/2026/
www/uploads/2026/10/
www/uploads/team.jpg

Forget `--listed-incremental` and nothing is replayed. tar extracts the directory records as plain directories, every exit status is 0, and the result is wrong:

Output
Only in /srv/www/assets: site.css
Only in /restore/www-test/www: css
Only in /restore/www-test/www/uploads: photo1.jpg
Only in /restore/www-test/www/uploads: photo2.jpg

Skip one archive and nothing warns you either. With Monday's incremental left out, every command still exited 0, but the restore had the old index.html and no photo3.jpg:

Output
diff -r /restore/www-test/www/index.html /srv/www/index.html
1c1
< <h1>Home</h1>
---
> <h1>Home v2</h1>
Only in /srv/www/uploads: photo3.jpg

To get back one file, find the newest archive that holds it and extract only that member. Extracting a named file never deletes anything, with or without -G.

Terminal
for f in /var/backups/www/www-*.tar.gz; do tar -tzf "$f" | grep -qx 'www/index.html' && echo "$f"; done
Output
/var/backups/www/www-2026-09-27-full.tar.gz
/var/backups/www/www-2026-09-28-inc.tar.gz
Terminal
sudo mkdir -p /restore/one
Terminal
sudo tar -xzf /var/backups/www/www-2026-09-28-inc.tar.gz -C /restore/one www/index.html

Compression and excludes

Incremental mode also worked with --zstd (archives named .tar.zst) in our test, and tar detects the compression when extracting. Keep one compressor per chain so the restore glob matches every archive.

Put excludes before the names, relative to the -C directory, or one per line in a file passed with --exclude-from. Excluded paths still appear in the directory records, as N:

Output
drwxr-xr-x root/root        52 2026-10-04 15:09 www/
N cache
Y config.php
D css
Y index.html
D logs
D uploads

drwxr-xr-x root/root        13 2026-10-04 15:09 www/logs/
N access.log

That run used --exclude='www/cache' --exclude='*.log'. Because excluded entries count as present, a restore neither recreates them nor deletes them from the target.

A weekly full and daily incremental script

This script keeps each weekly chain in its own directory, named after the day of its full backup, so a chain is deleted whole or not at all. It starts a new chain every Sunday, and puts the snapshot file back if tar fails.

/usr/local/bin/backup-www.sh
#!/bin/bash
# Weekly full + daily incremental backups of /srv/www with GNU tar.
# Each weekly chain lives in its own directory: /var/backups/www/<date of its full>/
set -uo pipefail

SRC_PARENT=/srv          # tar changes to this directory ...
SRC_NAME=www             # ... and archives this one inside it
BASE=/var/backups/www
KEEP_CHAINS=4            # weekly chains to keep

TODAY=$(date +%F)
NOW=$(date +%F-%H%M)
mkdir -p "$BASE"
CHAIN=$(ls -1d "$BASE"/20??-??-?? 2>/dev/null | tail -n 1)

# Start a new chain on Sunday (date +%u prints 7), or when there is none yet
if [ -z "$CHAIN" ] || { [ "$(date +%u)" = 7 ] && [ "$CHAIN" != "$BASE/$TODAY" ]; }; then
  CHAIN="$BASE/$TODAY"
  mkdir -p "$CHAIN"
fi

SNAR="$CHAIN/$SRC_NAME.snar"
if [ -f "$SNAR" ]; then KIND=inc; else KIND=full; fi
ARCHIVE="$CHAIN/$SRC_NAME-$NOW-$KIND.tar.gz"

# Keep the snapshot file as it was, so a failed run can be undone
if [ -f "$SNAR" ]; then cp -p "$SNAR" "$SNAR.prev"; fi

tar --create --gzip --file="$ARCHIVE" \
    --listed-incremental="$SNAR" \
    --exclude="$SRC_NAME/cache" --exclude='*.log' \
    -C "$SRC_PARENT" "$SRC_NAME"
status=$?

if [ "$status" -gt 1 ]; then
  echo "$(date '+%F %T') tar failed with status $status, removing $ARCHIVE" >&2
  rm -f "$ARCHIVE"
  if [ -f "$SNAR.prev" ]; then mv "$SNAR.prev" "$SNAR"; else rm -f "$SNAR"; fi
  exit "$status"
fi
rm -f "$SNAR.prev"
echo "$(date '+%F %T') $KIND backup written: $ARCHIVE (tar exit $status)"

# Delete whole chains, oldest first, keeping the newest KEEP_CHAINS
ls -1d "$BASE"/20??-??-?? | head -n -"$KEEP_CHAINS" | xargs -r rm -rf --
  • No snapshot file in the chain directory means a full run, so the first run on a new server, and the run after a failed full, are full backups.
  • tar exit status 1 (file changed as we read it) keeps the archive; 2 or higher deletes it and restores the snapshot file, so the next run covers the same changes.
  • KEEP_CHAINS=4 keeps about four weeks. The chains are directories, so the pruning never leaves incrementals without their full.
Terminal
sudo install -m 700 backup-www.sh /usr/local/bin/backup-www.sh
/etc/cron.d/backup-www
30 23 * * * root /usr/local/bin/backup-www.sh >> /var/log/backup-www.log 2>&1

We ran the script over 20 simulated days, with /srv/www moved away for one night. That night failed cleanly, the next night's incremental picked up both days' changes, and the chain restored identical to the live tree. The log from those two nights:

/var/log/backup-www.log
tar: www: Cannot stat: No such file or directory
tar: www: Cannot stat: No such file or directory
tar: Exiting with failure status due to previous errors
2026-09-30 23:30:00 tar failed with status 2, removing /var/backups/www/2026-09-27/www-2026-09-30-2330-inc.tar.gz
2026-10-01 23:30:00 inc backup written: /var/backups/www/2026-09-27/www-2026-10-01-2330-inc.tar.gz (tar exit 0)

For cron's PATH, % and logging traps, see how to schedule backups with cron. Archives on the same disk die with it: copy each chain directory off the server, for example with rclone, to follow the 3-2-1 rule.

Pitfalls

  • Keep the snapshot file in step with the archives. If it is lost, the next run is silently a full backup: safe but large. If it moves past an archive you then lose (a failed upload, a deleted file), those changes are gone from every later restore. On a non-fatal error, such as a path it cannot read, tar exits with status 2 but still updates the snapshot file, which is why the script saves a copy first.
  • Device numbers change. NFS with an automounter, LVM snapshot volumes, RAID reassembly or a new kernel can change the device numbers the snapshot stores. When we changed them in a copy of the snapshot file, tar reported www: Directory has been renamed for every directory and archived every file again. With --no-check-device (inode numbers only), the same run archived just the one changed file.
  • Renamed directories are fine; a changed command line is not. Directory renames are replayed through R/T records, as in Wednesday's archive. A renamed file is stored again in full. Changing -C or the archived names looks like a rename of the top directory.
  • Time stamps. The manual warns that results are unreliable if file time stamps change during the dump (for example with --atime-preserve=replace) or if the clock is set backwards.
  • BSD tar and macOS. The tar on macOS and FreeBSD is bsdtar from libarchive, and its manual has no --listed-incremental. Install GNU tar (on macOS, Homebrew's gnu-tar, run as gtar) to create or restore these archives.

Copy-on-write snapshots avoid half-written files during the run: archive from an LVM snapshot mount with --no-check-device. Whatever you use, prove the chain restores: see how to test a backup restore.

Frequently asked questions

What is a .snar file?
The snapshot file GNU tar reads and updates with --listed-incremental. It holds the time of the last run and, for each directory, its device number, inode number and file names. tar uses it to decide what changed; it is not needed to extract.
Do I need the snapshot file to restore a tar incremental backup?
No. Extract the full archive and then each incremental in order with --listed-incremental=/dev/null (or -G). The snapshot file only matters for making the next backup.
Why is my tar incremental backup as big as a full backup?
Usually the snapshot file was missing or emptied (--level=0), the command line changed (a different -C or path), or the device numbers changed, which shows up as Directory has been renamed for every directory with --verbose. Add --no-check-device for the last case.
What is the difference between incremental and differential backups with tar?
An incremental run updates the snapshot file, so each archive holds one day's changes and a restore needs the whole chain. A differential run starts from a copy of the level-0 snapshot every day, so each archive holds everything since the full and a restore needs only two archives.
Can I restore a single file from a tar incremental backup?
Yes. List the archives with tar -tzf to find the newest one containing the file, then extract just that name. Extracting a named file never deletes anything.

How this was checked

The commands were run on Ubuntu 24.04 LTS, GNU tar 1.35 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: