Encrypt backups with BorgBackup, automate them with borgmatic in Docker Compose, and add an append-only SSH replica with checks and a real restore.
Lab · Storage
Tap a component to see its step.
- Data to back up · step 1 ./datos folder, read-only
- borgmatic + Borg · step 3 container with cron
- Local repository · step 3 ./repositorio, encrypted
- SSH replica · step 2 OpenSSH + borg serve
- Remote repository · step 2 append-only volume
- You (terminal) · step 4 docker compose exec
What you will build and why
You will set up backups for a home server with BorgBackup and borgmatic inside Docker Compose: an encrypted local repository, automatic retention of old archives, an off-site replica over SSH in append-only mode, periodic checks and a real restore test.
The design follows the 3-2-1 rule: the original data, an encrypted local copy and another copy in a different location. The remote server in this lab is a container running OpenSSH with borg serve. In production that server would normally be another machine of yours, a NAS at a relative's house or a Borg-compatible provider; here we run it in a container to see the whole scheme end to end.
The main service is borgmatic (community image borgmatic-collective, pinned to 2.1.10, deliberately one version behind the latest release). The replica container is a dependency we build with a minimal Dockerfile on top of Alpine. Replace every CHANGE_ME_* value with your own and never commit them to a repository.

Requirements
Step by step
Prepare the structure and the SSH keys
First we create the folders and an SSH key dedicated to backups. A dedicated key lets you restrict it on the server so it can only run
borg serve, with no interactive shell.The key is generated without a passphrase because an automated process will use it; its protection is that on the server it can only launch Borg. Data encryption comes from the repository passphrase, which is a separate thing.
Copy the contents of
ssh-cliente/id_ed25519.pubnow (you will need it in step 2) before changing ownership. The borgmatic container runs as root, and OpenSSH requires the private key to belong to the user that uses it and not be accessible to others, so we hand only the private key over to root; the folder stays readable by your user and the public key stays world-readable.mkdir -p borg-lab/datos borg-lab/repositorio borg-lab/ssh-cliente borg-lab/replica borg-lab/borgmatic cd borg-lab ssh-keygen -t ed25519 -N "" -C borgmatic-lab -f ssh-cliente/id_ed25519 cat ssh-cliente/id_ed25519.pub sudo chown 0:0 ssh-cliente/id_ed25519 sudo chmod 600 ssh-cliente/id_ed25519 echo "contenido de prueba" > datos/prueba.txtConfigure the off-site SSH replica
The replica is an Alpine container with OpenSSH and BorgBackup. The client's public key is authorized with a forced command:
borg serve --restrict-to-path. According to theborg servedocumentation,--restrict-to-pathlimits access to that path; OpenSSH'srestrictoption (available since 7.2) blocks dangerous features such as port forwarding.So we don't depend on whether an append-only server allows initializing a repository, the first start uses the forced command without
--append-only; the remote repository is created in step 3 and right afterwards we enable--append-onlyand recreate the replica.Mind what this mode will actually guarantee: the documentation makes clear that append-only affects the low-level structure of the repository, and that
delete,pruneand reading are still allowed. It is a barrier against accidental deletion, not full immutability.We avoid passwords: public key only (
sshd_configuses OpenSSH's key-value format, which is why the file is declared astext). ReplaceCHANGE_ME_PUBLIC_KEYwith the full contents ofid_ed25519.pub, the line that starts withssh-ed25519. The entrypoint generates the host keys in a volume and sets the permissions sshd requires.The
borgaccount is created withadduser -Dand unlocked withsedon/etc/shadow(replacing the locked password field!with*), without relying onpasswdoptions; this way sshd accepts key login. The replica's SSH port is only published on 127.0.0.1:2222; borgmatic reaches it over Compose's internal network.File
replica/sshd_configPort 22 HostKey /etc/ssh/hostkeys/ssh_host_ed25519_key PasswordAuthentication no KbdInteractiveAuthentication no PermitRootLogin no AllowUsers borg AuthorizedKeysFile /etc/ssh/authorized_keys/%u ClientAliveInterval 10 ClientAliveCountMax 30File
replica/authorized_keyscommand="borg serve --restrict-to-path /srv/borg",restrict CHANGE_ME_PUBLIC_KEYReplace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.
File
replica/DockerfileFROM alpine:3.24.4 RUN apk add --no-cache borgbackup openssh-server \ && adduser -D -s /bin/sh borg \ && sed -i 's/^borg:!:/borg:*:/' /etc/shadow \ && mkdir -p /srv/borg /etc/ssh/hostkeys /etc/ssh/authorized_keys \ && chown borg:borg /srv/borg COPY sshd_config /etc/ssh/sshd_config COPY authorized_keys /etc/ssh/authorized_keys/borg RUN chmod 644 /etc/ssh/authorized_keys/borg COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 22 ENTRYPOINT ["/entrypoint.sh"]File
replica/entrypoint.sh#!/bin/sh set -e if [ ! -f /etc/ssh/hostkeys/ssh_host_ed25519_key ]; then ssh-keygen -t ed25519 -N "" -f /etc/ssh/hostkeys/ssh_host_ed25519_key fi chmod 600 /etc/ssh/hostkeys/ssh_host_ed25519_key chown -R borg:borg /srv/borg exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_configConfigure borgmatic, create the repositories and enable append-only
Now the main file. The repository passphrase comes in through the
BORG_PASSPHRASEenvironment variable, which Borg reads as described in its quick start guide; define it in a.envfile next to the Compose file and replaceCHANGE_ME_REPO_PASSPHRASEwith a long, unique phrase. Also store that passphrase in your password manager: without it, the backups are unrecoverable.Data paths are mounted read-only. The SSH keys folder is also mounted read-only, and the known hosts file is redirected to Borg's cache (a writable volume) with
UserKnownHostsFile. Settingstop_grace_periodgives a running backup time to finish cleanly when the container is stopped, as the image's README recommends.In the configuration we use
archive_name_formatwith a fixed prefix: the image's README warns that the container hostname changes when it is rebuilt, and that breaks pruning. Each repository declares its encryption mode with theencryptionkey; it is the only source of the mode, sorepo-createruns without the--encryptionoption. Both repositories are backed up in sequence.We start with the forced command without
--append-only, wait a few seconds for sshd to accept connections and create both repositories. Ifrepo-createfails with connection refused, wait a few more seconds and repeat it (existing ones are skipped). Once the repositories exist, we editreplica/authorized_keysto add--append-only, rebuild and recreate only the replica; the volumes keep the remote repository. The first connection will accept the host fingerprint thanks toStrictHostKeyChecking=accept-new; on a real replica you may prefer to verify it manually.Retention (
keep_daily,keep_weekly,keep_monthly) and checks (checkswithfrequency) are borgmatic configuration options. If your version rejects any of them, generate the reference file withborgmatic config generateand compare.File
.envBORG_PASSPHRASE=CHANGE_ME_REPO_PASSPHRASE TZ=Europe/MadridReplace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.
File
borgmatic/config.yamlsource_directories: - /mnt/source repositories: - path: /mnt/borg-repository label: local encryption: repokey-blake2 - path: ssh://borg@replica:22/srv/borg/lab.borg label: replica encryption: repokey-blake2 ssh_command: ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.cache/borg/known_hosts -o ServerAliveInterval=10 -o ServerAliveCountMax=30 archive_name_format: 'servidor-casa-{now:%Y-%m-%d-%H%M%S}' keep_daily: 7 keep_weekly: 4 keep_monthly: 6 checks: - name: repository frequency: 1 week - name: archives frequency: 2 weeksFile
compose.yamlservices: borgmatic: image: ghcr.io/borgmatic-collective/borgmatic:2.1.10 container_name: borgmatic hostname: borgmatic restart: unless-stopped stop_grace_period: 10m depends_on: - replica environment: - TZ=${TZ} - BORG_PASSPHRASE=${BORG_PASSPHRASE} - CRON=0 2 * * * volumes: - ./datos:/mnt/source:ro - ./repositorio:/mnt/borg-repository - ./borgmatic:/etc/borgmatic.d - ./ssh-cliente:/root/.ssh:ro - borg-config:/root/.config/borg - borg-cache:/root/.cache/borg - borgmatic-state:/root/.local/state/borgmatic replica: build: ./replica container_name: borg-replica restart: unless-stopped ports: - "127.0.0.1:2222:22" volumes: - replica-repo:/srv/borg - replica-hostkeys:/etc/ssh/hostkeys volumes: borg-config: borg-cache: borgmatic-state: replica-repo: replica-hostkeys:docker compose build replica docker compose up -d sleep 10 docker compose exec borgmatic borgmatic repo-create docker compose logs --tail 20 replicaFile
replica/authorized_keyscommand="borg serve --append-only --restrict-to-path /srv/borg",restrict CHANGE_ME_PUBLIC_KEYReplace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.
docker compose build replica docker compose up -d --force-recreate replicaTake the first backup, check it and restore it
We run a manual backup with
--verbosity 1 --list --statsto see which files go in and how much space the new data takes. Then we list the archives withrepo-list, run the checks withcheckand, most important of all, restore withextractinto a temporary folder. A backup that has never been restored is just a hope.To restore we use
--destinationand--archive latest, so we don't touch your real data. Borg stores paths without the leading slash, so the file/mnt/source/prueba.txtends up atmnt/source/prueba.txtinside the destination;findshows it before we read it.docker compose exec borgmatic borgmatic create --verbosity 1 --list --stats docker compose exec borgmatic borgmatic repo-listExpected output (illustrative: the exact format may vary)
servidor-casa-2025-01-01-020000 Wed, 2025-01-01 02:00:00 [...]docker compose exec borgmatic borgmatic check --verbosity 1 docker compose exec borgmatic mkdir -p /tmp/restauracion docker compose exec borgmatic borgmatic extract --archive latest --repository local --destination /tmp/restauracion docker compose exec borgmatic find /tmp/restauracion -name prueba.txt docker compose exec borgmatic cat /tmp/restauracion/mnt/source/prueba.txt
Check that it works
Both repositories contain the backup
repo-listshows the archives in the declared repositories; with two repositories, the output separates them by label. Names and dates will differ from the sample output. If thereplicarepository is missing, the backup to the replica failed: see the troubleshooting section.docker compose exec borgmatic borgmatic repo-listThe restore returns the original content
The
catof the restored file in/tmp/restauracionmust show the same text you wrote indatos/prueba.txt. That is the real proof that the backup works, not just that it exists.cat datos/prueba.txt docker compose exec borgmatic cat /tmp/restauracion/mnt/source/prueba.txtThe cron job is scheduled
The
CRONvariable schedules the daily run at 02:00. According to the image's README, the default command isborgmatic-start --stats -v 0, which prints very little. To confirm the job is installed, list the crontab inside the container: the line with0 2 * * *must appear.docker compose exec borgmatic crontab -l
If something fails
Permission denied (publickey) when copying to the replica
Check that you pasted the full line from id_ed25519.pub into replica/authorized_keys (replacing CHANGE_ME_PUBLIC_KEY) and that you rebuilt the image with docker compose build replica. Also verify that ssh-cliente/id_ed25519 belongs to root and has 600 permissions.
docker compose build replica
docker compose up -d --force-recreate replicaFailed to create/acquire the lock on the remote repository
This usually happens after a dropped connection: the server's borg serve process is still alive holding the lock. The borg serve documentation recommends SSH keepalives (already included in this lab, both in ssh_command and in sshd_config) and waiting for them to expire; restarting the replica also frees the process.
docker compose restart replicaPruning finds no archives or deletes them all after rebuilding the container
The container's hostname changes when it is rebuilt and, with the default name format, borgmatic no longer recognizes the earlier archives. That is why the configuration sets a fixed archive_name_format prefix; don't remove it.
Wrong passphrase or the repository won't unlock
Verify that .env sits next to compose.yaml and that BORG_PASSPHRASE matches the one used when the repository was created. After changing it, recreate the container so it picks it up.
docker compose up -d --force-recreate borgmaticHarden the setup
Keep the passphrase and key off the server
Write the repository passphrase down in a password manager and export the repository key to a separate place. If you lose both, the encrypted data cannot be recovered.
Set a quota on the remote repository
borg serve supports --storage-quota, which sets a storage quota for the repository and takes precedence over the repository's own quota. Add it to the forced command in authorized_keys with a value below the real free space on your disk (here 10G, within the lab's 20 GB). Then rebuild and recreate the replica with the same commands as in step 3 (docker compose build replica and docker compose up -d --force-recreate replica).
File replica/authorized_keys
command="borg serve --append-only --restrict-to-path /srv/borg --storage-quota 10G",restrict CHANGE_ME_PUBLIC_KEYReplace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.
Keep the replica out of the client's reach
Append-only does not prevent prune or delete according to Borg's documentation, so ideally the replica lives on another machine under another account and you run the remote pruning yourself from there, not from the server you are backing up.
Keep the replica's SSH local
The Compose file publishes the port only on 127.0.0.1. If you move the replica to another machine, expose SSH only to the addresses that need it and keep key-only authentication.
Clean-up: undo the lab
Remove the lab completely
down -v deletes the containers and the named volumes, including the remote replica and its host keys. --rmi local removes the image we built. Then delete the lab folder, which holds the encrypted local backup and the test SSH key (the private key belongs to root, which is why we use sudo).
docker compose down -v --rmi local
cd ..
sudo rm -rf borg-labWhat is verified
- replica/sshd_config: no automatic validator, review it by hand
- replica/authorized_keys: no automatic validator, review it by hand
- replica/Dockerfile: no automatic validator, review it by hand
- .env: no automatic validator, review it by hand
- borgmatic/config.yaml syntax (YAML parser)
- compose.yaml syntax (YAML parser and Compose structure)
- replica/authorized_keys: no automatic validator, review it by hand
- replica/authorized_keys: no automatic validator, review it by hand
- Syntax of 13 command block(s) (bash -n in an isolated container, not executed)
- Versions (4), options (9) and config keys (27) checked against 18 official documentation pages
- Running the whole lab end to end: not executed
Automatic checks run on 9 November 2026. Items marked "·" were not executed: check them in your own environment.
Sources
- Borg Quick Start borgbackup.readthedocs.io
- borg serve borgbackup.readthedocs.io
- borgmatic: How to set up backups torsion.org
- borgmatic: Configuration torsion.org
- borgmatic: How to extract a backup torsion.org
- docker-borgmatic github.com
Did this lab work for you?
Rate it from 1 to 5 stars. One vote per person; you can change it any time.
Article generated with AI.larebelion


