neverpanic.de

Backing Up Your Android Phone with borgbackup

(updated ) | Comments

Table of Contents
  1. What do I use on other systems?
  2. Borgbackuping Android
    1. Setup
      1. Installing packages
      2. Generating an SSH key
      3. Getting the required Android permissions
      4. Choosing a passphrase and creating the backup repository
    2. Writing the backup script
      1. Shebang
      2. Declarations
      3. Notification functions: cleanup & notify
      4. Logging helpers
      5. Checking whether the backup can be performed: prepare
      6. Backing up files
      7. Pruning old backups
      8. Getting the scheduling right
      9. Grabbing the wakelock and running the backup
      10. The full script
    3. Scheduling the backup script
  3. Seeing it in action
  4. Where to go from here

Since I last switched phones, I no longer have root access to my Android. On my previous phone, I was using Titanium Backup, which—because it does require root access—is no longer an option. After I bought my current phone, I started using Swift Backup to avoid loosing my apps, messages, call logs and Wi-Fi networks.

That means that I do not have a backup solution in place for data: should my phone break, I would no longer have a copy of the downloads, pictures, and videos. I have been procrastinating a solution for this problem for about 2.5 years now. Time to stop.

What do I use on other systems?

On my workstations, laptops, and servers, I have been using borgbackup. It’s reasonably fast, has deduplication (which means I can run frequent backups), and a nice backup retention system. Contrary to some other tools, such as duplicity, there is no need to periodically create a fresh full backup to get yearly, monthly, and weekly backup retention policies. Also, backups are encrypted and authenticated. This is not super important for me since I run my own borgbackup server and thus do not have a strong trust boundary, but it feels like a good standard precaution to take.

However, borgbackup is not a native Android app, so I had initially discarded the possibility to also use it on my phone. Prematurely, as it turns out.

A ticket in the borgbackup repository on GitHuub requests a port to Android. In the four years that the ticket was open, somebody ended up packaging borgbackup for Termux, an Android terminal emulator. This means that, theoretically, you can run pkg install borgbackup in Termux and then run borg in Termux to create your backup. Due to some of the features and limitations of the Android operating system, things are not quite as simple as that.

Borgbackuping Android

Andreas Erhard has written a German blog post on running borgbackup on Android in December that I initially followed. When I tried to replicate it, there were some setup steps missing, without which the backup does not work. I am writing my post to include those and document the changes I made.

Setup

I was about to run commands and write scripts on my Android phone, and I did not want to type them on the phone’s keyboard. The scrcpy project can forward the phone’s screen to a workstation if you have access using the Android Debug Bridge adb.

Installing packages

I started by installing the required packages. First, the Termux Android app (which I used from F-Droid because the release in the Google Play store comes with an outdated package repository configuration and thus does not work out of the box). I also installed Termux:API which contains helper utilities to allow scripts to determine, for example, whether the phone is charging.

Then I opened Termux and installed the rest of the required packages:

pkg upgrade
pkg install openssh     # for ssh-keygen
pkg install termux-api  # for termux-battery-status and other termux-* commands
pkg install vim         # because I used vim to write the backup script
pkg install borgbackup

Generating an SSH key

I use SSH to access my borgbackup server, so I created an SSH key pair on my phone. Ed25519 is a good choice as cryptosystem for SSH keys, so I used that:

ssh-keygen -t ed25519
A Termux terminal screen showing output of ssh-keygen -t ed25519, mirrored to a workstation using scrcpy

The generated public key then goes into ~/.ssh/authorized_keys on my backup server.

Getting the required Android permissions

Android’s permission system does not give applications access to storage by default. Termux comes with a helper script to request that. Running termux-setup-storage requests this permission—which you need to allow in the resulting pop-up.

An Android Files and Media permission prompt

I am using termux-wake-lock and termux-wake-unlock to prevent the phone from sleeping while the backup is running. On the first run, this also prompts for approval, so you should run the pair manually once:

termux-wake-lock    # prompts for permission
termux-wake-unlock  # release the lock again, we only need it while the backup is running

Choosing a passphrase and creating the backup repository

I encrypt my backups using a passphrase. In this case, I used xkcdpass to generate one. You must select the encryption type when creating the borg repository. The creation operation prompts for the passphrase.

HOST="borgbackup.location.example.com"
HOSTNAME="cplus7pro"  # the gethostname(3) function returns localhost, we cannot rely on it
TARGET="user@${HOST}:/mnt/backup/${HOSTNAME}"

borg init --encryption repokey "$TARGET"

If you want an append-only repository, you should pass --append-only here. Depending on your phone’s architecture, --encryption repokey-blake2 may be faster than the default of SHA256. See the output of borg init --help for more guidance.

Writing the backup script

Once the required setup steps are complete, you can write the backup script. I am including the script I use below with comments. Find the entire script at the end of this section.

Shebang

#!/data/data/com.termux/files/usr/bin/bash

The shebang line needs adjusting to work with Termux. You can use the termux-fix-shebang utility to do this after you wrote a normal #!/bin/bash shebang. I use bash, rather than sh, because I am using arrays.

Declarations

HOST="borgbackup.location.example.com"
HOSTNAME="cplus7pro"
TARGET="user@${HOST}:/mnt/backup/${HOSTNAME}"

export BORG_PASSPHRASE="<your-chosen-passphrase>"

declare -a TERMUX_NOTIFICATIONS=()
TERMUX_NOTIFICATION_ID="borgbackup-${HOSTNAME}"

set -o pipefail

First, I declare the backup target. I keep the $HOST variable separately because an invocation of ping uses it before attempting the backup to determine whether the server is available. The gethostname(3) function returns “localhost” in Termux, so rather than using the {hostname} format pattern when creating new backups (as I do on other systems), I have to define the name of my system manually.

Exporting the BORG_PASSPHRASE environment variable causes borg to no longer ask for the passphrase when performing backup operations.

The TERMUX_NOTIFICATIONS and TERMUX_NOTIFICATION_ID variables group and provide tracking for clean up of notifications that my backup script sends using the termux-notification utility.

Finally, set -o pipefail makes the entire command fail if one of the commands in a pipe fails.

Notification functions: cleanup & notify

Next, I define two functions: one to send Android notifications and another one for cleanup at the end of the script.

cleanup() {
  for notification in "${TERMUX_NOTIFICATIONS[@]}"; do
    termux-notification-remove "$notification"
  done
  termux-wake-unlock
}

The script appends IDs for notifications that I do not want to keep after the end of the script to TERMUX_NOTIFICATIONS. When cleanup runs, it removes any notifications in this variable. Any wakelocks that the script might have held are also released. This function is going to be registered as trap exit handler using trap "cleanup" EXIT.

##
# Send a notification to the user.
#
# Usage: echo "message" | notify persist identifier [options...]
#
# If persist is 0, the notification will be removed when the script exits.
# Otherwise, it will be kept (e.g. for warning or error messages).
#
# The identifier can be used to overwrite a previous notification with the same
# identifier. This can be useful for progress messages.
#
# Further options are those supported by termux-notification. The message must
# be passed on stdin.
notify() {
  local persist=$1
  shift
  local id="$1"
  shift
  local -a args=("--group" "${TERMUX_NOTIFICATION_ID}" "--id" "$id")

  if termux-notification "${args[@]}" "$@"; then
    if [ "$persist" -eq 0 ]; then
      TERMUX_NOTIFICATIONS+=("$id")
    fi
  fi
}

A helper function that sends Android notifications using termux-notification. Depending on the persist parameter, the notifications are automatically removed when cleanup runs.

Logging helpers

msg() {
  echo "***" "$@"
}

info() {
  msg "INFO:" "$@"
  termux-toast -s "$*"
}

warn() {
  msg "WARN:" "$@"
  echo "Warning:" "$@" | \
    notify 1 failure \
      --title "borgbackup" \
      --alert-once \
      --priority low
}

err() {
  msg "ERROR:" "$@"
  echo "Error:" "$@" | \
    notify 1 failure \
      --title "borgbackup" \
      --alert-once \
      --priority high
  exit 1
}

More helper functions deal with status messages by log level. A toast (emitted using termux-toast) displays informational messages. Warnings and errors become persistent notifications and are available after the exit of the script.

Checking whether the backup can be performed: prepare

prepare() {
  if ! termux-battery-status | grep "status" | grep -qE '"(CHARGING|FULL)"'; then
    warn "Not charging, not performing backup"
    return 1
  fi
  if ! termux-wifi-connectioninfo | grep "supplicant_state" | grep -q "COMPLETED"; then
    warn "WiFi not connected, not performing backup"
    return 1
  fi
  if ! ping -w 10 -c 3 "$HOST" >/dev/null; then
    warn "Failed to ping target $HOST"
    return 1
  fi
}

This function checks whether a backup should run and is called before the backup. Using the termux-battery-status and termux-wifi-connectioninfo utilities it verifies that the phone is either charging or full, and that it’s connected to Wi-Fi. The output of termux-wifi-connectioninfo has fields for the Wi-Fi SSID, but those end up hidden for me, probably because Android now requires permissions (quite possibly the location permissions) before it tells you the name of the Wi-Fi to which you are connected.

At the end of this function, I run a ping test to my backup host. If it does not answer, it’s either down and the backup would fail anyway, or the phone is not in the correct network.

Backing up files

backup() {
  local -a flags=()

  # enable interactive output
  if [ -t 0 ] && [ -t 1 ]; then
    flags+=('--stats' '--progress' '--list')
  fi

  info "Starting backup"
  ionice -c 3 \
    nice -n20 \
    borg create \
    --noatime \
    --compression='lz4' \
    --exclude-caches \
    --exclude='fm:/storage/emulated/0/*/.thumbnails' \
    --exclude='pp:/storage/emulated/0/Android/data' \
    --exclude='pp:/storage/emulated/0/Android/obb' \
    "${flags[@]}" \
    "${TARGET}::${HOSTNAME}-{utcnow:%Y-%m-%dT%H:%M:%S}" \
    /storage/emulated/0/ \
    /data/data/com.termux/files/home
}

This function creates a new backup snapshot. I used the -t test for the stdin and stdout file descriptors to detect whether a user ran the script interactively to increase debug output. If you do not want massive amounts of output while testing, you may want to remove this, or run the script with stdin connected to /dev/null.

I backup the entire SD card, which Android mounts at /storage/emulated/0. termux-setup-storage creates symbolic links in ~/storage that point to specific subfolders of this path, but I want all data to be part of the backup, not just these subfolders. I added the location of the script itself and all its configuration in /data/data/com.termux/files/home.

My photo gallery stores generated thumbnails in folders named .thumbnails, which I do not want in the backups. The Android/data and Android/obb subfolders are not accessible for Termux, so I exclude them as well.

Pruning old backups

prune() {
  local -a flags=()

  # enable interactive output
  if [ -t 0 ] && [ -t 1 ]; then
    flags+=('--stats' '--list')
  fi

  info "Pruning old backups..."
  borg prune \
    --prefix="${HOSTNAME}-" \
    --keep-within=14d \
    --keep-daily=31 \
    --keep-weekly=$((6 * 4)) \
    --keep-monthly=$(( 2 * 12 )) \
    "${flags[@]}" \
    "${TARGET}"
}

As the last of the functions, this removes old backups that are no longer required. This is also where you could configure your personal preferences for retention. In my case, I keep all backups in the last two weeks, one daily backup for 31 days, weekly backups for about 6 months, and monthly backups for two years.

Getting the scheduling right

On Android, there is no cron daemon that you could use to invoke this script automatically. You could install a cron daemon in Termux (and I have read articles online where people have done that), but I remain sceptical whether Android’s power management features would keep the cron daemon in the Termux running.

Instead, Termux comes with termux-job-scheduler, which can run periodic jobs. termux-job-scheduler’s help output tells us about its features:

Usage: termux-job-scheduler [options]
Schedule a script to run at specified intervals.
  -p/--pending               list pending jobs and exit
  --cancel-all               cancel all pending jobs and exit
  --cancel                   cancel given job-id and exit
Options for scheduling:
  -s/--script path           path to the script to be called
  --job-id int               job id (will overwrite any previous job with the same id)
  --period-ms int            schedule job approximately every period-ms milliseconds (default 0 means once).
                             Note that since Android N, the minimum period is 900,000ms (15 minutes).
  --network text             run only when this type of network available (default any): any|unmetered|cellular|not_roaming|none
  --battery-not-low boolean  run only when battery is not low, default true (at least Android O)
  --storage-not-low boolean  run only when storage is not low, default false (at least Android O)
  --charging boolean         run only when charging, default false
  --persisted boolean        should the job survive reboots, default false
  --trigger-content-uri text (at least Android N)
  --trigger-content-flag int default 1, (at least Android N)

Note that while it can theoretically run a job daily, it can not run it at a specific time of day, for example, each night at 4 AM, where my phone is probably charging and has Wi-Fi.

As a workaround, I run the script every 30 minutes, but add checks to the script itself so that (a) it does not run before 4 AM, and (b) it runs once per day. As a nice side effect, the phone re-attempts a backup every 30 minutes in case the conditions were not fulfilled at 4 AM:

# Run once per day, unless BORGBACKUP_FORCE=1
MARKER_FILE=~/.borgbackup-"${HOSTNAME}-$(date +%Y-%m-%d)"
if [ "${BORGBACKUP_FORCE:-0}" -eq 0 ]; then
  if [ "$(date +%H)" -lt 4 ]; then
    echo "Backup not yet due, waiting..."
    exit 0
  elif [ -f "$MARKER_FILE" ]; then
    echo "Backup already ran today"
    exit 0
  fi
fi

if ! prepare; then
  info "Server connectivity or charging status does not meet expectations, skipping backup."
  exit 1
fi
rm -f ~/.borgbackup-"${HOSTNAME}"-*
touch "$MARKER_FILE"

Grabbing the wakelock and running the backup

What remains is invoking the defined functions in the right order. Registering the cleanup function as an exit handler ensures that any non-persistent notifications sent get cleaned up, and any held wakelocks are released when the script exits, whether it succeeded or failed.

Then, I grab a wakelock using termux-wake-lock to prevent Android from killing my process while it’s performing a backup, display a notification that the backup is running (which is not persistent and is going to be removed when the script exits), and then run backup and prune.

trap "cleanup" EXIT
termux-wake-lock
notify 0 progress \
  --alert-once \
  --ongoing \
  --priority low \
  --title "borgbackup" \
  --content "Running backup for ${HOSTNAME}"

if ! backup; then
  err "Backup failed, aborting!"
fi
if ! prune; then
  warn "Pruning failed. Continuing anyway."
fi

info "Backup finished successfully"

The full script

As promised, this section contains the full script without interruptions:

#!/data/data/com.termux/files/usr/bin/bash

HOST="borgbackup.location.example.com"
HOSTNAME="cplus7pro"
TARGET="user@${HOST}:/mnt/backup/${HOSTNAME}"

export BORG_PASSPHRASE="<your-chosen-passphrase>"

declare -a TERMUX_NOTIFICATIONS=()
TERMUX_NOTIFICATION_ID="borgbackup-${HOSTNAME}"

set -o pipefail

cleanup() {
  for notification in "${TERMUX_NOTIFICATIONS[@]}"; do
    termux-notification-remove "$notification"
  done
  termux-wake-unlock
}

##
# Send a notification to the user.
#
# Usage: echo "message" | notify persist identifier [options...]
#
# If persist is 0, the notification will be removed when the script exits.
# Otherwise, it will be kept (e.g. for warning or error messages).
#
# The identifier can be used to overwrite a previous notification with the same
# identifier. This can be useful for progress messages.
#
# Further options are those supported by termux-notification. The message must
# be passed on stdin.
notify() {
  local persist=$1
  shift
  local id="$1"
  shift
  local -a args=("--group" "${TERMUX_NOTIFICATION_ID}" "--id" "$id")

  if termux-notification "${args[@]}" "$@"; then
    if [ "$persist" -eq 0 ]; then
      TERMUX_NOTIFICATIONS+=("$id")
    fi
  fi
}

msg() {
  echo "***" "$@"
}

info() {
  msg "INFO:" "$@"
  termux-toast -s "$*"
}

warn() {
  msg "WARN:" "$@"
  echo "Warning:" "$@" | \
    notify 1 failure \
      --title "borgbackup" \
      --alert-once \
      --priority low
}

err() {
  msg "ERROR:" "$@"
  echo "Error:" "$@" | \
    notify 1 failure \
      --title "borgbackup" \
      --alert-once \
      --priority high
  exit 1
}

prepare() {
  if ! termux-battery-status | grep "status" | grep -qE '"(CHARGING|FULL)"'; then
    warn "Not charging, not performing backup"
    return 1
  fi
  if ! termux-wifi-connectioninfo | grep "supplicant_state" | grep -q "COMPLETED"; then
    warn "WiFi not connected, not performing backup"
    return 1
  fi
  if ! ping -w 10 -c 3 "$HOST" >/dev/null; then
    warn "Failed to ping target $HOST"
    return 1
  fi
}

backup() {
  local -a flags=()

  # enable interactive output
  if [ -t 0 ] && [ -t 1 ]; then
    flags+=('--stats' '--progress' '--list')
  fi

  info "Starting backup"
  ionice -c 3 \
    nice -n20 \
    borg create \
    --noatime \
    --compression='lz4' \
    --exclude-caches \
    --exclude='fm:/storage/emulated/0/*/.thumbnails' \
    --exclude='pp:/storage/emulated/0/Android/data' \
    --exclude='pp:/storage/emulated/0/Android/obb' \
    "${flags[@]}" \
    "${TARGET}::${HOSTNAME}-{utcnow:%Y-%m-%dT%H:%M:%S}" \
    /storage/emulated/0/ \
    /data/data/com.termux/files/home
}

prune() {
  local -a flags=()

  # enable interactive output
  if [ -t 0 ] && [ -t 1 ]; then
    flags+=('--stats' '--list')
  fi

  info "Pruning old backups..."
  borg prune \
    --prefix="${HOSTNAME}-" \
    --keep-within=14d \
    --keep-daily=31 \
    --keep-weekly=$((6 * 4)) \
    --keep-monthly=$(( 2 * 12 )) \
    "${flags[@]}" \
    "${TARGET}"
}

# Run once per day, unless BORGBACKUP_FORCE=1
MARKER_FILE=~/.borgbackup-"${HOSTNAME}-$(date +%Y-%m-%d)"
if [ "${BORGBACKUP_FORCE:-0}" -eq 0 ]; then
  if [ "$(date +%H)" -lt 4 ]; then
    echo "Backup not yet due, waiting..."
    exit 0
  elif [ -f "$MARKER_FILE" ]; then
    echo "Backup already ran today"
    exit 0
  fi
fi

if ! prepare; then
  info "Server connectivity or charging status does not meet expectations, skipping backup."
  exit 1
fi
rm -f ~/.borgbackup-"${HOSTNAME}"-*
touch "$MARKER_FILE"

trap "cleanup" EXIT
termux-wake-lock
notify 0 progress \
  --alert-once \
  --ongoing \
  --priority low \
  --title "borgbackup" \
  --content "Running backup for ${HOSTNAME}"

if ! backup; then
  err "Backup failed, aborting!"
fi
if ! prune; then
  warn "Pruning failed. Continuing anyway."
fi

info "Backup finished successfully"

Scheduling the backup script

We can now run the backup script manually to test whether it works, but it’s not yet run automatically. To do that, we must invoke termux-job-scheduler:

termux-job-scheduler \
  --script ~/bin/borg-backup-to-nas \
  --period-ms 1800000 \
  --network unmetered \
  --persisted true

Here, I have chosen

  • a frequency of 1.8 million milliseconds, that is 1800 seconds or 30 minutes
  • unmetered network (the script would not perform a backup anyway otherwise)
  • keeping the job across a phone reboot (--persisted true)

You can verify that the scheduler has accepted the job using termux-job-scheduler -p.

Seeing it in action

Seeing is believing, so I recoded the backup run. See also the impressive deduplication statistics at the end that show why I like doing by backups with borg:

A new incremental backup takes about one and a half minutes for the 26 GB of data on my phone’s SD card. With minimal changes, a new backup uses just 140 kB of disk space on the backup server. This makes new backups extraordinarily cheap, so that there is no good reason not to do them often.

Where to go from here

This solution works for me. If you want to replicate this setup, keep in mind that this backup does not include call logs, messages, Wi-Fi networks, or apps. You may want to use an separate app (such as Swift Backup) to write this data to a folder on your SD card, which would include them in the borgbackup run.

I also do not yet have long term test data on how stable the solution is, and how often Android’s power saving features interrupt my backup. I may update this post later with results.

Update: I have been running this setup for just shy of two weeks now, and have yet to encounter issues with it. When not at home, the backup is re-attempted every 30 minutes. If there was an issue, I would receive a notification, which means the process is silent and low on maintenance.

If you try this on your own, I would welcome feedback—feel free to use the comments below to leave your experience or any suggestions for improvement you might have.

Comments

Please enable JavaScript to view comments. Note that comments are provided by a local instance of Isso. No data is sent to third party servers. For more information, see the privacy policy.