Saltar al contenido
ES EN

Back up with BorgBackup and borgmatic in Docker Compose

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

60 min intermediate level validated configuration

Lab architectureComponents: Data to back up, borgmatic + Borg, Local repository, SSH replica, Remote repository, You (terminal).readborg createSSHappend-onlyextract / checkData to back up: step 1Data to back up./datos folder, read-on…You (terminal): step 4You (terminal)docker compose execborgmatic + Borg: step 3borgmatic + Borgcontainer with cronLocal repository: step 3Local repository./repositorio, encryptedSSH replica: step 2SSH replicaOpenSSH + borg serveRemote repository: step 2Remote repositoryappend-only volume
  1. Data to back up · step 1 ./datos folder, read-only
  2. borgmatic + Borg · step 3 container with cron
  3. Local repository · step 3 ./repositorio, encrypted
  4. SSH replica · step 2 OpenSSH + borg serve
  5. Remote repository · step 2 append-only volume
  6. 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.

Lab: Back up with BorgBackup and borgmatic in Docker Compose

Requirements

SystemLinux (Debian 12 / Ubuntu 24.04)
Docker Engine28.5.2
borgmatic (imagen docker-borgmatic, incluye Borg)2.1.10
Alpine Linux (imagen base de la réplica)3.24.4
OpenSSH (servidor de la réplica)7.2 o superior
RAM2 GB
Disk20 GB

Step by step

  1. 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.pub now (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.txt
  2. Configure 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 the borg serve documentation, --restrict-to-path limits access to that path; OpenSSH's restrict option (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-only and 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, prune and reading are still allowed. It is a barrier against accidental deletion, not full immutability.

    We avoid passwords: public key only (sshd_config uses OpenSSH's key-value format, which is why the file is declared as text). Replace CHANGE_ME_PUBLIC_KEY with the full contents of id_ed25519.pub, the line that starts with ssh-ed25519. The entrypoint generates the host keys in a volume and sets the permissions sshd requires.

    The borg account is created with adduser -D and unlocked with sed on /etc/shadow (replacing the locked password field ! with *), without relying on passwd options; 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_config

    Port 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 30

    File replica/authorized_keys

    command="borg serve --restrict-to-path /srv/borg",restrict CHANGE_ME_PUBLIC_KEY

    Replace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.

    File replica/Dockerfile

    FROM 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_config
  3. Configure borgmatic, create the repositories and enable append-only

    Now the main file. The repository passphrase comes in through the BORG_PASSPHRASE environment variable, which Borg reads as described in its quick start guide; define it in a .env file next to the Compose file and replace CHANGE_ME_REPO_PASSPHRASE with 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. Setting stop_grace_period gives 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_format with 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 the encryption key; it is the only source of the mode, so repo-create runs without the --encryption option. 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. If repo-create fails with connection refused, wait a few more seconds and repeat it (existing ones are skipped). Once the repositories exist, we edit replica/authorized_keys to add --append-only, rebuild and recreate only the replica; the volumes keep the remote repository. The first connection will accept the host fingerprint thanks to StrictHostKeyChecking=accept-new; on a real replica you may prefer to verify it manually.

    Retention (keep_daily, keep_weekly, keep_monthly) and checks (checks with frequency) are borgmatic configuration options. If your version rejects any of them, generate the reference file with borgmatic config generate and compare.

    File .env

    BORG_PASSPHRASE=CHANGE_ME_REPO_PASSPHRASE
    TZ=Europe/Madrid

    Replace every CHANGE_ME_… value with your own before starting: never keep example passwords or keys.

    File borgmatic/config.yaml

    source_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 weeks

    File compose.yaml

    services:
      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 replica

    File replica/authorized_keys

    command="borg serve --append-only --restrict-to-path /srv/borg",restrict CHANGE_ME_PUBLIC_KEY

    Replace 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 replica
  4. Take the first backup, check it and restore it

    We run a manual backup with --verbosity 1 --list --stats to see which files go in and how much space the new data takes. Then we list the archives with repo-list, run the checks with check and, most important of all, restore with extract into a temporary folder. A backup that has never been restored is just a hope.

    To restore we use --destination and --archive latest, so we don't touch your real data. Borg stores paths without the leading slash, so the file /mnt/source/prueba.txt ends up at mnt/source/prueba.txt inside the destination; find shows it before we read it.

    docker compose exec borgmatic borgmatic create --verbosity 1 --list --stats
    docker compose exec borgmatic borgmatic repo-list

    Expected 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

  1. Both repositories contain the backup

    repo-list shows 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 the replica repository is missing, the backup to the replica failed: see the troubleshooting section.

    docker compose exec borgmatic borgmatic repo-list
  2. The restore returns the original content

    The cat of the restored file in /tmp/restauracion must show the same text you wrote in datos/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.txt
  3. The cron job is scheduled

    The CRON variable schedules the daily run at 02:00. According to the image's README, the default command is borgmatic-start --stats -v 0, which prints very little. To confirm the job is installed, list the crontab inside the container: the line with 0 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 replica

Failed 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 replica

Pruning 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 borgmatic
Harden 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_KEY

Replace 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-lab

What 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

Article generated with AI.larebelion

Byline

· Chief editor · English edition · London

“A backup you have never restored is a hope. Restore first, then trust the schedule.”

Comentarios