Nandishwar Singh

Home / Learn Linux / Project 3

Backups between two machines over SSH

Two machines, key-only SSH and an rsync backup you can run with one command.

Project
3 of 5
Time
About 60 minutes
Uses
Lessons 18 to 21

On this page

  1. What you'll build
  2. Step 0: two machines
  3. Step 1: the backup account (on vault)
  4. Step 2: a key just for backups (on your machine)
  5. Step 3: install the key, locked down (on vault)
  6. Step 4: a shortcut, and the first login
  7. Step 5: something worth backing up
  8. Step 6: the first backup
  9. Step 7: one command
  10. Step 8: a snapshot for every day
  11. Step 9: prove it with a restore
  12. Step 10: what's on vault?
  13. What you built
  14. Take it further

What you'll build

A backup that runs with one command, from your machine to a separate backup server, over SSH:

You'll use users (lesson 13), permissions (14), SSH and keys (19), rsync and checksums (20), and hard links (11).

Step 0: two machines

The backup server is called vault throughout. Choose one way to get it:

The output on this page comes from the one-VM way, so prompts show linux where yours might show vault.

$ echo "127.0.0.1 vault" | sudo tee -a /etc/hosts
127.0.0.1 vault
$ getent hosts vault
127.0.0.1       vault

Step 1: the backup account (on vault)

On vault, create a user called backup with no password (it will only ever log in with a key), and a storage directory /srv/backups that only backup can read or write.

Hint

adduser --disabled-password, then mkdir, chown backup:backup and chmod 700.

Solution
$ sudo adduser --disabled-password --comment "Backup receiver" backup
$ sudo mkdir /srv/backups
$ sudo chown backup:backup /srv/backups
$ sudo chmod 700 /srv/backups
$ sudo ls -ld /srv/backups
drwx------ 2 backup backup 4096 Oct 21 15:51 /srv/backups

Step 2: a key just for backups (on your machine)

Backups should run without anyone typing a passphrase, so they need a key without one. That makes the key valuable to steal, so don't reuse your own: make a dedicated key called ~/.ssh/backup_key, and limit what it can do in the next step.

Solution
$ ssh-keygen -t ed25519 -f ~/.ssh/backup_key -N "" -C "backup from linux" -q
$ ls -l ~/.ssh/backup_key*
-rw------- 1 ubuntu ubuntu 411 Oct 21 15:51 /home/ubuntu/.ssh/backup_key
-rw-r--r-- 1 ubuntu ubuntu  99 Oct 21 15:51 /home/ubuntu/.ssh/backup_key.pub
$ cat ~/.ssh/backup_key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICbFsnvxLzNXQJBJfEvBiCVUWZYC4R7eNVGVsIUVr8sj backup from linux

-q keeps ssh-keygen quiet.

Step 3: install the key, locked down (on vault)

Put the public key into backup's ~/.ssh/authorized_keys on vault, with the right owner and permissions. Then lock it down: putting restrict in front of a key in authorized_keys turns off everything SSH can do besides running commands, like port forwarding and terminals. rsync still works, because it only runs a command.

Hint

On a second VM, copy the .pub line across by pasting it (it's one line). On one VM, cat it into sudo tee. Lesson 19 has the permissions: 700 for .ssh, 600 for the file, owned by backup.

Solution
$ sudo mkdir -p /home/backup/.ssh
$ echo "restrict $(cat ~/.ssh/backup_key.pub)" | sudo tee /home/backup/.ssh/authorized_keys
restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICbFsnvxLzNXQJBJfEvBiCVUWZYC4R7eNVGVsIUVr8sj backup from linux
$ sudo chown -R backup:backup /home/backup/.ssh
$ sudo chmod 700 /home/backup/.ssh
$ sudo chmod 600 /home/backup/.ssh/authorized_keys

Step 4: a shortcut, and the first login

Add a vault entry to your ~/.ssh/config that logs in as backup with backup_key. Then test it by running whoami on vault. It's the first connection to the name vault, so SSH asks you to confirm the host key (lesson 19).

Solution
$ printf "Host vault\n    HostName vault\n    User backup\n    IdentityFile ~/.ssh/backup_key\n    IdentitiesOnly yes\n\n" >> ~/.ssh/config
$ ssh vault whoami
The authenticity of host 'vault (127.0.0.1)' can't be established.
ED25519 key fingerprint is: SHA256:ZVOScnETK5a0XrLkGdWrXwgnybL4hn7wEVwfoimHCa8
This host key is known by the following other names/addresses:
    ~/.ssh/known_hosts:1: [hashed name]
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'vault' (ED25519) to the list of known hosts.
backup@vault: Permission denied (publickey).

If you hardened SSH in lesson 19 on this same machine, the login itself fails, and the server's log says why:

$ sudo journalctl -u ssh --since "1 min ago" --no-pager | grep -i "not allowed" | tail -n 1
Oct 21 15:51:33 linux sshd-session[288878]: User backup from 127.0.0.1 not allowed because not listed in AllowUsers

AllowUsers from lesson 19 only lets ubuntu and ravi in. (On a fresh second VM you won't have this problem.) Add backup to the list, check the config, reload, and try again:

$ sudo sed -i 's/^AllowUsers .*/AllowUsers ubuntu ravi backup/' /etc/ssh/sshd_config.d/10-hardening.conf
$ sudo sshd -t && sudo systemctl reload ssh
$ ssh vault whoami
backup

Check: the key really is restricted

restrict means no interactive shell for this key. A normal login now gets no terminal:

$ ssh vault 'tty'
not a tty

not a tty confirms it: commands run, but nobody can sit at a terminal with this key.

Step 5: something worth backing up

Make ~/important with a few files of different kinds, including a big one, so you can see rsync skip it the second time.

Solution
$ mkdir -p ~/important/{docs,photos,code}
$ echo "Quarterly report draft" > ~/important/docs/report.txt
$ echo "Rent: 15000" > ~/important/docs/budget.csv
$ head -c 5000000 /dev/urandom > ~/important/photos/trip.jpg
$ printf 'print("hello")\n' > ~/important/code/app.py
$ du -sh ~/important
4.8M    /home/ubuntu/important
$ find ~/important -type f | wc -l
4

Step 6: the first backup

Send ~/important to /srv/backups/linux/latest/ on vault with rsync, keeping permissions and times, compressed, and as an exact mirror. Then check from vault's side what arrived.

Hint

rsync -az --delete source/ vault:/srv/backups/linux/latest/. Remote directories that don't exist yet need --mkpath. And check the trailing slash on the source (lesson 20).

Solution
$ rsync -az --delete --mkpath --stats ~/important/ vault:/srv/backups/linux/latest/ | grep -E "Number of regular files transferred|Total transferred"
Number of regular files transferred: 4
Total transferred file size: 5,000,050 bytes
$ ssh vault 'find /srv/backups/linux/latest -type f'
/srv/backups/linux/latest/photos/trip.jpg
/srv/backups/linux/latest/code/app.py
/srv/backups/linux/latest/docs/budget.csv
/srv/backups/linux/latest/docs/report.txt

Run exactly the same command again. Nothing changed, so nothing is sent:

$ rsync -az --delete --mkpath --stats ~/important/ vault:/srv/backups/linux/latest/ | grep -E "Number of regular files transferred|Total transferred"
Number of regular files transferred: 0
Total transferred file size: 0 bytes

Step 7: one command

Put the backup into a small script, ~/bin/backup.sh, that also writes a line to ~/backup.log each time it runs. Lesson 27 explains scripts properly; for now, here's all you need:

Solution
$ mkdir -p ~/bin
$ cat > ~/bin/backup.sh << 'EOF'
> #!/bin/bash
> set -e
> rsync -az --delete --mkpath ~/important/ vault:/srv/backups/linux/latest/
> echo "$(date '+%F %T') backup ok" >> ~/backup.log
> EOF
$ chmod +x ~/bin/backup.sh
$ ~/bin/backup.sh
$ cat ~/backup.log
2026-10-21 15:51:45 backup ok

The quotes in << 'EOF' stop the shell from running $(date ...) while writing the file, so the script itself gets the $(...) and works out the date each time it runs.

Step 8: a snapshot for every day

A mirror only has today. If a file was damaged a week ago and you only notice now, the mirror has faithfully copied the damage. You want a copy for every day, without storing everything again each day.

rsync --link-dest does exactly that. Point it at yesterday's snapshot: files that haven't changed become hard links (lesson 11) to yesterday's copies, so they take no extra space, and only changed files are stored again. Every day's folder still looks like a complete backup.

Make two dated snapshots, changing one small file in between:

$ ssh vault 'rm -rf /srv/backups/linux/2026-10-20 /srv/backups/linux/2026-10-21'
$ rsync -a ~/important/ vault:/srv/backups/linux/2026-10-20/
$ echo "Rent: 16000" > ~/important/docs/budget.csv
$ rsync -a --link-dest=/srv/backups/linux/2026-10-20 ~/important/ vault:/srv/backups/linux/2026-10-21/

Now look at the big photo in both snapshots. Same inode number, link count 2: it's stored once. The budget file changed, so it has two different inodes:

$ ssh vault 'ls -li /srv/backups/linux/2026-1*/photos/trip.jpg /srv/backups/linux/2026-1*/docs/budget.csv'
535404 -rw-rw-r-- 1 backup backup      12 Oct 21 15:51 /srv/backups/linux/2026-10-20/docs/budget.csv
535406 -rw-rw-r-- 2 backup backup 5000000 Oct 21 15:51 /srv/backups/linux/2026-10-20/photos/trip.jpg
535411 -rw-rw-r-- 1 backup backup      12 Oct 21 15:51 /srv/backups/linux/2026-10-21/docs/budget.csv
535406 -rw-rw-r-- 2 backup backup 5000000 Oct 21 15:51 /srv/backups/linux/2026-10-21/photos/trip.jpg

And the disk space: du counts each stored file only once, so the second snapshot costs almost nothing:

$ ssh vault 'du -sh /srv/backups/linux/2026-10-20 /srv/backups/linux/2026-10-21'
4.8M    /srv/backups/linux/2026-10-20
20K     /srv/backups/linux/2026-10-21

Two full-looking backups for the space of one. A real script would use $(date +%F) for today's folder and yesterday's for --link-dest.

Step 9: prove it with a restore

A backup you've never restored from is a hope, not a backup. Simulate losing a file: delete report.txt, restore it from the latest snapshot into a separate directory, and prove it's byte-for-byte the original by comparing checksums with the copy on vault.

Solution
$ rm ~/important/docs/report.txt
$ ls ~/important/docs
budget.csv
$ rsync -a vault:/srv/backups/linux/latest/docs/report.txt ~/restore-test/
$ cat ~/restore-test/report.txt
Quarterly report draft
$ sha256sum ~/restore-test/report.txt
870f3488796646fb0279e12ba464f51e2b9f338e1852e1bdd910d96ebe775276  /home/ubuntu/restore-test/report.txt
$ ssh vault 'sha256sum /srv/backups/linux/latest/docs/report.txt'
870f3488796646fb0279e12ba464f51e2b9f338e1852e1bdd910d96ebe775276  /srv/backups/linux/latest/docs/report.txt
$ cp ~/restore-test/report.txt ~/important/docs/

The two checksums match. And because the next backup.sh would mirror your deletion into latest, the dated snapshots are what save you if you notice a loss late.

Step 10: what's on vault?

Show everything stored for this machine, two levels deep, and the total space used.

Solution
$ ssh vault 'cd /srv/backups/linux && find . -maxdepth 2 -type d | sort && du -sh .'
.
./2026-10-20
./2026-10-20/code
./2026-10-20/docs
./2026-10-20/photos
./2026-10-21
./2026-10-21/code
./2026-10-21/docs
./2026-10-21/photos
./latest
./latest/code
./latest/docs
./latest/photos
9.7M    .

What you built

PieceWhereWhy
backup user, /srv/backups 700vaultBackups belong to one account nobody logs into
~/.ssh/backup_key with restrictbothUnattended logins that can only run commands
Host vault in ~/.ssh/configyour machineOne short name for address, user and key
~/bin/backup.shyour machineThe whole backup in one command, with a log
--link-dest snapshotsvaultA full copy per day, space only for changes

Take it further