--- # ============================================================================ # backup-homarr.yml — snapshot Homarr appdata + compose, upload to Cloudflare R2 # ============================================================================ # Weekly (Sunday night) backup. Produces: homarr--MM-DD-YY.tar # and uploads it to the R2 bucket (pbs-offsite) under homarr/. # # Consistency: the container is STOPPED for the tar (Homarr uses sqlite in # appdata; hot-copying a live sqlite file risks a corrupt backup). Downtime is # only the few seconds it takes to tar, then it's restarted + health-checked. # # Secrets (pulled from Infisical at runtime, never hardcoded): # SSH_PRIVATE_KEY - deploy key (root /) to reach the ansible user # R2_ACCESS_KEY_ID - R2 S3 access key (path /homarr) # R2_SECRET_ACCESS_KEY - R2 S3 secret (path /homarr) # R2_BUCKET - e.g. pbs-offsite (path /homarr) # R2_ENDPOINT - R2 S3 endpoint URL (path /homarr) # # Usage: playbook playbooks/backup-homarr.yml, extra vars: target=racknerd1 # ============================================================================ - name: "PRECHECK — fetch secrets from Infisical" hosts: localhost gather_facts: false vars: infisical_url: "{{ lookup('env','INFISICAL_URL') | default('http://192.168.122.41:8080', true) }}" infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default('dev', true) }}" tasks: - name: "[precheck] Infisical auth vars present?" ansible.builtin.assert: that: - lookup('env','INFISICAL_CLIENT_ID') | length > 0 - lookup('env','INFISICAL_CLIENT_SECRET') | length > 0 - lookup('env','INFISICAL_PROJECT_ID') | length > 0 fail_msg: "Missing INFISICAL_CLIENT_ID / _SECRET / _PROJECT_ID in Semaphore Environment." - name: "[precheck] Login to Infisical" infisical.vault.login: url: "{{ infisical_url }}" auth_method: universal_auth universal_auth_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') }}" universal_auth_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') }}" register: infisical_login - name: "[precheck] Read SSH key from root path" infisical.vault.read_secrets: login_data: "{{ infisical_login.login_data }}" project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}" env_slug: "{{ infisical_env_slug }}" path: "/" as_dict: true register: infisical_root - name: "[precheck] Read R2 creds from /homarr path (prod)" infisical.vault.read_secrets: login_data: "{{ infisical_login.login_data }}" project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}" env_slug: "prod" path: "/homarr" as_dict: true register: infisical_homarr - name: "[precheck] Validate required secrets present" ansible.builtin.assert: that: - infisical_root.secrets.SSH_PRIVATE_KEY is defined - infisical_homarr.secrets.R2_ACCESS_KEY_ID is defined - infisical_homarr.secrets.R2_SECRET_ACCESS_KEY is defined - infisical_homarr.secrets.R2_BUCKET is defined - infisical_homarr.secrets.R2_ENDPOINT is defined fail_msg: "Missing SSH_PRIVATE_KEY (root /) or R2_* secrets (prod /homarr)." - name: "[precheck] stash secrets" ansible.builtin.set_fact: pc_ssh_privkey: "{{ infisical_root.secrets.SSH_PRIVATE_KEY }}" pc_r2_akid: "{{ infisical_homarr.secrets.R2_ACCESS_KEY_ID }}" pc_r2_sak: "{{ infisical_homarr.secrets.R2_SECRET_ACCESS_KEY }}" pc_r2_bucket: "{{ infisical_homarr.secrets.R2_BUCKET }}" pc_r2_endpoint: "{{ infisical_homarr.secrets.R2_ENDPOINT }}" no_log: true - name: "[precheck] write deploy private key to runner temp (0600)" ansible.builtin.copy: dest: "/tmp/homarr_deploy_key" content: "{{ pc_ssh_privkey }}\n" mode: "0600" no_log: true - name: "Backup Homarr + upload to R2 (as ansible user)" hosts: "{{ target | default('racknerd1') }}" gather_facts: true vars: deploy_user: ansible homarr_dir: "/home/{{ deploy_user }}/docker/homarr" homarr_port: 7575 backup_dir: "/home/{{ deploy_user }}/backups/homarr" keep_local: 2 # keep this many most-recent local tars r2_akid: "{{ hostvars['localhost'].pc_r2_akid }}" r2_sak: "{{ hostvars['localhost'].pc_r2_sak }}" r2_bucket: "{{ hostvars['localhost'].pc_r2_bucket }}" r2_endpoint: "{{ hostvars['localhost'].pc_r2_endpoint }}" ansible_user: ansible ansible_ssh_private_key_file: "/tmp/homarr_deploy_key" ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes" tasks: - name: Reset connection so docker group membership is active ansible.builtin.meta: reset_connection - name: Gather facts (after connection reset) ansible.builtin.setup: - name: Docker available? ansible.builtin.command: docker version register: dv changed_when: false failed_when: dv.rc != 0 - name: Compose stack present? ansible.builtin.stat: path: "{{ homarr_dir }}/docker-compose.yml" register: compose_file - name: Fail early if stack not deployed ansible.builtin.assert: that: [compose_file.stat.exists] fail_msg: "No docker-compose.yml at {{ homarr_dir }} — nothing to back up. Run deploy-homarr.yml first." - name: Compute backup filename homarr--MM-DD-YY.tar ansible.builtin.set_fact: backup_name: "homarr-{{ inventory_hostname }}-{{ '%m-%d-%y' | strftime(ansible_date_time.epoch | int) }}.tar" - name: Ensure backup dir exists ansible.builtin.file: path: "{{ backup_dir }}" state: directory owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0750" - name: Ensure rclone is installed (ROOTLESS — no sudo; installs to ~/.local/bin) block: - name: rclone present on PATH or in ~/.local/bin? ansible.builtin.shell: 'command -v rclone || command -v "$HOME/.local/bin/rclone"' register: rc_ver changed_when: false failed_when: false - name: Install rclone rootless (download static binary to ~/.local/bin) when: rc_ver.rc != 0 block: - name: Ensure ~/.local/bin exists ansible.builtin.file: path: "/home/{{ deploy_user }}/.local/bin" state: directory mode: "0755" - name: Download + unpack latest rclone linux-amd64 into ~/.local/bin (rootless, python unzip) ansible.builtin.shell: | set -e D="$(mktemp -d)"; cd "$D" curl -fsSL https://downloads.rclone.org/rclone-current-linux-amd64.zip -o rclone.zip python3 -c "import zipfile,glob,shutil,os,stat; z=zipfile.ZipFile('rclone.zip'); z.extractall('.'); b=glob.glob('rclone-*-linux-amd64/rclone')[0]; dst=os.path.expanduser('~/.local/bin/rclone'); shutil.copy(b,dst); os.chmod(dst, 0o755)" changed_when: true - name: Resolve rclone binary path (PATH or ~/.local/bin) ansible.builtin.shell: 'command -v rclone || echo "/home/{{ deploy_user }}/.local/bin/rclone"' register: rclone_bin_res changed_when: false - name: Set rclone binary fact ansible.builtin.set_fact: rclone_bin: "{{ rclone_bin_res.stdout_lines[0] }}" - name: "Stop Homarr (consistent sqlite snapshot)" community.docker.docker_compose_v2: project_src: "{{ homarr_dir }}" state: stopped - name: Create tar (compose + appdata) ansible.builtin.command: cmd: tar -cf "{{ backup_dir }}/{{ backup_name }}" -C "{{ homarr_dir }}" docker-compose.yml appdata register: tarres changed_when: true - name: "Restart Homarr immediately" community.docker.docker_compose_v2: project_src: "{{ homarr_dir }}" state: present - name: Wait for Homarr healthy ansible.builtin.uri: url: "http://127.0.0.1:{{ homarr_port }}/" status_code: [200, 302, 307] register: health retries: 15 delay: 5 until: health.status is defined and health.status in [200, 302, 307] - name: Backup size ansible.builtin.stat: path: "{{ backup_dir }}/{{ backup_name }}" register: bkstat - name: Configure rclone R2 remote (env, this play only) ansible.builtin.set_fact: rclone_env: RCLONE_CONFIG_R2_TYPE: s3 RCLONE_CONFIG_R2_PROVIDER: Cloudflare RCLONE_CONFIG_R2_ACCESS_KEY_ID: "{{ r2_akid }}" RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: "{{ r2_sak }}" RCLONE_CONFIG_R2_ENDPOINT: "{{ r2_endpoint }}" RCLONE_CONFIG_R2_ACL: private no_log: true - name: Upload tar to R2 (r2:/homarr/) ansible.builtin.command: cmd: "{{ rclone_bin }} copyto \"{{ backup_dir }}/{{ backup_name }}\" \"r2:{{ r2_bucket }}/homarr/{{ backup_name }}\" --s3-no-check-bucket" environment: "{{ rclone_env }}" register: upload changed_when: true no_log: true - name: Verify object exists in R2 ansible.builtin.command: cmd: "{{ rclone_bin }} lsf \"r2:{{ r2_bucket }}/homarr/{{ backup_name }}\"" environment: "{{ rclone_env }}" register: verify changed_when: false failed_when: backup_name not in verify.stdout - name: List existing Homarr backups in R2 for this host ansible.builtin.command: cmd: "{{ rclone_bin }} lsf \"r2:{{ r2_bucket }}/homarr/\"" environment: "{{ rclone_env }}" register: r2_listing changed_when: false - name: Delete OLDER R2 backups for this host (keep only the one just uploaded) ansible.builtin.command: cmd: "{{ rclone_bin }} deletefile \"r2:{{ r2_bucket }}/homarr/{{ item }}\"" environment: "{{ rclone_env }}" loop: "{{ r2_listing.stdout_lines }}" when: - item is match('homarr-' ~ inventory_hostname ~ '-.*\.tar$') - item != backup_name changed_when: true - name: Prune old LOCAL tars (keep {{ keep_local }} newest) ansible.builtin.shell: >- ls -1t "{{ backup_dir }}"/homarr-*.tar 2>/dev/null | tail -n +{{ keep_local + 1 }} | xargs -r rm -f changed_when: true failed_when: false - name: Result ansible.builtin.debug: msg: >- Backed up {{ backup_name }} ({{ (bkstat.stat.size / 1048576) | round(1) }} MB) -> r2:{{ r2_bucket }}/homarr/ (older versions for {{ inventory_hostname }} deleted) ; Homarr healthy (HTTP {{ health.status }}). - name: "Cleanup runner temp key" hosts: localhost gather_facts: false tasks: - name: remove temp deploy key ansible.builtin.file: path: /tmp/homarr_deploy_key state: absent