Articles / Systems Programming

  • restic
  • backups
  • sftp
  • encryption
  • linux
  • systemd

Nightly Encrypted Backups to an SFTP Box with restic

If you have a box somewhere with SSH access, you already have an offsite backup target. It might be a VPS you rent for a few pounds a month or a Raspberry Pi at a relative's house. Either way you need no object storage account, no API keys, and no third-party bucket policy to get wrong.

Just SFTP and restic, which encrypts everything client-side before it leaves your machine.

This is a practical walkthrough in four steps:

  1. set up the remote target
  2. initialise an encrypted restic repository over SFTP
  3. run a first backup
  4. wire it up as a systemd timer, so it happens on its own and you find out about failures instead of discovering them the day you need a restore

SFTP is the boring backend, and boring is good here

restic supports several backends:

  • local paths
  • SFTP
  • S3-compatible object storage
  • Backblaze B2, Azure and Google Cloud Storage
  • REST servers

SFTP is the least glamorous of these, and that is exactly its appeal for a personal setup. It needs nothing more than an SSH-accessible account on the far end: no bucket permissions model to reason about, no lifecycle rules, no vendor SDK.

The repository is just a directory tree that restic manages entirely through the SFTP protocol, and the remote side never sees plaintext or the encryption key.

The tradeoff is throughput and concurrency. SFTP is single-connection-per-operation and generally slower than a well-tuned S3 backend for very large repositories. For a home server, a handful of laptops, or a small fleet of VPS instances, that ceiling is rarely the limiting factor.

What you need before you start

  • A remote host reachable over SSH, with enough disk space for your backup set plus growth headroom.
  • A dedicated, unprivileged user on that remote host for backups (do not use your normal login account or root).
  • restic installed on the machine being backed up. It ships as a single static binary; install via your distribution's package manager or download the release binary directly from the restic GitHub releases page.
  • SSH key authentication already working from the source machine to the remote host, with the private key unprotected by a passphrase (or unlocked via an agent) so that unattended backups can run without a prompt.

Step 1: a locked-down user and directory on the remote host

On the remote host, create a user with no shell and a home directory dedicated to the repository:

sudo useradd --system --create-home --shell /usr/sbin/nologin backup
sudo mkdir -p /home/backup/repo
sudo chown backup:backup /home/backup/repo

Add the source machine's public key to that user's authorized_keys:

sudo -u backup mkdir -p /home/backup/.ssh
sudo -u backup tee -a /home/backup/.ssh/authorized_keys < /path/to/id_ed25519.pub
sudo chmod 700 /home/backup/.ssh
sudo chmod 600 /home/backup/.ssh/authorized_keys

Optional: allow SFTP and nothing else

If you want to restrict this key to nothing but SFTP (no interactive shell, no port forwarding), prefix the key's line in authorized_keys with a restriction. Then give the user internal-sftp as its forced command in sshd_config:

Match User backup
    ForceCommand internal-sftp
    ChrootDirectory /home/backup
    AllowTcpForwarding no
    X11Forwarding no

Restart sshd after this change. One gotcha: ChrootDirectory requires the chroot root (/home/backup) to be owned by root and not writable by the backup user.

So if you use this, adjust ownership with chown root:root /home/backup and put the actual writable repo in a subdirectory the backup user owns.

Step 2: initialise the repository, and guard that password

On the source machine, decide how you will store the repository password. The cleanest option for unattended backups is a password file, not an environment variable baked into a script, because a file can be locked down with normal filesystem permissions:

sudo mkdir -p /etc/restic
sudo sh -c 'tr -dc A-Za-z0-9 </dev/urandom | head -c 48 > /etc/restic/password'
sudo chmod 600 /etc/restic/password

Keep a copy of this password somewhere outside the machine you are backing up. restic encrypts the repository with a key derived from this password, and it is not recoverable from the repository itself.

Lose the password and the backups are cryptographically unrecoverable. That is the point of an encrypted backup, but it is also the way people lose entire archives.

Now initialise the repository over SFTP:

export RESTIC_REPOSITORY="sftp:backup@remote-host:/home/backup/repo"
export RESTIC_PASSWORD_FILE="/etc/restic/password"
restic init

Quick detour, because this one is handy: restic shells out to your local ssh binary for the SFTP connection. So it picks up your existing ~/.ssh/config, including any IdentityFile, Port, or ProxyJump directives you have set for that host.

The flip side: if the remote host is not yet a known host, add it to known_hosts before running this unattended. Otherwise restic (via ssh) will hang on the interactive fingerprint prompt.

Step 3: run a backup and check it landed

restic backup /home/andy /etc --exclude-caches \
  --exclude '/home/andy/.cache' \
  --exclude '*.tmp'

--exclude-caches skips any directory containing a CACHEDIR.TAG file. That covers most browser and build tool caches without you having to enumerate them by hand. List the repository's snapshots to confirm it worked:

restic snapshots

restic deduplicates at the chunk level across all snapshots in a repository. Repeated backups of a mostly-unchanged filesystem are fast and the incremental storage cost is small.

Yet every snapshot is logically a full backup you can restore from directly, with no chain of incrementals to replay. Actually, this bit is the clever part: you get incremental costs with full-backup restores.

Step 4: let systemd run it nightly

A cron entry works, but a systemd service plus timer gives you structured logging in the journal and a straightforward way to see the last run's exit status. Create the service unit:

[Unit]
Description=restic backup to remote SFTP repository
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
Environment=RESTIC_REPOSITORY=sftp:backup@remote-host:/home/backup/repo
Environment=RESTIC_PASSWORD_FILE=/etc/restic/password
ExecStart=/usr/bin/restic backup /home/andy /etc \
  --exclude-caches --exclude '/home/andy/.cache'
ExecStartPost=/usr/bin/restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune

Save this as /etc/systemd/system/restic-backup.service.

The forget line is your retention policy

The forget step is what actually enforces a retention policy. backup on its own keeps every snapshot forever, and a repository with no pruning grows without bound.

--keep-daily 7 --keep-weekly 4 --keep-monthly 12 is a reasonable starting policy. Adjust it to how much history you actually want to be able to restore from.

The timer

[Unit]
Description=Daily restic backup

[Timer]
OnCalendar=daily
RandomizedDelaySec=30min
Persistent=true

[Install]
WantedBy=timers.target

Save as /etc/systemd/system/restic-backup.timer, then enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
sudo systemctl list-timers restic-backup.timer

Two timer settings are doing real work here:

  • Persistent=true means a missed run (machine was off overnight) fires as soon as the machine is back up, rather than silently waiting for the next scheduled slot.
  • RandomizedDelaySec is worth having if you manage more than one machine backing up to the same remote host, so they do not all hit the SFTP server at exactly the same second.

Prove the backup actually restores

A backup you have never restored from is a hypothesis, not a backup. Periodically check repository integrity:

restic check

And actually restore something, into a scratch directory, to confirm the whole chain works end to end:

restic restore latest --target /tmp/restore-test --include /etc/hostname
diff /tmp/restore-test/etc/hostname /etc/hostname

Do this after the initial setup, and again after any change to the exclude list, retention policy, or SSH configuration.

It is the cheapest insurance available against discovering, at the worst possible moment, that the password file was wrong or the remote host's disk quietly filled up months ago.

Three things that catch people out

Password file permissions

These matter more than they look. If /etc/restic/password is world-readable, anyone with local access to the source machine can decrypt every snapshot in the repository. That defeats the point of encrypting it in the first place.

The remote disk filling up

SFTP gives you no built-in lifecycle management or storage quota enforcement. If the remote filesystem fills up, restic backup fails loudly (check the journal).

But nothing stops the underlying disk getting there in the first place, except your own forget --prune schedule and occasional manual checks of df on the remote host.

forget without prune

forget without --prune only removes the snapshot metadata. The underlying data chunks stay on disk until a prune actually runs, because other retained snapshots might still reference them.

Running --prune as part of every scheduled job (as in the unit above) keeps the repository from accumulating orphaned chunks, at the cost of a slower rewrite pass each time it removes something.

For a large repository pruned daily, that cost is usually small. If it becomes noticeable, prune on a separate, less frequent timer instead of every run.