How to automate BorgBackup with borgmatic to a second server

Back up files and databases to a second server over SSH with borgmatic: an append-only key, one YAML config, retention, checks and a daily timer.

Tested on Ubuntu 24.04 LTS

Before you start

  • A server running Ubuntu 24.04 to back up
  • A second Linux server with SSH and enough disk for the backups
  • Root or a user with sudo on both

To automate BorgBackup, install borgmatic and borgbackup, describe what to back up, where to send it, how long to keep it and which databases to dump in one file, /etc/borgmatic/config.yaml, and turn on the systemd timer that ships with the package. Point it at a second server reached over SSH with a key that may only run borg serve --append-only in one directory, and a break-in on the first server can't delete its own backups.

Our guide to backups you should run shows a plain borg create script. borgmatic replaces that script: it runs create, prune, compact and check in the right order, dumps the databases into the archive without temporary files filling your disk, and restores them with one command.

The examples use these names:

What In this tutorial
The server you back up web1
The backup server backup.example.com, address 203.0.113.20
The account on the backup server borg, with repositories under /home/borg/repos
The repository for web1 /home/borg/repos/web1

Commands marked on web1 run there as root (sudo -i); commands marked on the backup server run there.

Install Borg on both servers

On the backup server and on web1:

bash
sudo apt update
sudo apt install -y borgbackup

On web1 also install borgmatic:

bash
sudo apt install -y borgmatic
borgmatic --version
borg --version

Ubuntu 24.04 installs borgmatic 1.8.3 and Borg 1.2.8. Both servers should run the same Borg major version (1.x); minor differences are fine. On Debian 13 the package is borgmatic 1.9, which reads the same configuration. Debian 12's borgmatic 1.7 uses an older layout with location:, storage: and retention: sections, so on Debian 12 install a newer borgmatic with pipx or adapt the file.

Prepare the backup server

On the backup server, create an account that does nothing but hold repositories:

bash
sudo adduser --disabled-password --gecos '' borg
sudo -u borg mkdir -p /home/borg/repos/web1 /home/borg/.ssh
sudo -u borg chmod 700 /home/borg/.ssh

--disabled-password means nobody can log in with a password; the only way in will be the key you add next.

Give web1 an append-only key

On web1, make a key used only for backups:

bash
ssh-keygen -t ed25519 -N '' -C 'borg@web1' -f /root/.ssh/borg_ed25519
cat /root/.ssh/borg_ed25519.pub

On the backup server, add that public key to /home/borg/.ssh/authorized_keys on one line, with the restrictions in front of it:

text
command="borg serve --append-only --restrict-to-path /home/borg/repos/web1",restrict ssh-ed25519 AAAAC3... borg@web1
bash
sudo chown borg:borg /home/borg/.ssh/authorized_keys
sudo chmod 600 /home/borg/.ssh/authorized_keys

What the restrictions do:

  • command="borg serve ..." runs Borg's server whatever the client asks for. The key can't open a shell or run any other command.
  • --restrict-to-path limits it to web1's own repository. Asking for another one fails with Repository path not allowed.
  • --append-only means the key can add archives but can't really remove anything. A prune or delete sent through it only writes a deletion record to the repository's log; the data stays on disk until you compact the repository from the backup server itself (the last step of this tutorial).
  • restrict turns off port forwarding, agent forwarding and terminal allocation.

Each server you back up gets its own key, its own line and its own directory.

On web1, accept the backup server's host key once, so unattended runs don't stop at the question:

bash
ssh-keyscan -t ed25519 backup.example.com >> /root/.ssh/known_hosts

Compare the fingerprint with the one the backup server shows for ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub.

Check it worked

bash
ssh -i /root/.ssh/borg_ed25519 borg@backup.example.com ls

Instead of a file list you get Borg's usage text: the key reached the server, and the only thing it can do is run borg serve.

Write the borgmatic configuration

On web1, create the passphrase that encrypts the repository:

bash
mkdir -p /etc/borgmatic
chmod 700 /etc/borgmatic
openssl rand -base64 32 > /etc/borgmatic/passphrase
chmod 600 /etc/borgmatic/passphrase

Then /etc/borgmatic/config.yaml:

yaml
source_directories:
    - /etc
    - /home
    - /root
    - /var/www

repositories:
    - path: ssh://borg@backup.example.com/./repos/web1
      label: offsite

exclude_patterns:
    - /root/.cache
    - /home/*/.cache
    - '*/node_modules'
exclude_caches: true

encryption_passcommand: cat /etc/borgmatic/passphrase
compression: zstd,3
ssh_command: ssh -i /root/.ssh/borg_ed25519

keep_daily: 7
keep_weekly: 4
keep_monthly: 6

checks:
    - name: repository
      frequency: 2 weeks
    - name: archives
      frequency: 4 weeks

postgresql_databases:
    - name: appdb
      username: postgres

mariadb_databases:
    - name: all
bash
chmod 600 /etc/borgmatic/config.yaml
borgmatic config validate

The last command prints All configuration files are valid. What the settings do:

  • source_directories: what you made, not the operating system. Add /srv, /opt/yourapp or Docker volumes if your apps keep data there.
  • path: ssh://borg@backup.example.com/./repos/web1: the ./ makes the path relative to the borg user's home, so it is /home/borg/repos/web1.
  • encryption_passcommand: borgmatic reads the passphrase from the root-only file, so it is never in the config or the process list.
  • keep_*: the retention policy for prune: the newest archive of each of the last 7 days, 4 weeks and 6 months.
  • checks: a repository check every two weeks and a full archive check every four, instead of on every run.
  • postgresql_databases and mariadb_databases: dumped at backup time and streamed straight into the archive. name: all dumps every MariaDB database. Delete the section for a database you don't run. Setting the databases up is in install PostgreSQL and install MariaDB.

Copy /etc/borgmatic/passphrase into your password manager now. If web1 is lost, so is that file, and the backups can't be decrypted without it.

Let root dump PostgreSQL

MariaDB needs nothing: on Ubuntu, root logs in through the Unix socket. PostgreSQL only lets the postgres Linux user in as the postgres role, and borgmatic runs as root. Running sudo -u postgres pg_dump doesn't work under the systemd service either, because the service is locked down with NoNewPrivileges and sudo fails with sudo: PERM_SUDOERS: setresuid(-1, 1, -1): Operation not permitted.

Instead, map the root user to the postgres role for socket logins. In /etc/postgresql/16/main/pg_hba.conf (use your version number), add map=backup to the first line:

text
local   all             postgres                                peer map=backup

At the end of /etc/postgresql/16/main/pg_ident.conf, allow both root and postgres to use that role:

text
backup          root                    postgres
backup          postgres                postgres

Reload PostgreSQL and check:

bash
systemctl reload postgresql
psql -U postgres -c 'SELECT current_user;'

It prints postgres without asking for a password. Nothing changes for logins from the network.

Create the repository and the first backup

On web1:

bash
borgmatic init --encryption repokey-blake2

repokey-blake2 keeps the encrypted key inside the repository, protected by your passphrase. Export a copy of the key as well, and store it with the passphrase in your password manager; if the repository's copy is ever damaged, this is the only other one:

bash
borgmatic key export

Now run a backup and watch it:

bash
borgmatic create --verbosity 1 --stats

The end of the output shows the archive's size before and after compression and deduplication. Run plain borgmatic (no action) to do everything a scheduled run does: create, prune, compact and the checks that are due.

Check it worked

bash
borgmatic list
borgmatic list --archive latest --find etc/hostname

The first lists your archives, named after the host and time (web1-2026-10-21T23:53:23.463655). The second shows the file inside the newest one.

Run it every day

The Ubuntu package ships a systemd timer that runs borgmatic once a day at a random time (up to three hours after midnight), and catches up on a missed run after a reboot. Turn it on:

bash
systemctl enable --now borgmatic.timer
systemctl list-timers borgmatic.timer

The service runs with a low CPU and disk priority and blocks shutdown while a backup is in progress. To see the last run:

bash
journalctl -u borgmatic.service -n 30 --no-pager

To pick your own time, run systemctl edit borgmatic.timer and add:

ini
[Timer]
OnCalendar=
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=15m

The empty OnCalendar= clears the packaged schedule before setting yours.

Restore files and databases

Try this now, not on the day you need it.

Restore one directory from the newest archive into a scratch location:

bash
mkdir -p /tmp/restore
borgmatic extract --archive latest --path var/www/site --destination /tmp/restore
ls -R /tmp/restore

Paths inside an archive have no leading /. Use an archive name from borgmatic list instead of latest to go back further.

Restore the databases from the newest archive. This overwrites the current contents of every database in the configuration:

bash
borgmatic restore --archive latest

Add --database appdb to restore just one.

Clean up the space on the backup server

Because web1's key is append-only, the nightly prune and compact only mark old archives as deleted. The space comes back when you compact the repository on the backup server, where the append-only rule doesn't apply. Do it every month or two, after checking from web1 that the archives you expect are still listed (borgmatic list). If an attacker had pruned everything through web1's key, compacting would make that permanent; checking first is what makes append-only mode worth having.

On the backup server:

bash
sudo -u borg borg compact --verbose /home/borg/repos/web1
text
compaction freed about 22.56 kB repository space.

Compacting doesn't need the passphrase: the backup server never sees your data unencrypted. /home/borg/repos/web1/transactions logs every change with its time, which tells you what happened if you ever need to roll back; Borg's append-only documentation explains how.

Troubleshooting

Remote: borg@backup.example.com: Permission denied (publickey,password). The backup server didn't accept the key. Check that the public key in authorized_keys is complete and on one line, that the file belongs to borg with mode 600, and that ssh_command points at the right private key.

Remote: Host key verification failed. web1 doesn't know the backup server's host key. Run the ssh-keyscan step again (and compare the fingerprint).

Repository path not allowed: /home/borg/repos/.... The path in repositories doesn't match --restrict-to-path in authorized_keys. Remember the ./ in the URL means "inside the borg user's home".

pg_dump: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "root" does not exist. The PostgreSQL entry has no username: postgres, so pg_dump tried a role named after the Linux user. Add it, and the pg_ident.conf mapping above.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee