My solution for copying backups around the homelab

I have database servers outside the homelab, as in not in my basement. They are in datacenters. I don’t let them push the backups into the basement. Instead, I let them call home asking for the backups to be picked up. I prefer it that way. As I describe it, it may seem complex to do multiple steps when one step will do. However, this solution promises that the backups are ready and you’re not pulling back a half-completed backup.The process is:

  1. backup the database
  2. call home for pickup
  3. the pickup (pull) happens
  4. profit

In this post:

  • FreeBSD 15.0
  • rsync-3.4.1
  • you’ll learn how I use passphrase ssh keys in a secure manner to accomplish backups on hosts and copy them to another
  • r720-02 is the jail host
  • pg01 runs a PostgreSQL instance with databases we will backup
  • dbclone is the destination of the backups – that’s where they are pulled to

Some background details

For these examples, you might be able to skip over this section. In it, I talk about where the database is, there the backup goes, and who initiates the backup. You don’t have to do it my way, but I find it more convenient.

I usually run the database software in a jail, completely separate from any other components. I run the backups from the jail host, not within the jail. I find this makes it easier to script the backup rsyncs. Mostly that is because my jails cannot contact the home lab in the basement, but the host can. While this sounds more complex, I find it easier to do it this way. Add another jail to be backed up, modify the rsyncer host on the jail host. No need for each jail to have its own rsyncer user.

With more detail provided later in this post, that jail host will have more than one ssh key which is used to initiate a database pull. You might wonder why do it that way? Each passphrase ssh key is tied to a specific single task. That’s the only thing that ssh key can do. That restriction is part security and part using authorized_keys to the fullest.

It is because there are multiple-keys-per host that the keys have non-trivial names. It makes it easier to confirm their intended use.

Hope that helps to clarify what I’m doing and does not add confusion.

The rsyncer user

For both security and simplicity, I create a new user, rsyncer, which:

  • has read-only access to the database – how I did that
  • Can ssh to the backup destination host

Creation of this user is outside scope. There are many guides available.

The database dump

How the database is dumped to disk is outside scope and is not the primary purpose of this post. That’s something you have to create or pull from an example. That said, my simple script appears later (search for The backup script).

In my case, the database dumps are at ${HOME}/backups/database-backup for the user in question (rsyncer).

In this example, it’s PostgreSQL. It works with MySQL too (if you must use it).

ssh keys

For my solution, the local user (where the database backup is created) and the remote user (where the database backup is copied to) both need their own respective passphrase-less ssh keys. In general, a key without a passphrase are a bad idea. Except for when that’s exactly what you need. In my case, the keys will be tightly restricted as to what they can do. See anvil – copying the certificates to the website for similar uses.

The following paste shows the creation of the local user ssh-key. Note that I’m using the new-to-me -C option (thanks to https://jpmens.net/2026/04/03/ssh-certificates-the-better-ssh-experience/). The value shows up in the public key file. That precise comment will be useful when it appears in the ~/.ssh/authorized_keys file in other jails.

I’m also using a very specific file name, helpful when used in scripts, etc.

[rsyncer@r720-02 ~/.ssh]$ ssh-keygen -C "r720-02-pg01 copy to dbclone" -f id_ed25519.rsync.r720-02-pg01.copy.to.dbclone
Generating public/private ed25519 key pair.
Enter passphrase for "/home/rsyncer/.ssh/id_ed25519.rsync.r720-02-pg01.copy.to.dbclone" (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/rsyncer/.ssh/id_ed25519.rsync.r720-02-pg01.copy.to.dbclone
Your public key has been saved in /home/rsyncer/.ssh/id_ed25519.rsync.r720-02-pg01.copy.to.dbclone.pub
The key fingerprint is:
SHA256:Sdu5fsmd/sJ5VJftW348aGG/EZxiSdAgRBtHnEBQxMM r720-02-pg01 copy to dbclone
The key's randomart image is:
+--[ED25519 256]--+
|       .XX+=+    |
|         E=o..   |
|        ...  .  o|
|       . + .. o.=|
|        S o  + =o|
|           ..o. =|
|          ...++Bo|
|         .  +o*+*|
|          .....==|
+----[SHA256]-----+

[rsyncer@r720-02 ~/.ssh]$ ls -l
total 27
-rw-------  1 rsyncer rsyncer  419 Apr 11 21:02 id_ed25519.rsync.r720-02-pg01.copy.to.dbclone
-rw-r--r--  1 rsyncer rsyncer  110 Apr 11 21:02 id_ed25519.rsync.r720-02-pg01.copy.to.dbclone.pub
-rw-------  1 rsyncer rsyncer 1405 Apr  5  2024 known_hosts

[rsyncer@r720-02 ~/.ssh]$ cat id_ed25519.rsync.r720-02-pg01.copy.to.dbclone.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBpQDHQdyau7iWqrf1HjJwTG1CcnfGtZrra/48bsyW1z r720-02-pg01 copy to dbclone

I won’t show the creation of the remote user ssh key, mostly because it already exists.

The backup script

Initially, I wasn’t going to show the backup script. I now think it is better to show the pg_dump and the rsync to help demonstrate all the steps.

[rsyncer@r720-02 ~]$ cat ~/bin/backup-pg01.sh
#!/bin/sh

PGDUMP='/usr/local/bin/pg_dump -h 127.163.54.32'
PGDUMPALL='/usr/local/bin/pg_dumpall -h 127.163.54.32'
BACKUPROOT=${HOME}/backups/pg01/database-backup

cd ${BACKUPROOT}/postgresql

DATABASES="freshports.org"
for database in ${DATABASES}
do
	echo dumping $database
	if [ -f ${database}.dump ]
	then
		mv ${database}.dump archive
	fi

	${PGDUMP} -Fc ${database} -f ${database}.dump

	if [ $? -ne 0 ]
	then
		echo backup of ${database} failed
	fi

done

#
# Now dump the globals
#

if [ -f globals.sql ]
then
	mv globals.sql archive
fi

$PGDUMPALL -g -f globals.sql

if [ $? -ne 0 ]
then
	echo backup of globals failed
fi

# now call home to start the database pickup (pull)
#
/usr/bin/ssh -i ${HOME}/.ssh/id_ed25519.rsync.r720-02-pg01.copy.to.dbclone dbclone.int.unixathome.org

That last line is the call home. That is the line which causes the database dump to be pulled into the basement. We’ll learn more about that in the next section.

The call home

The call home occurs after the database backup has completed. The backup job is initiated by this cron job.

[rsyncer@r720-02 ~]$ cat /usr/local/etc/cron.d/rsyncer
# use /bin/sh to run commands, overriding the default set by cron
SHELL=/bin/sh

# mail any output to here, no matter whose crontab this is
MAILTO=dan@langille.org

# take a local copy of the bacula stuff

#minute	hour	mday	month	wday	who	    command
2       2       *       *       *       rsyncer     /usr/bin/lockf -t 0 /tmp/.rsync.backup-pg01.sh       ${HOME}/bin/backup-pg01.sh               >> /var/log/rsync-backup-pg01.log

Of note:

  • the use of lockf ensures one backup job completes before the next one starts – that scenario is unlikely, and this simple step prevents it from happening
  • the last line of ~/bin/backup.sh does the call home

That last line looks like this:

[rsyncer@r720-02 ~]$ tail -1 ~/bin/backup-pg01.sh
/usr/bin/ssh -i ${HOME}/.ssh/id_ed25519.rsync.r720-02-pg01.copy.to.dbclone dbclone.int.unixathome.org

I call that line the call home step.

That line says: ssh, using the indicated key (-i), to dbclone.int.unixathome.org. That’s it. Just ssh. Nothing else. Just ssh into the host. Don’t run a command when you get there. Just connect.

So what use it that? You’re connected to the server. How does the pull happen?

In the next section, I’ll show you what what happens and how it invokes a script to pull the database to the remote host.

For my own notes, that r720-02 host needs this entry in /etc/hosts because the required DNS is not available here:

10.55.0.140	dbclone.int.unixathome.org

The remote host

On the remote host (in this case, dbclone.int.unixathome.org), the ~/.ssh/authorized_keys file has this distinct entry. By distinct, I mean specific to one purpose: copying the backups from the pg01 jail to the remote host.

In the following, I will grep for the ssh key created above in the ssh keys section.

[rsyncer@dbclone ~]$ grep AAAAC3NzaC1lZDI1NTE5AAAAIBpQDHQdyau7iWqrf1HjJwTG1CcnfGtZrra ~/.ssh/authorized_keys
from="r720-02.startpoint.vpn.unixathome.org,10.8.1.140",command="/home/rsyncer/bin/rsync.pg01.from.r720-02.sh" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBpQDHQdyau7iWqrf1HjJwTG1CcnfGtZrra/48bsyW1z r720-02-pg01 copy to dbclone

This entry allows the sending host to connect. It also tightly controls what that sending host can do. Let’s examine that line closely.

  1. r720-02.startpoint.vpn.unixathome.org,10.8.1.140 – For a connection from that hostname or IP address (see from=”pattern-list” in the man page)
  2. command=”/home/rsyncer/bin/rsync.pg01.from.r720-02.sh – invoke the shown command if, and only if…
  3. ssh-ed25519 … – the presented ssh key matches the public key at the end of the line

This is why the sending host does not specify a command on the ssh connection. It lets the other host do that.

That is the only thing that ssh-key can do (on this host, with that authorized_keys configuration): run that command. Nothing else. That is why / how the source host only needs to invoke ssh and connect. The destination host then takes over and runs the required command. This is how the risk of using a passphrase ssh-key is tightly controlled. I will use that key only with this host and, thus, only with that command. Nothing else. That reduces the attack vector to an acceptable-to-me level of risk.

For more information on this stringent use of an ssh-key, perhaps Using ~/.ssh/authorized keys to decide what the incoming connection can do will help.

The remote command

Now we’re still on dbclone, the destination / remote host.

This is the remote command (remote, relative to the source of the backup) which is run when the call home step is invoked. This script is referenced in the authorized_keys file referenced above.

NOTE: The rsyncer user is used at both ends of the process: on one host, it does the call home. On the other host, it does the pull. This is that latter host. This is how that pull occurs.

[rsyncer@dbclone ~]$ cat /home/rsyncer/bin/rsync.pg01.from.r720-02.sh
#!/bin/sh
#
# This file obtains a backup of each database on the server.
# It relies upon a file on the server to do the actual backup,
# then uses rsync to copy the files from the server to here.
#

# the ~/.ssh/authorized keys entry on the other server must look like this:
#
# from="10.55.0.140",command="/usr/local/sbin/rrsync /usr/home/backups/" [ssh key goes here]
#
# invoking rrsync ensures the incoming rsync process is chrooted to /usr/home/backups/

# BACKUPDIR is used 

BACKUPDIR=${HOME}/backups/r720-02-pg01

IDENTITY_FILE_RSYNC=${HOME}/.ssh/id_ed25519.rsync.pg01.from.r720-02
SERVER_TO_RSYNC=r720-02.vpn.unixathome.org

# I bet this cd is not necessary
cd ${BACKUPDIR}

#
# use rsync to get local copies
# from / on the remote host
# put them into ${BACKUPDIR} here
#
/usr/local/bin/rsync -e "/usr/bin/ssh -i ${IDENTITY_FILE_RSYNC}" --recursive -av --stats --progress --exclude 'archive' ${SERVER_TO_RSYNC}:/ ${BACKUPDIR}

That last line, in short, says:

  1. use rsync
  2. over an ssh session invoked using that ssh-key
  3. to pull data from the server
  4. into our backup directory

That’s it. The backup is pulled down onto the host.

But what, the real magic is back on the source host, the one with the backup waiting to be pulled.

The ssh-key referred to by that script

In the previous section, I presented a script which pulls the data from the source host to the remote host (whose name is dbclone). In that script, it refers to this newly-created ssh-key.

[rsyncer@dbclone ~/.ssh]$ ssh-keygen -C "dblone copy pg01 from r720-02" -f ~/.ssh/id_ed25519.rsync.pg01.from.r720-02
Generating public/private ed25519 key pair.
Enter passphrase for "/home/rsyncer/.ssh/id_ed25519.rsync.pg01.from.r720-02" (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/rsyncer/.ssh/id_ed25519.rsync.pg01.from.r720-02
Your public key has been saved in /home/rsyncer/.ssh/id_ed25519.rsync.pg01.from.r720-02.pub
The key fingerprint is:
SHA256:deD49CKs08YoLcxjQD7qwftx50+h0UqpAK8svQ/sRzU dblone copy pg01 from r720-02
The key's randomart image is:
+--[ED25519 256]--+
|          .      |
|         o .     |
| ..     . + .    |
| oo   E.o+ o     |
|  +o . =Soo .    |
|o+.=o.o=+...     |
|o=+oBo=++.       |
|o.o+++oo.        |
| o++.  ...       |
+----[SHA256]-----+
[rsyncer@dbclone ~/.ssh]$ cat /home/rsyncer/.ssh/id_ed25519.rsync.pg01.from.r720-02.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEDbqveNb7IWGFQmRBEpoA3J1zL5txsimWucPY3+J9m dblone copy pg01 from r720-02

This key will be used on the source host to allow the remote host to pull down the backup.

What if you need to do something different on a call home?

This section is an aside and explains I use different ssh keys when connecting to a given host depending upon the task at hand.

Consider this scenario. I already have an ssh key for rsyncer, and it is tied to another task (rsyncing my Bacula database backup). How can I easily create another call home which does something else?

Solution: create another ssh key and use that one in the call home. Give it a unique entry in the ~/.ssh/authorized_keys file, with a different value for command.

Here’s my example, which I created just now, on the sending / source host.

On the receiving host, I have two entries for the source host:

[rsyncer@dbclone ~]$ grep r720-02 ~/.ssh/authorized_keys
from="r720-02.startpoint.vpn.unixathome.org,10.8.1.140",command="/usr/local/sbin/rrsync -ro /usr/home/rsyncer/backups/bacula-database/postgresql/" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILb6Z+Gt6wN8YP8XSQPFp2KgyNcd7CZiW+s4ghMbKnk4 rsyncer@r720-02.unixathome.org
from="r720-02.startpoint.vpn.unixathome.org,10.8.1.140",command="command="/home/rsyncer/bin/rsync-backup-for-r720-02-pg01.sh ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBpQDHQdyau7iWqrf1HjJwTG1CcnfGtZrra/48bsyW1z r720-02-pg01 copy to dbclone

That’s straight forward. Now, back to our backup pull.

Back on the source host

Back on the source host (r720-02), we have another ~/.ssh/authorized_keys file with magic even more exotic than the above.

It looks like this:

[rsyncer@r720-02 ~/.ssh]$ cat authorized_keys
from="dbclone.int.unixathome.org,10.55.0.140",command="/usr/local/sbin/rrsync /usr/home/rsyncer/backups/database-backup/pg01" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILEDbqveNb7IWGFQmRBEpoA3J1zL5txsimWucPY3+J9m dblone copy pg01 from r720-02

Here’s my explanation of this fantastical directive. It is similar to the entry on the destination ~/.ssh/authorized_keys, except that this one invokes the command /usr/local/sbin/rrsync.

Note: that is rrsync, with two r’s, not the rsync command.

That command is also installed by the rsync package, or a flavor thereof:

[rsyncer@r720-02-pg01 ~]$ pkg which /usr/local/sbin/rrsync
/usr/local/sbin/rrsync was installed by package rsync-python-3.4.1_6

Effectively, it chroots the rsync command to the supplied directly. This means the remote user can only pull files from within that directory, and not outside. Very nice..

The test

Now that I’ve written this post while setting up a new host (r720-02-pg01), it’s time to see if it works. If it does, I’ll use this post to set up the next host (r720-02-pg02) and see if that works. With the corrections and additions from those two run-throughs, this blog post will be ready to publish.

… yes, it’s ready now. I’ve deployed this approach to two different hosts covering four database servers. These are those files.

[17:29 dbclone dvl ~rsyncer/backups] % find r720-02-pg0? zuul-pg0?
r720-02-pg01
r720-02-pg01/database-backup
r720-02-pg01/database-backup/postgresql
r720-02-pg01/database-backup/postgresql/freshports.org.dump
r720-02-pg01/database-backup/postgresql/globals.sql
r720-02-pg02
r720-02-pg02/database-backup
r720-02-pg02/database-backup/postgresql
r720-02-pg02/database-backup/postgresql/globals.sql
r720-02-pg02/database-backup/postgresql/freshports.org.dump
zuul-pg01
zuul-pg01/database-backup
zuul-pg01/database-backup/postgresql
zuul-pg01/database-backup/postgresql/globals.sql
zuul-pg02
zuul-pg02/database-backup
zuul-pg02/database-backup/postgresql
zuul-pg02/database-backup/postgresql/globals.sql
zuul-pg02/database-backup/postgresql/pgcon.dump
zuul-pg02/database-backup/postgresql/bsdcan.dump
zuul-pg02/database-backup/postgresql/bsdcan.beta.dump
zuul-pg02/database-backup/postgresql/postgres.dump
zuul-pg02/database-backup/postgresql/postgresqleu.dump
zuul-pg02/database-backup/postgresql/postgresqleu_bsdcan.dump
zuul-pg02/database-backup/postgresql/pgcon.beta.dump
zuul-pg02/database-backup/postgresql/bugtracker.dump
zuul-pg02/database-backup/postgresql/postgresqleu_pg02.dump
zuul-pg02/database-backup/postgresql/freebsddiary.org.dump

Hope this helps. Worst case, this is here for me when I do it again. :)

Website Pin Facebook Twitter Myspace Friendfeed Technorati del.icio.us Digg Google StumbleUpon Premium Responsive

Leave a Comment

Scroll to Top