Automated Linux Web Server Backup Script to Google Drive

A bash script that backs up MySQL/MariaDB databases and website files from a Linux web server, packages them into a compressed archive, and uploads it to Google Drive via cron on a set schedule. Supports encryption, configurable retention, failure notifications, and upload retry. No backup service required. A Google account is all you need.

Why You Might Need This Script

Most hosting control panels offer some form of automated backup, but they vary widely in reliability, retention control, and whether you can actually get your data back in a usable form when you need it. If you manage your own Linux server, such as a VPS running one or more websites, this script gives you a straightforward, self-contained backup process with no third-party backup service required.

It is particularly suited to self-managed websites where you want:

What It Does

Each run of the script:

  1. Optionally copies specified filesystem paths into a staging directory.
  2. Dumps one or all MySQL databases using credentials stored in a config file.
  3. Packages everything into a compressed .tar.gz archive.
  4. Optionally encrypts the archive with AES-256-CBC.
  5. Uploads the archive to a Google Drive folder, with automatic retry on failure.
  6. Deletes old backups from Drive according to your retention setting.
  7. Sends a failure notification by e-mail or webhook if any step fails.

On a successful run the local archive is removed after upload. On failure it is preserved for manual retry.

Requirements

NOTE: This script works with any fork of the gdrive Google Drive CLI, but it depends on the output format of gdrive list. Specifically, list results must be space-delimited with the file/folder ID in the first column and the name in the second. Verify this matches your version before deploying. The CLI must be authenticated before the script can run.

Setup

Step 1: Install Dependencies

Ensure mysqldump, gdrive, openssl, and flock are available in PATH.

NOTE: Authenticating gdrive requires creating a Google Cloud project and OAuth credentials. This is the most involved part of the setup.

Step 2: Create .mysqldump.cnf

Store your database credentials in a [client] format file:

[client]
user=dbuser
password=dbpass

Set permissions to 600: chmod 600 .mysqldump.cnf

Step 3: Create .backup.cnf

Required if using encryption (-e); not needed otherwise. Add your encryption passphrase:

pass=your-passphrase-here

Set permissions to 600: chmod 600 .backup.cnf

Both files must be in the same directory as the script.

Step 4: Create backup.sh

Copy the source code below into a file named backup.sh in the same directory as the config files, then make it executable:

chmod 700 backup.sh

Example Directory Structure

~/ (home directory)
└── scripts/
    ├── .backup.cnf
    ├── .mysqldump.cnf
    └── backup.sh

Source Code

#!/bin/bash
#
# Automated Linux Web Server Backup to Google Drive
# Dumps MySQL, optionally backs up paths, archives, encrypts,
# uploads to Google Drive, and enforces retention policy.
#
# Version: 1.0.0
#
# Requirements
# ------------
# OS:           Linux (GNU/Linux).  The following are Linux-specific and will
#               fail or behave incorrectly on macOS or BSD:
#                 - stat -c '%a'         (GNU stat; macOS uses stat -f '%A')
#                 - cp --parents         (GNU coreutils; not available on macOS)
#                 - flock                (util-linux; not available on
#                                          macOS without brew)
#                 - tar --warning=...    (GNU tar flag; ignored or
#                                          fatal on BSD tar)
#
# Shell:        bash 4.0+.  Requires associative arrays, [[ ]], and process
#               substitution.  The #!/bin/bash shebang must resolve to bash 4+;
#               macOS ships bash 3.2 at /bin/bash due to licensing.
#
# Required tools (must be in PATH):
#               mysqldump   — MySQL/MariaDB client package
#               gdrive      — Google Drive CLI (any fork).  This script
#                             depends on the following subcommands and output
#                             format; verify compatibility before use:
#                               gdrive list   — space-delimited output with
#                                               file/folder ID in $1, name in $2
#                               gdrive upload FILE --parent FOLDER_ID
#                               gdrive mkdir  FOLDER_NAME
#                               gdrive delete FILE_ID
#                             Must be authenticated before use.
#               openssl     — for encryption (-e flag); standard on all Linux
#                             distributions and always present on a server with
#                             TLS configured
#               flock       — for concurrency lock; part of util-linux
#
# Optional tools (needed only if the corresponding feature is used):
#               mail        — for email notification (-N flag)
#               curl        — for webhook notification (-w flag)
#
# Config files (must exist in the same directory as this script):
#               .backup.cnf      — key=value file; requires
#                                  pass=KEY if using -e
#               .mysqldump.cnf   — MySQL credentials in [client] format:
#                                    [client]
#                                    user=dbuser
#                                    password=dbpass
#               Both files should be chmod 600 or 400.
#
# Tested on:    Ubuntu 20.04+, Debian 11+.  Other GNU/Linux distributions with
#               bash 4+ and GNU coreutils are expected to work.
#
# Example cron entry (2 AM daily; full encrypted backup with
# email notification):
#   0 2 * * * /home/admin/backup.sh -f -e -P /var/www \
#     -N ops@example.com >> /var/log/backup.log 2>&1
#

# -e: exit on error
# -u: error on unset variable
# -o pipefail: pipe fails if any stage fails
set -euo pipefail

############################################
# Utility functions
############################################

# Read a key=value config file.  Handles values containing '=' (e.g., base64
# keys) by cutting only on the first delimiter.
getConfigVal() {
  grep -m1 "^${1}=" "$2" | cut -d= -f2-
}

log() {
  echo "[$(date +'%F %T')] $*"
}

# Send a failure notification if a notify address or webhook was configured.
# Called with the failure message as its sole argument; the subject line is
# constructed internally from the backup prefix and hostname.
# Both channels are attempted independently so a broken mail setup does not
# prevent a webhook from firing, and vice versa.
notify() {
  local msg="$1"
  local subject="[backup] FAILED: ${filename_prefix_backup} on ${_hostname}"

  if [ -n "${notify_email:-}" ]; then
    if command -v mail > /dev/null 2>&1; then
      echo "$msg" | mail -s "$subject" "$notify_email" \
        || log "WARNING: Failed to send notification email to ${notify_email}."
    else
      log "WARNING: -N email specified but 'mail' command not found."
      log "WARNING: Notification not sent."
    fi
  fi

  if [ -n "${notify_webhook:-}" ]; then
    if command -v curl > /dev/null 2>&1; then
      # Build a JSON-safe payload by escaping backslashes and double quotes
      # in the message text.  This prevents malformed JSON when paths or
      # hostnames contain those characters.
      # Payload format {"text":"..."} is compatible with Slack, ntfy, and
      # most webhook receivers.  Adjust for other services as needed.
      local full_msg="${subject}: ${msg}"
      full_msg="${full_msg//\\/\\\\}"   # escape backslashes first
      full_msg="${full_msg//\"/\\\"}"   # then escape double quotes
      curl -s -o /dev/null -w "%{http_code}" \
        -X POST -H "Content-Type: application/json" \
        -d "{\"text\": \"${full_msg}\"}" \
        "$notify_webhook" | grep -qE '^2' \
        || log "WARNING: Webhook POST to ${notify_webhook} returned"\
             " a non-2xx response."
    else
      log "WARNING: -w webhook specified but 'curl' not found."
      log "WARNING: Notification not sent."
    fi
  fi
}

# Upload to Google Drive with exponential backoff retry.
# Usage: gdrive_upload_with_retry FILE FOLDER_ID
# Returns 0 on success, 1 if all attempts fail.
# Sets _upload_attempts_made (script-scoped) to the number of attempts used,
# so callers can include the count in failure messages.
# Logs a warning after each failed non-final attempt.
gdrive_upload_with_retry() {
  local file="$1"
  local parent="$2"
  local max_attempts=3
  local attempt=1
  local wait=10  # seconds before first retry; doubles each attempt

  # Reset before loop; written to script scope for caller access.
  _upload_attempts_made=0
  while [ "$attempt" -le "$max_attempts" ]; do
    _upload_attempts_made="$attempt"
    if "$path_gdrive" upload "$file" --parent "$parent"; then
      return 0
    fi
    if [ "$attempt" -lt "$max_attempts" ]; then
      local _msg="WARNING: Upload attempt ${attempt}/${max_attempts} failed."
      log "$_msg Retrying in ${wait}s."
      sleep "$wait"
      wait=$(( wait * 2 ))
    fi
    attempt=$(( attempt + 1 ))
  done

  return 1
}

cleanup() {
  # Release the lock fd explicitly (flock also releases on fd close at exit).
  exec 9>&- 2>/dev/null || true
  # Remove the lock file so it does not accumulate in $HOME across runs.
  rm -f "${lock_file:-}" 2>/dev/null || true
  # Guard against path_backup being empty before passing to rm -rf.
  [ -n "$path_backup" ] && rm -rf "$path_backup"
  # Remove any intermediate/output files that were not successfully renamed or
  # moved.  Variables are cleared by the script as files are promoted so that
  # the cleanup trap only removes files that are genuinely leftover.
  rm -f "${backup_file:-}" 2>/dev/null || true
  rm -f "${enc_file:-}"    2>/dev/null || true
  # On upload failure final_file is cleared so the local copy is preserved for
  # manual retry.  On success the trap removes it, which is correct since the
  # file has already been uploaded to Drive.
  rm -f "${final_file:-}"  2>/dev/null || true
}
# Registered here: functions above are defined, variables below
# are initialised as empty.
trap cleanup EXIT

############################################
# Defaults
############################################

# Temp working dir under $HOME; removed on exit by cleanup.
dirname_temp_backup="automated_backup"
filename_prefix_backup="backup"
filename_suffix_backup="_$(date +'%Y_%m_%d_%H_%M')"
db_args=(--all-databases)
gdrive_backup_dirname="backup"
num_files_to_retain=30
extra_paths=()      # array for additional paths
exclude_patterns=() # array for exclude patterns
notify_email=""     # set via -N; empty means no email notification
notify_webhook=""   # set via -w; empty means no webhook notification

path_script="$(dirname "$(readlink -f "$0")")"
# Use || true so set -e does not abort here on a missing binary; the validation
# block below emits a clear error for each missing tool.
path_mysqldump="$(command -v mysqldump 2>/dev/null || true)"
path_gdrive="$(command -v gdrive    2>/dev/null || true)"
path_backup_cnf="${path_script}/.backup.cnf"
path_mysqldump_cnf="${path_script}/.mysqldump.cnf"
# path_backup depends on $HOME; assigned after $HOME is validated below.
path_backup=""

# Declare all intermediate/output filenames early so the cleanup trap always
# has defined variables regardless of how early the script exits.
backup_file=""
enc_file=""
final_file=""
lock_file=""

# Initialise flags and key here so set -u does not complain if -e or -f
# are never passed.
encryption_key=""
bool_encrypt_backup=""
bool_full_backup=""

# Cache hostname once at startup — notify() may be called at multiple failure
# points and forking a subshell each time is unnecessary.
# _upload_attempts_made is initialised here so set -u does not complain if
# gdrive_upload_with_retry is never called
# (e.g., the script fails before upload).
_hostname="$(hostname)"
_upload_attempts_made=0

############################################
# Parse options
############################################

while getopts "hefn:d:p:m:P:E:N:w:" flag; do
  case "${flag}" in
    h)
      cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Assembles files and database dumps in a .tar.gz and uploads to Google Drive.

Options:
  -h            Show this help message and exit.
  -f            Full backup (include DB + paths).
  -e            Encrypt backup with AES-256-CBC (requires pass in .backup.cnf).
  -n NUM        Retain NUM backups on Google Drive (default: 30).
  -d DIR        Google Drive folder name (default: backup).
  -p PREFIX     Backup filename prefix (default: backup).
  -m DBNAME     Database name to backup (default: all databases).
  -P PATH       Extra path(s) to include. Repeat for multiple.
                Glob patterns are supported (e.g., /var/www/*.php).
                NOTE: glob patterns containing spaces in directory components
                are not supported.
  -E PATTERN    Exclude pattern(s). Repeat for multiple. Glob syntax.
  -N EMAIL      Send a failure notification email to EMAIL (requires 'mail').
  -w URL        POST a failure notification to a webhook URL (requires 'curl').
                Payload format: {"text": "..."} — compatible with Slack, ntfy,
                and most webhook receivers.

EOF
      exit 0
      ;;
    e)
      bool_encrypt_backup=1
      # Defer key read until after validation so a missing .backup.cnf produces
      # "Required file missing" rather than a confusing empty-key error.
      ;;
    f) bool_full_backup=1 ;;
    n)
      if [[ "${OPTARG}" =~ ^[0-9]+$ ]]; then
        num_files_to_retain="${OPTARG}"
      else
        _msg="WARNING: -n requires an integer."
        log "$_msg Using default (${num_files_to_retain})."
      fi
      ;;
    d) gdrive_backup_dirname="${OPTARG}" ;;
    # Strip newlines to prevent header injection via mail -s.
    p) filename_prefix_backup="${OPTARG//$'\n'/}" ;;
    m) db_args=(--databases "${OPTARG}") ;;
    P) extra_paths+=("${OPTARG}") ;;
    E) exclude_patterns+=("${OPTARG}") ;;
    N) notify_email="${OPTARG}" ;;
    w) notify_webhook="${OPTARG}" ;;
    ?)
      log "ERROR: Unknown option. Run $(basename "$0") -h for usage."
      exit 1
      ;;
  esac
done

############################################
# Preflight checks
############################################

# Verify HOME is set and writable before any file operations depend on it.
if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ] || [ ! -w "$HOME" ]; then
  log "ERROR: HOME ('${HOME:-<unset>}') is not a writable directory."
  exit 1
fi

# Assign path_backup here now that $HOME is confirmed valid.
path_backup="${HOME}/${dirname_temp_backup}"

############################################
# Concurrency lock
############################################

# Scope the lock to the backup prefix so independent prefix-based jobs do not
# block each other (e.g., two cron entries running backup_db and backup_files).
#
# Set the umask around exec so the fd-open creates the lock file at 600
# rather than inheriting the ambient umask.
lock_file="${HOME}/.backup_${filename_prefix_backup}.lock"
_old_umask=$(umask)
umask 177
exec 9>"$lock_file"
umask "$_old_umask"
unset _old_umask
if ! flock -n 9; then
  _msg="ERROR: Another instance of '${filename_prefix_backup}' backup"
  log "$_msg is already running."
  exit 1
fi

############################################
# Validation
############################################

if [ -z "$path_mysqldump" ]; then
  log "ERROR: 'mysqldump' not found in PATH."
  exit 1
fi

if [ -z "$path_gdrive" ]; then
  log "ERROR: 'gdrive' not found in PATH. Install it or add it to PATH."
  exit 1
fi

# Check all required paths exist.  path_mysqldump and path_gdrive are included
# here as a belt-and-suspenders catch; they were already checked
# for emptiness above.
for f in "$path_script" "$path_mysqldump" "$path_gdrive" \
         "$path_backup_cnf" "$path_mysqldump_cnf"; do
  if [ ! -e "$f" ]; then
    log "ERROR: Required file/path missing: $f"
    exit 1
  fi
done

# Warn if credential files are group- or world-readable.
# stat -c '%a' is GNU/Linux-specific; this script targets Linux deployments.
for cnf in "$path_backup_cnf" "$path_mysqldump_cnf"; do
  perms=$(stat -c '%a' "$cnf")
  if [[ "$perms" != "600" && "$perms" != "400" ]]; then
    log "WARNING: $cnf permissions are $perms. Recommended: 600 or 400."
  fi
done

# Now that .backup.cnf is confirmed to exist, read the encryption key.
if [ -n "$bool_encrypt_backup" ]; then
  encryption_key=$(getConfigVal "pass" "${path_backup_cnf}" || true)
  if [ -z "${encryption_key}" ]; then
    log "ERROR: Encryption key ('pass') not found in ${path_backup_cnf}"
    exit 1
  fi
fi

# Warn when filesystem paths are included without encryption.
if [ -n "$bool_full_backup" ] && [ "${#extra_paths[@]}" -gt 0 ] \
   && [ -z "$bool_encrypt_backup" ]; then
  log "WARNING: Full backup includes filesystem paths but"
  log "WARNING: encryption is not enabled (-e)."
fi

############################################
# Start backup
############################################

log "Starting backup"
log "Prefix:      ${filename_prefix_backup}"
log "Suffix:      ${filename_suffix_backup}"
log "DB args:     ${db_args[*]}"
log "Drive folder: ${gdrive_backup_dirname}"
log "Retention:   ${num_files_to_retain}"
if [ "${#extra_paths[@]}" -gt 0 ]; then
  for p in "${extra_paths[@]}"; do log "Extra path: $p"; done
else
  log "Extra paths: (none)"
fi
if [ "${#exclude_patterns[@]}" -gt 0 ]; then
  for p in "${exclude_patterns[@]}"; do log "Exclude pattern: $p"; done
else
  log "Exclude patterns: (none)"
fi

# -p: no error if already exists; safe on re-run after partial failure.
mkdir -p "$path_backup"

# ------------------ Filesystem backup ------------------
if [ -n "$bool_full_backup" ] && [ "${#extra_paths[@]}" -gt 0 ]; then
  for src in "${extra_paths[@]}"; do
    log "Including: $src"
    # Expand globs inside a subshell so nullglob is scoped and does not
    # affect the rest of the script.  Null-delimited printf output keeps
    # filenames with spaces intact through the pipe.
    #
    # Design note: $src is deliberately unquoted inside the subshell's for
    # loop to allow glob expansion (e.g., /var/www/*.php).  Paths with spaces
    # in directory components are not supported as glob patterns; supply them
    # as literal paths without wildcards if needed.
    while IFS= read -r -d '' f; do
      [ -e "$f" ] || continue

      # case is used for exclusion matching because it handles patterns with
      # slashes and leading ./ more reliably than [[ "$f" == $pat ]].
      skip=false
      for pat in "${exclude_patterns[@]}"; do
        case "$f" in
          $pat)
            log "Excluding: $f (matched $pat)"
            skip=true
            break
            ;;
        esac
      done
      [[ "$skip" == true ]] && continue

      # cp --parents reproduces the full absolute path under $path_backup
      # (e.g., /var/www/html/foo -> $path_backup/var/www/html/foo).
      # This preserves path context in the archive.
      cp --parents -a "$f" "$path_backup"
    done < <(
      ( shopt -s nullglob; for f in $src; do printf '%s\0' "$f"; done )
    )
  done
fi

# ------------------ Database dump ------------------
log "Dumping database(s)"
"$path_mysqldump" --defaults-extra-file="$path_mysqldump_cnf" \
  "${db_args[@]}" --no-tablespaces -ce > "$path_backup/mysqldump.sql"

# Abort if the dump file is empty — catches silent auth failures and
# permission errors.  A partial dump (some tables written, then failure)
# produces a non-empty file; mysqldump's non-zero exit under set -e handles
# that separately.
if [ ! -s "$path_backup/mysqldump.sql" ]; then
  msg="Database dump is empty. Check credentials and permissions."
  log "ERROR: $msg"
  notify "$msg"
  exit 1
fi

# ------------------ Archive ------------------
backup_file="${HOME}/${filename_prefix_backup}.tar.gz"
# --warning=no-file-changed suppresses the warning message and non-zero exit
# that tar produces when a file changes during archiving (e.g., active log
# files).  Remove this flag to let such changes abort the backup.
tar --warning=no-file-changed -czf "$backup_file" -C "$path_backup" .

# Restrict read access in case umask is permissive in cron.
chmod 600 "$backup_file"

# Verify the archive is readable before spending time on upload.
if ! tar -tzf "$backup_file" > /dev/null 2>&1; then
  msg="Archive integrity check failed. The .tar.gz may be corrupt."
  log "ERROR: $msg"
  notify "$msg"
  exit 1
fi

# ------------------ Encryption ------------------
if [ -n "$bool_encrypt_backup" ]; then
  enc_file="${backup_file%.tar.gz}_enc.tar.gz"
  # Pass the key via a process substitution (anonymous pipe) rather than a
  # herestring or command-line argument.  A herestring may create a temp file
  # in /tmp on older kernels; a command-line argument is visible in ps output.
  # The process substitution approach is reliable and leaves nothing on disk.
  openssl enc -aes-256-cbc -md sha512 -pbkdf2 -iter 100000 -salt \
    -pass fd:3 \
    -in  "$backup_file" \
    -out "$enc_file"    \
    3< <(printf '%s' "$encryption_key")
  # Remove the plaintext archive and promote the encrypted file to its path
  # so the rest of the script treats backup_file as the file to finalise.
  rm -f "$backup_file"
  mv "$enc_file" "$backup_file"
  enc_file=""  # cleared so cleanup does not try to remove the renamed file
fi

# Rename to the timestamped final filename now that the file is complete.
# backup_file is cleared so the cleanup trap does not attempt to remove a path
# that no longer exists.
final_file="${HOME}/${filename_prefix_backup}${filename_suffix_backup}.tar.gz"
mv "$backup_file" "$final_file"
backup_file=""

# ------------------ Google Drive upload ------------------
# $2==name is an exact column match; $0~name would be a substring match and
# would incorrectly match e.g. "backup_old" when the target is "backup".
#
# -m 1000 caps results at 1000 entries.  If the Drive account contains more
# than 1000 folders the target may not appear.  Increase -m if needed.
folder_id=$("$path_gdrive" list -m 1000 --no-header \
  -q "trashed = false and mimeType = 'application/vnd.google-apps.folder'" |
  awk -v name="$gdrive_backup_dirname" '$2==name {print $1; exit}')

if [ -z "$folder_id" ]; then
  log "Drive folder '${gdrive_backup_dirname}' not found — creating it."
  "$path_gdrive" mkdir "$gdrive_backup_dirname"
  folder_id=$("$path_gdrive" list -m 1000 --no-header \
    -q "trashed = false and mimeType = 'application/vnd.google-apps.folder'" |
    awk -v name="$gdrive_backup_dirname" '$2==name {print $1; exit}')
  if [ -z "$folder_id" ]; then
    msg="Could not retrieve folder ID after creating"
    msg+=" '${gdrive_backup_dirname}'."
    log "ERROR: $msg"
    notify "$msg"
    exit 1
  fi
fi

if gdrive_upload_with_retry "$final_file" "$folder_id"; then
  log "Upload successful"
else
  msg="Upload failed after ${_upload_attempts_made} attempt(s)."
  msg+=" Backup preserved at $final_file"
  log "ERROR: $msg"
  notify "$msg"
  # Clear final_file so the cleanup trap does not delete it.
  final_file=""
  exit 1
fi

# ------------------ Retention policy ------------------
# Increment before tail so `tail -n +N` skips exactly num_files_to_retain
# entries (the ones to keep) and emits the remainder for deletion.
# Arithmetic assignment avoids the set -e / (( n++ )) == 0 exit-status trap.
num_files_to_retain=$(( num_files_to_retain + 1 ))

# Retention uses lexicographic sort on the filename column (sort -k2r). This
# correctly reflects age order only when filenames contain a sortable timestamp.
# The default suffix _YYYY_MM_DD_HH_MM guarantees this.  If the prefix or
# suffix format is changed, verify that sort order still matches age order.
#
# -m 1000 caps the file list.  If the backup folder ever exceeds 1000 files,
# the oldest entries will not be visible here and retention will stop pruning
# them.  Increase -m if that becomes a concern.
expired_file_ids=$("$path_gdrive" list -m 1000 --no-header \
  -q "'${folder_id}' in parents and trashed = false \
      and mimeType != 'application/vnd.google-apps.folder'" |
  sort -k2r | tail -n +"${num_files_to_retain}" | awk '{print $1}')

if [ -n "$expired_file_ids" ]; then
  log "Deleting old backups"
  while read -r file_id; do
    if [ -n "$file_id" ]; then
      # Treat individual deletion failures as non-fatal: a file may have been
      # removed manually or the Drive API may be temporarily unavailable.
      # The backup itself succeeded, so we log and continue.
      _msg="WARNING: Failed to delete ${file_id}"
      "$path_gdrive" delete "$file_id" \
        || log "$_msg (may already be gone or API error)."
    fi
  done <<< "$expired_file_ids"
fi

log "Backup complete"

Options

FlagArgumentDescription
-hShow help and exit.
-fFull backup. Include database and filesystem paths.
-eEncrypt archive with AES-256-CBC (requires pass in .backup.cnf).
-nNUMNumber of backups to retain on Google Drive (default: 30).
-dDIRGoogle Drive folder name (default: backup).
-pPREFIXArchive filename prefix (default: backup).
-mDBNAMEDatabase name to dump (default: all databases).
-PPATHExtra path to include. Repeat for multiple. Glob patterns supported.
-EPATTERNExclude pattern. Repeat for multiple. Glob syntax.
-NEMAILSend failure notification e-mail (requires mail).
-wURLPOST failure notification to a webhook URL (requires curl).

NOTE: Glob patterns in -P do not support spaces in directory components. Supply literal paths for directories with spaces.

How to Use

Basic Database-only Backup

./backup.sh

Full Backup (Database Plus Web Root, Encrypted, With E-mail Notification)

./backup.sh -f -e -P /var/www/example.com -N ops@example.com

Multiple Paths, With Exclusions

./backup.sh -f -P /var/www/example.com -P /etc/nginx -E /var/www/example.com/cache

Custom Prefix, Drive Folder, and Retention

./backup.sh -f -p example-com -d website-backups -n 60

Typical cron Setup (Full Backup Twice a Month, Database-only Daily)

Because the script depends on mysqldump and gdrive, make sure cron has access to the correct PATH. Use env $PATH to display the current user path and add it to your crontab. Replace /home/admin/bin in the example below with the actual location of your gdrive binary.

PATH=$PATH:/home/admin/bin:/usr/bin

# Full encrypted backup on the 1st and 15th at 1 AM. 5 most recent retained
0 1 1,15 * * /home/admin/scripts/backup.sh -e -f -n 5 -d backup_full-example-com -p backup-example-com >> /var/log/backup.log 2>&1

# Database-only encrypted backup daily at 2 AM. 30 most recent retained
0 2 * * * /home/admin/scripts/backup.sh -e -n 30 -d backup_db-example-com -p db-example-com >> /var/log/backup.log 2>&1

Notifications

Failure notifications fire on runtime errors: an empty database dump, a corrupt archive, a Google Drive folder that cannot be created, or an upload that fails after three attempts. Configuration and validation errors are logged but do not trigger notifications, since they indicate a setup problem rather than a runtime failure.

E-mail and webhook channels are attempted independently. A broken mail setup does not prevent a webhook from firing.

The webhook payload format is {"text": "..."}, compatible with Slack, ntfy, Discord (with minor payload adjustment), and most generic webhook receivers.

All script output is written to stdout. When running via cron, redirect to a log file to preserve it. The cron examples write to /var/log/backup.log.

Retention

The -n flag sets how many backup files to keep in the Drive folder. The default is 30. When a new backup is uploaded, any files beyond the retention count are deleted oldest-first. Deletion failures are non-fatal. If a file cannot be deleted (already removed manually, temporary API error), the script logs a warning and continues.

Retention relies on alphabetical sort of filenames. The default timestamp suffix (_YYYY_MM_DD_HH_MM) guarantees correct sort order. If you change the prefix or suffix format, verify that lexicographic order still reflects age order.

Upload Retry

On upload failure the script retries up to three times with exponential backoff (10s, then 20s between attempts). If all attempts fail, the local archive is preserved at $HOME/<prefix><suffix>.tar.gz for manual retry, and a failure notification is sent if configured.

Restoring a Backup

These steps cover a full restore. Skip any step that does not apply to your backup. For example, if you ran a database-only backup, there are no filesystem paths to restore.

Step 1: Download the Backup from Google Drive

Download the relevant .tar.gz file to your local machine or directly to the server.

Step 2: Decrypt the Archive (If Encrypted)

If you used the -e flag, the archive is encrypted and must be decrypted before it can be extracted. Use openssl with the -d flag and the same options used during encryption. When prompted, enter the passphrase from your .backup.cnf file.

Decryption can be performed on any machine with OpenSSL installed, including macOS and Windows, even though the script itself requires Linux.

Linux / macOS:

openssl enc -d -md sha512 -pbkdf2 -iter 100000 -salt -aes-256-cbc \
  -in backup-example-com_YYYY_MM_DD_HH_MM.tar.gz \
  -out decrypted-backup.tar.gz

Windows (requires OpenSSL for Windows):

openssl enc -d -md sha512 -pbkdf2 -iter 100000 -salt -aes-256-cbc -in "C:\backup-example-com_YYYY_MM_DD_HH_MM.tar.gz" -out "C:\decrypted-backup.tar.gz"

Step 3: Extract the Archive

tar -xzf decrypted-backup.tar.gz -C /path/to/restore/

Replace decrypted-backup.tar.gz with the actual filename. If the backup was not encrypted, use the original timestamped filename downloaded from Drive. The archive preserves the full absolute path of each file (e.g., var/www/html/ rather than /var/www/html/), so extracting to / restores files to their original locations. Extract to a staging directory first if you want to review contents before overwriting anything.

Step 4: Restore the Database

The database dump is stored as mysqldump.sql inside the archive. Import it using the mysql client. The MySQL user must have sufficient privileges to create and populate the databases being restored.

mysql -u root -p < mysqldump.sql

If you dumped all databases (--all-databases, the default), this restores all of them. If you used -m DBNAME to dump a specific database, make sure that database exists before importing, or create it first:

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS dbname;"
mysql -u root -p dbname < mysqldump.sql

Step 5: Restore Filesystem Paths

If the backup included filesystem paths via -f and -P, copy the extracted files back to their original locations on the server. The directory structure inside the archive mirrors the original paths, so files from /var/www/example.com/ will be found at var/www/example.com/ relative to the extraction directory.

Known Limitations

Summary

This is a single-file, self-contained script for backing up any Linux server to Google Drive. It handles the full backup lifecycle including dump, archive, encrypt, upload, and prune. It is built to run unattended: failure notifications, upload retry, concurrency locking, and cleanup of intermediate files on exit. No third-party backup service is required.