--- # Database Backup Playbook # Automated backup of all PostgreSQL and MySQL databases across homelab # Usage: ansible-playbook playbooks/backup_databases.yml # Usage: ansible-playbook playbooks/backup_databases.yml --limit atlantis # Usage: ansible-playbook playbooks/backup_databases.yml -e "backup_type=full" - name: Backup All Databases hosts: "{{ host_target | default('all') }}" gather_facts: yes vars: backup_base_dir: "/volume1/backups/databases" # Synology path backup_local_dir: "/tmp/database_backups" # Database service mapping database_services: atlantis: - name: "immich-db" type: "postgresql" database: "immich" container: "immich-db" user: "postgres" - name: "vaultwarden-db" type: "postgresql" database: "vaultwarden" container: "vaultwarden-db" user: "postgres" - name: "joplin-db" type: "postgresql" database: "joplin" container: "joplin-stack-db" user: "postgres" - name: "firefly-db" type: "postgresql" database: "firefly" container: "firefly-db" user: "firefly" calypso: - name: "authentik-db" type: "postgresql" database: "authentik" container: "authentik-db" user: "postgres" - name: "paperless-db" type: "postgresql" database: "paperless" container: "paperless-db" user: "paperless" homelab_vm: - name: "mastodon-db" type: "postgresql" database: "mastodon" container: "mastodon-db" user: "postgres" - name: "matrix-db" type: "postgresql" database: "synapse" container: "synapse-db" user: "postgres" tasks: - name: Check if Docker is running systemd: name: docker register: docker_status failed_when: docker_status.status.ActiveState != "active" - name: Create backup directories file: path: "{{ item }}" state: directory mode: '0755' loop: - "{{ backup_base_dir }}/{{ inventory_hostname }}" - "{{ backup_local_dir }}/{{ inventory_hostname }}" ignore_errors: yes - name: Get current database services for this host set_fact: current_databases: "{{ database_services.get(inventory_hostname, []) }}" - name: Display backup plan debug: msg: | ๐Ÿ“Š DATABASE BACKUP PLAN ======================= ๐Ÿ–ฅ๏ธ Host: {{ inventory_hostname }} ๐Ÿ“… Date: {{ ansible_date_time.date }} ๐Ÿ”„ Type: {{ backup_type | default('incremental') }} ๐Ÿ“ฆ Databases: {{ current_databases | length }} {% for db in current_databases %} - {{ db.name }} ({{ db.type }}) {% endfor %} ๐Ÿ“ Backup Dir: {{ backup_base_dir }}/{{ inventory_hostname }} ๐Ÿ—œ๏ธ Compression: {{ compress_backups | default(true) }} - name: Check database containers are running shell: docker ps --filter "name={{ item.container }}" --format "{{.Names}}" register: container_check loop: "{{ current_databases }}" changed_when: false - name: Create pre-backup container status shell: | echo "=== PRE-BACKUP STATUS ===" > {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log echo "Host: {{ inventory_hostname }}" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log echo "Date: {{ ansible_date_time.iso8601 }}" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log echo "Type: {{ backup_type | default('incremental') }}" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log echo "" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log {% for db in current_databases %} echo "=== {{ db.name }} ===" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log docker ps --filter "name={{ db.container }}" --format "Status: {% raw %}{{.Status}}{% endraw %}" >> {{ backup_local_dir }}/{{ inventory_hostname }}/backup_status_{{ ansible_date_time.epoch }}.log {% endfor %} - name: Backup PostgreSQL databases shell: | backup_file="{{ backup_local_dir }}/{{ inventory_hostname }}/{{ item.name }}_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}.sql" echo "๐Ÿ”„ Backing up {{ item.name }}..." docker exec {{ item.container }} pg_dump -U {{ item.user }} {{ item.database }} > "$backup_file" if [ $? -eq 0 ]; then echo "โœ… {{ item.name }} backup successful" {% if compress_backups | default(true) %} gzip "$backup_file" backup_file="${backup_file}.gz" {% endif %} # Get backup size backup_size=$(du -h "$backup_file" | cut -f1) echo "๐Ÿ“ฆ Backup size: $backup_size" # Copy to permanent storage if available if [ -d "{{ backup_base_dir }}/{{ inventory_hostname }}" ]; then cp "$backup_file" "{{ backup_base_dir }}/{{ inventory_hostname }}/" echo "๐Ÿ“ Copied to permanent storage" fi else echo "โŒ {{ item.name }} backup failed" exit 1 fi register: postgres_backups loop: "{{ current_databases }}" when: - item.type == "postgresql" - item.container in (container_check.results | selectattr('stdout', 'equalto', item.container) | map(attribute='stdout') | list) - name: Backup MySQL databases shell: | backup_file="{{ backup_local_dir }}/{{ inventory_hostname }}/{{ item.name }}_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}.sql" echo "๐Ÿ”„ Backing up {{ item.name }}..." docker exec {{ item.container }} mysqldump -u {{ item.user }} -p{{ item.password | default('') }} {{ item.database }} > "$backup_file" if [ $? -eq 0 ]; then echo "โœ… {{ item.name }} backup successful" {% if compress_backups | default(true) %} gzip "$backup_file" backup_file="${backup_file}.gz" {% endif %} backup_size=$(du -h "$backup_file" | cut -f1) echo "๐Ÿ“ฆ Backup size: $backup_size" if [ -d "{{ backup_base_dir }}/{{ inventory_hostname }}" ]; then cp "$backup_file" "{{ backup_base_dir }}/{{ inventory_hostname }}/" echo "๐Ÿ“ Copied to permanent storage" fi else echo "โŒ {{ item.name }} backup failed" exit 1 fi register: mysql_backups loop: "{{ current_databases }}" when: - item.type == "mysql" - item.container in (container_check.results | selectattr('stdout', 'equalto', item.container) | map(attribute='stdout') | list) no_log: true # Hide passwords - name: Verify backup integrity shell: | backup_file="{{ backup_local_dir }}/{{ inventory_hostname }}/{{ item.name }}_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}.sql{% if compress_backups | default(true) %}.gz{% endif %}" if [ -f "$backup_file" ]; then {% if compress_backups | default(true) %} # Test gzip integrity gzip -t "$backup_file" if [ $? -eq 0 ]; then echo "โœ… {{ item.name }} backup integrity verified" else echo "โŒ {{ item.name }} backup corrupted" exit 1 fi {% else %} # Check if file is not empty and contains SQL if [ -s "$backup_file" ] && head -1 "$backup_file" | grep -q "SQL\|PostgreSQL\|MySQL"; then echo "โœ… {{ item.name }} backup integrity verified" else echo "โŒ {{ item.name }} backup appears invalid" exit 1 fi {% endif %} else echo "โŒ {{ item.name }} backup file not found" exit 1 fi register: backup_verification loop: "{{ current_databases }}" when: - verify_backups | default(true) | bool - item.container in (container_check.results | selectattr('stdout', 'equalto', item.container) | map(attribute='stdout') | list) - name: Clean up old backups shell: | echo "๐Ÿงน Cleaning up backups older than {{ backup_retention_days | default(30) }} days..." # Clean local backups find {{ backup_local_dir }}/{{ inventory_hostname }} -name "*.sql*" -mtime +{{ backup_retention_days | default(30) }} -delete # Clean permanent storage backups if [ -d "{{ backup_base_dir }}/{{ inventory_hostname }}" ]; then find {{ backup_base_dir }}/{{ inventory_hostname }} -name "*.sql*" -mtime +{{ backup_retention_days | default(30) }} -delete fi echo "โœ… Cleanup complete" when: backup_retention_days | default(30) | int > 0 - name: Generate backup report shell: | report_file="{{ backup_local_dir }}/{{ inventory_hostname }}/backup_report_{{ ansible_date_time.date }}.txt" echo "๐Ÿ“Š DATABASE BACKUP REPORT" > "$report_file" echo "=========================" >> "$report_file" echo "Host: {{ inventory_hostname }}" >> "$report_file" echo "Date: {{ ansible_date_time.iso8601 }}" >> "$report_file" echo "Type: {{ backup_type | default('incremental') }}" >> "$report_file" echo "Retention: {{ backup_retention_days | default(30) }} days" >> "$report_file" echo "" >> "$report_file" echo "๐Ÿ“ฆ BACKUP RESULTS:" >> "$report_file" {% for db in current_databases %} backup_file="{{ backup_local_dir }}/{{ inventory_hostname }}/{{ db.name }}_{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}.sql{% if compress_backups | default(true) %}.gz{% endif %}" if [ -f "$backup_file" ]; then size=$(du -h "$backup_file" | cut -f1) echo "โœ… {{ db.name }}: $size" >> "$report_file" else echo "โŒ {{ db.name }}: FAILED" >> "$report_file" fi {% endfor %} echo "" >> "$report_file" echo "๐Ÿ“ BACKUP LOCATIONS:" >> "$report_file" echo "Local: {{ backup_local_dir }}/{{ inventory_hostname }}" >> "$report_file" echo "Permanent: {{ backup_base_dir }}/{{ inventory_hostname }}" >> "$report_file" # Copy report to permanent storage if [ -d "{{ backup_base_dir }}/{{ inventory_hostname }}" ]; then cp "$report_file" "{{ backup_base_dir }}/{{ inventory_hostname }}/" fi cat "$report_file" register: backup_report - name: Display backup summary debug: msg: | โœ… DATABASE BACKUP COMPLETE =========================== ๐Ÿ–ฅ๏ธ Host: {{ inventory_hostname }} ๐Ÿ“… Date: {{ ansible_date_time.date }} ๐Ÿ“ฆ Databases: {{ current_databases | length }} ๐Ÿ”„ Type: {{ backup_type | default('incremental') }} {{ backup_report.stdout }} ๐Ÿ” Next Steps: - Verify backups: ls -la {{ backup_local_dir }}/{{ inventory_hostname }} - Test restore: ansible-playbook playbooks/restore_from_backup.yml - Schedule regular backups via cron ===========================