With Ansible and Borg I have been able to automate my backup configuration into just a few host variables per service.
I spent the better part of two weeks working on my Ansible role to set up a backup system in a way that works for me. I am happy to report that it has been a success. I will be posting the content of my files in here, I do have a git repo but I am not in a position to publish it as I want to avoid accidentally leaking something. So instead I will share the contents of all the files to help anybody else looking for inspiration or help. I am by no means a pro, but I get by with Ansible.
Timeline
- Started simple and tested at each step. Below is a list of things I did and tested before moving on.
- Install Borg, create directories with correct permission, copy files, copy files with some
echos to verify variables. - Only when all that was working did I create the templates and send them to hosts as backup scripts.
- Install Borg, create directories with correct permission, copy files, copy files with some
- Added more quality of life features after I got the basics down.
- Added status pings to my Uptime Kuma instance.
- Modified pre and post hook commands to be a list and iterate instead of a single item so I can dump a database as well as stop/start containers.
- Added logrotate into the mix to keep my logs from expanding forever.
- Added ability to exclude files, this was for Trilium Notes and any other services that include built-in backups and/or log files that I don’t need to backup.
- Tested and realized that I needed to make modifications to the ability to exclude files because I structured the Jinja If and For statements incorrectly.
The Setup
Here is a quick overview of the parts of my home lab that pertain to backups.
The borg server is running on TrueNAS in a container. That took some work, I tried working with the Borg Warehouse community application but I could not get permissions working correctly. I am glad I did not get it working, it would have required much more manual configuration. Instead I am running Borg as a container. Generic Debian or Ubuntu image (whatever you want to use works too), and then I mounted a BorgBackup dataset into my user’s home directory. The reason I use a specific dataset is so I can also run a Cloud Task on it in TrueNAS and have that as the offsite backup.
My servers are all connected via Tailscale and my Tailnet serves as the backbone for connecting and controlling everything. It also serves to transfer data to services that aren’t expose to the internet, such as a Borg backup server. So my VPSes run backups over the Tailnet. Same with other servers I manage for friends, at least until they get their own NAS.
My cloud tasks from TrueNAS currently backup to OneDrive and Google Drive. Only reason is that I have the family plan on both and I am not paying for either, so I may as well take advantage while I can. Eventually I would like to move it to a storage VPS. Anyways, I am not terribly concerned since all the Borg backups are encrypted. Yes I know that these companies are still not to be trusted and can hold onto data for an era of quantum computing to break all the encryption, but for now this is sufficient for me.
The Role
Role Defaults
This establishes default options for the role in case a variable is not specified, and it is not crucial to be defined per-host. This way we do not run into a failure over a benign variable being.
---
# file: roles/borgbackup/defaults/main.yaml
borgbackup_backup_server: false
borgbackup_init_repo: true
borgbackup_cron:
hour: 4
minute: 25
Tasks
Below is the main task list file that everything else is orchestrated from.
Task files below will be in alphabetical order following main.yaml. Explanations will be provided for each file where necessary.
---
# file: roles/borgbackup/tasks/main.yaml
- name: Install borgbackup packages
ansible.builtin.apt:
name: "{{ borgbackup_install_packages }}"
state: present
become: true
- name: Setup clients and all their backup targets
ansible.builtin.include_tasks: client_setup.yaml
when:
- borgbackup_backup_server is false
- borgbackup_configs is defined
loop: "{{ borgbackup_configs | dict2items }}"
- name: Init the borg repo
ansible.builtin.include_tasks: init_repo.yaml
when:
- borgbackup_backup_server is false
- borgbackup_configs is defined
loop: "{{ borgbackup_configs | dict2items }}"
- name: Configure backups on hosts
ansible.builtin.include_tasks: roles/borgbackup/tasks/configure_backups.yaml
when:
- borgbackup_backup_server is false
- borgbackup_configs is defined
This configures clients with keys to connected to their respective Borg repos. Borg backup runs as root on the clients so that file read permissions are not an issue during backups. There are probably security implications for this and I may change it in the future, but for now this is my solution.
---
# file: roles/borgbackup/tasks/client_setup.yaml
- name: Set and get var names for use in client tasks
ansible.builtin.include_tasks: roles/borgbackup/tasks/set_borg_server_facts.yaml
- name: Verify that client root has ssh key and register
ansible.builtin.user:
name: root
generate_ssh_key: true
ssh_key_type: ed25519
register: borgbackup_client_user_ssh_key
when: item.value.ssh_key is not defined
- name: Set borgbackup_ssh_pubkey when ssh_key not defined
ansible.builtin.set_fact:
borgbackup_ssh_pubkey: "{{ borgbackup_client_user_ssh_key.ssh_public_key }}"
borgbackup_ssh_comment: "root@{{ inventory_hostname }}"
when: item.value.ssh_key is not defined
- name: Set borgbackup_ssh_pubkey when ssh_key is defined
ansible.builtin.set_fact:
borgbackup_ssh_pubkey: "{{ item.value.ssh_key }}"
borgbackup_ssh_comment: "{{ item.key }}@{{ inventory_hostname }}"
when: item.value.ssh_key is defined
- name: Add the public_key to authorized_keys on borg_repo_server if not already defined
ansible.posix.authorized_key:
key: "{{ borgbackup_ssh_pubkey }}"
comment: "{{ borgbackup_ssh_comment }}"
user: "{{ my_user }}"
key_options: 'command="cd {{ borgbackup_repo_root }};borg serve --restrict-to-path {{ borgbackup_repo_root }}",restrict'
delegate_to: "{{ item.value.borg_repo_server }}"
- name: Create repo root on repo server host for {{ item.key }}
ansible.builtin.file:
path: "{{ borgbackup_repo_root }}"
state: directory
owner: "{{ my_user }}"
group: apps # TrueNAS group for dataset
mode: '0770'
delegate_to: "{{ item.value.borg_repo_server }}"
This creates the backup configuration directories, bash files, and cron jobs on each client. Includes logging files as well.
---
# file: roles/borgbackup/tasks/configure_backups.yaml
- name: Create .borg directory for managing backup scripts & logs
ansible.builtin.file:
path: "/home/{{ my_user }}/.borg"
mode: '0774'
owner: "{{ my_user }}"
group: "{{ my_user }}"
state: directory
- name: Create .borg/logs directory for logs
ansible.builtin.file:
path: "/home/{{ my_user }}/.borg/logs"
mode: '0774'
owner: root
group: root
state: directory
- name: Apply logrotate policy to logs directory
ansible.builtin.template:
dest: /etc/logrotate.d/borg
mode: '0644'
owner: root
group: root
src: logrotate_conf.j2
- name: Create .borg/services directory for service backup scripts
ansible.builtin.file:
path: "/home/{{ my_user }}/.borg/services"
mode: '0700'
owner: root
group: root
state: directory
- name: Create .env file inside .borg/services containing repo vars
ansible.builtin.template:
dest: "/home/{{ my_user }}/.borg/services/.env"
mode: '0600'
owner: root
group: root
src: borg_env.j2
- name: Create .borg/tmp directory for temporary backup files (eg database dumps)
ansible.builtin.file:
path: "/home/{{ my_user }}/.borg/tmp"
mode: '0700'
owner: root
group: root
state: directory
- name: Create backup_orchestrator.sh file
ansible.builtin.template:
dest: "/home/{{ my_user }}/.borg/backup_orchestrator.sh"
mode: '0700'
owner: root
group: root
src: backup_orchestrator.sh.j2
- name: Create cron job for backup_orchestrator.sh
ansible.builtin.cron:
name: Run BorgBackup Orchestrator script
job: '/home/{{ my_user }}/.borg/backup_orchestrator.sh >> "/home/{{ my_user }}/.borg/logs/borg_cron.log" 2>&1'
state: present
minute: "{{ borgbackup_cron.minute }}"
hour: "{{ borgbackup_cron.hour }}"
- name: Call tasks to create each backup service
ansible.builtin.include_tasks: roles/borgbackup/tasks/generate_backup_scripts.yaml
loop: "{{ borgbackup_configs | dict2items }}"
This generates backup scripts and adds them into the queue on the backup_orchestrator.sh file.
---
# file: roles/borgbackup/tasks/generate_backup_scripts.yaml
- name: Set and get var names for use in client tasks
ansible.builtin.include_tasks: roles/borgbackup/tasks/set_borg_server_facts.yaml
- name: Add bash file to backup_orchestrator.sh - {{ item.key }}
ansible.builtin.lineinfile:
path: "/home/{{ my_user }}/.borg/backup_orchestrator.sh"
line: "source /home/{{ my_user }}/.borg/services/{{ item.key }}.sh;"
insertbefore: "## Ansible Managed Section End"
when: item.value.src_path is defined
- name: Create backup script files in services directory - {{ item.key }}
ansible.builtin.template:
dest: "/home/{{ my_user }}/.borg/services/{{ item.key }}.sh"
mode: '0700'
owner: root
group: root
src: service_backup.sh.j2
when: item.value.src_path is defined
When running the role it checks if each repo is initialized, and if it needs to be. If it finds a repo that isn’t initialized then it creates it. I just check the borg backup host for a readme where I expect the repo to exist. This was the most reliable way to check if a repo is created. I tried borg info on the repositories but it tended to time out.
I also export the repo key files to the machine running the script so I can recover the repository if needed.
---
# file: roles/borgbackup/tasks/init_repo.yaml
- name: Set and get var names for use in client tasks
ansible.builtin.include_tasks: roles/borgbackup/tasks/set_borg_server_facts.yaml
# Check if repo exists
- name: Check if borg repo exists on the repo_server - {{ item.key }}
ansible.builtin.file:
path: "{{ borgbackup_repo_root }}/{{ item.key }}/README"
delegate_to: "{{ item.value.borg_repo_server }}"
check_mode: true
register: borgbackup_repo_initialised
changed_when: false
failed_when: false
when: borgbackup_init_repo is true
# Create borg repo for machine when not already present
- name: Init borg repo for the service - {{ item.key }}
ansible.builtin.command:
cmd: "borg init --encryption=repokey ssh://{{ borgbackup_server_ssh }}/./{{ item.key }}"
register: borgbackup_init_return
changed_when: borgbackup_init_return.rc != 0
when:
- borgbackup_init_repo is true
- borgbackup_repo_initialised.state == "absent"
environment:
BORG_PASSPHRASE: "{{ borg_repokey }}"
# Export key to a file in facts/borgbackup
- name: Get borg repo key and register - {{ item.key }}
ansible.builtin.command:
# chdir: "/home/{{ my_user }}"
cmd: "borg key export ssh://{{ borgbackup_server_ssh }}/./{{ item.key }}"
register: borgbackup_repo_key_export
changed_when: false
failed_when: borgbackup_repo_key_export.rc != 0
when:
- borgbackup_init_repo is true
- borgbackup_repo_initialised.state == "absent"
environment:
BORG_PASSPHRASE: "{{ borg_repokey }}"
- name: Save repo export to a file in files - {{ item.key }}
ansible.builtin.copy:
content: "{{ borgbackup_repo_key_export.stdout }}"
dest: "PATH/TO/REPO_KEYS/{{ inventory_hostname }}_{{ item.key }}"
mode: '0770'
delegate_to: localhost
become: false
when:
- borgbackup_init_repo is true
- borgbackup_repo_initialised.state == "absent"
This is a helper task that sets facts for me. I need to use and update a few times throughout the run of the task. Sometimes as part of a loop on the same host but different backup configurations.
---
# file: roles/borgbackup/tasks/set_borg_server_facts.yaml
- name: Set variables for use in the upcoming client tasks
ansible.builtin.set_fact:
borgbackup_server_ssh: >-
{{ hostvars[item.value.borg_repo_server]['my_user'] }}@{{ hostvars[item.value.borg_repo_server]['ansible_host']
}}:{{ hostvars[item.value.borg_repo_server]['ansible_port'] }}
borgbackup_repo_root: "{{ hostvars[item.value.borg_repo_server]['borgbackup_repo_dir'] }}/{{ inventory_hostname }}"
- name: Set borgbackup_init_repo to true before checking (default)
ansible.builtin.set_fact:
borgbackup_init_repo: true
- name: If borgbackup_repo_init is defined in loop then override default
ansible.builtin.set_fact:
borgbackup_init_repo: "{{ item.value.init_repo }}"
when: item.value.init_repo is defined
- name: Output borgbackup_init_repo var for {{ item.key }}
ansible.builtin.debug:
var: borgbackup_init_repo
Templates
This file is backuporchestrator.sh.j2.
#!/usr/bin/env bash
set -euo pipefail
# Generated with Ansible
# Cron: {{ borgbackup_cron.minute }} {{ borgbackup_cron.hour }} * * * /home/{{ my_user }}/.borg/backup_orchestrator.sh >> "/home/{{ my_user }}/.borg/logs/borg_cron.log" 2>&1
# Manual: /home/{{ my_user }}/.borg/backup_orchestrator.sh >> "/home/{{ my_user }}/.borg/logs/borg_cron.log" 2>&1
# Backup start time set
export orch_start_time=$(date +%s%3N)
info() { printf "\n%s %s\n\n" "$( date )" "$*"; }
trap 'echo $( date ) Backup orchestrator interrupted; exit 2' INT TERM
info "Starting backup orchestrator"
info "Running backup scripts"
## Ansible Managed Section Start
## Ansible Managed Section End
info "Backup scripts completed"
# Backup end time set
export orch_end_time=$(date +%s%3N)
# Calculator duration
export duration=$((orch_end_time - orch_start_time))
info "Emptying .borg/tmp directory"
rm -rf /home/{{ my_user }}/.borg/tmp/*
info "Backup orchestrator completed in $duration ms"
This is borg.env.j2.
# Generated with Ansible
export BORG_PASSPHRASE='{{ borg_repokey }}'
This is logrotate.conf.j2.
# Generated with Ansible
/home/{{ my_user }}/.borg/logs/borg_cron.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
dateext
dateformat -%Y%m%d
}
This is service_backup.sh.j2
#!/usr/bin/env bash
set -uo pipefail
# Generated with Ansible for {{ inventory_hostname }}
export PUSH_URL='{{ item.value.status_url }}'
export BORG_REPO='ssh://{{ borgbackup_server_ssh }}/./{{ item.key }}'
# Get other env vars from .env file in dir
source /home/{{ my_user }}/.borg/services/.env
export start_time=$(date +%s%3N)
# some helpers and error handling:
info() { printf "\n%s %s\n\n" "$( date )" "$*"; }
trap 'echo $( date ) {{ item.key }} Backup interrupted; \
curl -fsS --retry 3 "${PUSH_URL}?status=down&msg=ERROR"; \
return 2' INT TERM
info "Backup Starting: {{ item.key }}"
{% if item.value.pre_hook is defined %}
# Pre Hook Commands
info "Running pre-hook commands"
{% for cmd in item.value.pre_hook %}
{{ cmd }}
{% endfor %}
pre_hook_exit=$?
{% endif %}
# The backup itself
borg create --stats --compression zstd \
::'archive-{now}' \
{% if item.value.exclude_patterns is defined %}
{% for pattern in item.value.exclude_patterns %}--exclude '{{ pattern }}' \
{% endfor %}{% endif %}
{% for path in item.value.src_path %}{{ path }} {% endfor %}
backup_exit=$?
{% if item.value.post_hook is defined %}
# Post Hook commands
info "Running post-hook commands"
{% for cmd in item.value.post_hook %}
{{ cmd }}
{% endfor %}
post_hook_exit=$?
{% endif %}
# Archive rotation & compact
info "Pruning repository"
borg prune --keep-daily=14 --keep-weekly=4 --keep-monthly=6
prune_exit=$?
info "Compacting repository"
borg compact --cleanup-commits
compact_exit=$?
# Print borg info to the log
borg info
# Check to find highest exit code
global_exit=$(( backup_exit > prune_exit ? backup_exit : prune_exit ))
global_exit=$(( prune_exit > global_exit ? prune_exit : global_exit ))
global_exit=$(( compact_exit > global_exit ? compact_exit : global_exit ))
{% if item.value.pre_hook is defined %}
global_exit=$(( pre_hook_exit > global_exit ? pre_hook_exit : global_exit ))
{% endif %}
{% if item.value.post_hook is defined %}
global_exit=$(( post_hook_exit > global_exit ? post_hook_exit : global_exit ))
{% endif %}
# Calc total backup run duration
export end_time=$(date +%s%3N)
export duration=$((end_time - start_time))
# Heartbeat: signal how backup went
if [ ${global_exit} -eq 0 ]; then
info "Backup, Prune, and Compact finished successfully"
curl -fsS --retry 3 "${PUSH_URL}?status=up&msg=OK&ping=$duration"
elif [ ${global_exit} -eq 1 ]; then
info "Backup, Prune, and/or Compact finished with warnings"
curl -fsS --retry 3 "${PUSH_URL}?status=up&msg=WARNING&ping=$duration"
else
info "Backup, Prune, and/or Compact finished with errors"
curl -fsS --retry 3 "${PUSH_URL}?status=down&msg=ERROR&ping=$duration"
fi
info "Backup Finished: {{ item.key }} in $duration ms"
return ${global_exit}
Variables
---
# file: roles/borgbackup/vars/main.yaml
borgbackup_install_packages:
- borgbackup
- logrotate
I also have a variable you’ve probably seen my_user. It is a host variable that represents the user that I connect to my servers with and run my services from. My Ansible setup role creates a separate ansible user to run Ansible commands from. Make sure to either configure a my_user variable, or set it to the value of your user.
Host Variables
This is how I configure each backup per host, in their own host file. Here is an example of a few different backups.
borg_repokey: REPO_PASSPHRASE
borgbackup_configs:
first-backup:
src_path:
- /path/to/.borg/tmp/first-backup-db.sql
- /path/to/first/backup
pre_hook:
- >-
docker exec mariadb mariadb-dump --password= --databases
> /path/to/.borg/tmp/first-backup-db.sql
status_url: https://kuma.domain.com
borg_repo_server: borgbackup.server.hostname
second-backup:
src_path:
- /path/to/second/backup
- /path/to/.borg/tmp/second-backup-db.sql
pre_hook:
- >-
docker exec mariadb mariadb-dump --password= --databases
> /path/to/.borg/tmp/second-backup-db.sql
status_url: https://kuma.domain.com
borg_repo_server: borgbackup.server.hostname
third-backup:
src_path:
- /path/to/third/backup
exclude_patterns:
- /path/to/third/backup/logs
pre_hook:
- docker stop container
post_hook:
- docker start container
status_url: https://kuma.domain.com
borg_repo_server: borgbackup.server.hostname
What did I learn?
So very much. I got a better grasp of whitespace adjustments that YAML and Jinja can make, the various modifiers for that such as - and >-. I also learned a lot about shell scripting. More about lists and YAML handling in general too. I wish I could list it better but pretty much this whole process was learning.
What’s Next?
I am currently happy with where the role is now, I have added a few services already and they have been happily backing up without any need for interference. Everything is logged so I can monitor it.
I think what I have built should cover 99% of my use-cases, including creating a repo/adding an SSH key for a service such as Nextcloud AIO.
There will inevitably be some tweaking as I use the role and find edge-cases that need a little extra added. I look forward to that, it is fun, like trying to add new pieces to a puzzle.
Sources & Inspiration
- Ansible docs
- Borg docs & deployment page specifically
- Borg ansible role (continued) – adyxax.org
- How to Use Ansible to Configure System Backup (borgbackup) – OneUptime
- My previous restic script that I made but have not published.
One last thing
I will be writing about this in the future, but I am venturing back into pyinfra now that I have had some time with Ansible. If I do move forward with pyinfra again I will be writing up a post with details why and what I like more and all that fun stuff.
I will of course be porting my borg role over into a deploy, and I will also create a similar write-up for that too. Stay tuned!