Automating Efficient VM Backups with Ansible, virtnbdbackup, and Borg

Automating Efficient VM Backups with Ansible, virtnbdbackup, and Borg

August 7, 2025

I always wanted a simple and flexible way to back up my KVM virtual machines.
In the past, I relied on custom scripts using rsync to copy disk images to a backup location. It worked – but with some annoying downsides: downtime during backup, large backup sizes, and manual effort.

At some point, I switched to Proxmox because of its sleek backup system – fast, reliable, and easy to manage. But things changed, and I went back to vanilla KVM setups.

Recently, I found a fascinating solution with virtnbdbackup that fits perfectly into my existing toolchain:
Ansible + Virtnbdbackup + Borgbackup.

Since I already use Ansible for automation, and I’m a big fan of Borg because of its deduplication and reliability, this setup felt like a natural upgrade. And the best part: no VM downtime during backup.

In this post, I’ll show you how I set it up, why it rocks, and how you can integrate it easily into your own infrastructure.

I will not cover here the installation of ansible and borgbackup, both have exellent dokumentation:
https://borgbackup.readthedocs.io/en/latest/quickstart.html
https://docs.ansible.com/ansible/latest/installation_guide/installation_distros.html
For ansible i have created a dedicated ansibe user with login permissions via ssh-key and is in group sudo.

Folder structure

.
├── group_vars
│   ├── all
│   │   └── ansible_user_vars.yaml
│   ├── borg
│   │   └── borg_passphrase.yaml
│   └── kvmHost
│       └── virtnbdbackup.yaml
├── host_vars
│   ├── dost.yaml
│   └── stor.yaml
├── production
├── staging
├── Makefile
├── playbooks
│   ├── kvmVM_backup.yaml
│   └── software_virtnbdbackup.yaml
└── roles
Code Snippet 1: Folder structure of ansible repository

Create vault passphrase

openssl rand -base64 32 > ~/.vault_pass.txt
chmod 600 ~/.vault_pass.txt

Install sofware virtnbdbackup

Since virtnbdbackup only needs to be installed once on the KVM host, we separate the process
into two playbooks: one for installing the software, and a second for running the backups.

Create the file: group_vars/borg/virtnbdbackup.yaml

virtnbdbackup_repo: https://github.com/abbbi/virtnbdbackup.git
virtnbdbackup_dir: /opt/virtnbdbackup
ℹ️
virtnbdbackup_dir here we extract the repository

Create ansible_user_vars

Create the file: group_vars/all/ansible_user_vars.yaml

ansible_user_name: ansible
ansible_user_shell: /bin/bash
ansible_user_groups: ['sudo']
ansible_user_ssh_key: "{{ lookup('file', lookup('env','HOME') + '/.ssh/id_ansible.pub') }}"
sudoers_file_path: "/etc/sudoers.d/{{ ansible_user_name }}"
ansible_user_password: "<password for user ansible>"

and encrytp that file

ansible-vault encrypt group_vars/all/ansible_user_vars.yaml

Create Borg Passwordfile

Create the file: group_vars/borg/borg_passphrase.yaml

borg_passphrase: <borg repo init passphrase>

and encrypt also

ansible-vault encrypt group_vars/borg/borg_passphrase.yaml

Create Inventory

Create the file ./production

[kvmHost]
stor ansible_host=<ip address of host>
dost ansible_host=<ip address of host>

[borg]
stor ansible_host=<ip address of host>
dost ansible_host=<ip address of host>

Install software playbook

This installs the necessary packages, sets up BorgBackup, and clones plus installs virtnbdbackup directly from its Git repository.

Create the file: playbooks/software_virtnbdbackup.yaml

---
- name: Install virtnbdbackup
  hosts: kvmHost 
  become: true
  tasks:

    - name: install required packages
      apt:
        name:
          - git
          - qemu-utils
          - libguestfs-tools
          - python3
          - python3-pip
          - python3-libnbd
          - python3-libvirt
          - python3-lxml
          - python3-tqdm
          - python3-paramiko
          - python3-lz4
          - python3-colorlog
          - nbd-client
          - borgbackup
        update_cache: yes
      when: ansible_os_family == 'Debian'

    - name: load NBD kernelmodul
      modprobe:
        name: nbd
        state: present
        params: max_part=8

    - name: clone virtnbdbackup repository
      git:
        repo: "{{ virtnbdbackup_repo }}"
        dest: "{{ virtnbdbackup_dir }}"
        version: master

virtnbdbackup playbook

Create the file: playbooks/kvmVM_backup.yaml

---
- name: KVM VM Backup with virtnbdbackup and Borg
  hosts: kvmHost 
  become: true
  tasks:

    - name: remove old backup
      ansible.builtin.file:
        path: "{{ backup_dir }}/{{ item }}_backup.qcow2"
        state: absent
      loop: "{{ vm_names }}"

    - name: ensure backup folder exists
      file:
        path: "{{ backup_dir }}"
        state: directory
        mode: '0755'

    - name: process virtnbdbackup for every VM
      command:
          argv:
            - "{{ virtnbdbackup_dir }}/virtnbdbackup"
            - -d
            - "{{ item }}"
            - -o
            - "{{ backup_dir }}/{{ item }}_backup.qcow2"
      loop: "{{ vm_names }}"
      args:
        executable: /bin/bash

    - name: execute Borg-Backup
      shell: |
        borg create --compression {{ borg_compression }} {{ borg_repo }}::kvm-{{ ansible_date_time.iso8601_basic_short }} {{ backup_dir }}        
      register: borg_result
      environment:
        BORG_PASSPHRASE: "{{ borg_passphrase }}"
      args:
        executable: /bin/bash

    - name: remove temporary virtnbdbackup files
      file:
        path: "{{ backup_dir }}"
        state: absent
      when: borg_result.rc == 0

    - name: cleanup and recreate temporary backup folder
      file:
        path: "{{ backup_dir }}"
        state: directory
        mode: '0755'
      when: borg_result.rc == 0
ℹ️
virtnbdbackup creates a temporary snapshot of the running VM and produces an uncompressed backup file in a temporary folder.
We need this uncompressed file because Borg requires it to efficiently scan and compress the final backup archive.

Create Host vars

For each host, we need a separate host_vars file where we define host-specific settings and list which VMs should be backed up.

Create the file: host_vars/dost.yaml

backup_dir: /var/backups

vm_names:
  - node3

borg_repo: /srv/backup
borg_compression: zstd,3
ℹ️
backup_dir : temporary backup space, chose one with enough room
vm_names : list of all VMs to backup
borg_repo : backup destination

And one for other Host
Create the file: host_vars/stor.yaml

backup_dir: /var/backups

vm_names:
  - node2
  - icinga2
  - netbox

borg_repo: /tank/backup/stor
borg_compression: zstd,3

Install virtnbdbackup

only once needed
This playbook installs virtnbdbackup on all Hosts in group kvmHost

ansible-playbook -i production  playbooks/software_virtnbdbackup.yaml --vault-password-file ~/.vault_pass.txt

Start backup

As the Ansible user created earlier, run the backup with:

ansible-playbook -i production --limit stor playbooks/kvmVM_backup.yaml --vault-password-file ~/.vault_pass.txt

That’s it — all wrapped up in a single command line, which I schedule as a crontab entry.

No worries about whether the machines are running or not — just reliable backups.

Restore

What’s a backup solution without a restore?
First, you need the Borg backup repository, then restore the content with:

List available backups:

sugras@stor:~$ sudo borg list --format '{archive}{NL}' /tank/backup/stor
[sudo] Passwort für sugras:
Enter passphrase for key /tank/backup/stor:
kvm-2025-07-09
kvm-20250709T191728
kvm-20250709T193031
kvm-20250709T220554
kvm-20250710T005033
kvm-20250718T215945

Pick one backup from the list and restore:

sudo borg extract /tank/backup/stor::kvm-20250718T215945 var/backups/icinga2_backup.qcow2 .

After typing the backup name, autocomplete helps, making it easy to restore a specific file.
In this example, I restore var/backups/icinga2_backup.qcow2 to my current working directory.

Now, use virtnbdrestore to extract the image:

/opt/virtnbdbackup/virtnbdrestore -i icinga2_backup.qcow2 -o dump
/opt/virtnbdbackup/virtnbdrestore -i icinga2_backup.qcow2 -o verify
/opt/virtnbdbackup/virtnbdrestore -i icinga2_backup.qcow2 -o ~/

Finally, copy or move the restored image to the desired location. It’s an extra step but safer, I think.

Hope this helps someone :-)

.

Last updated on