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:
- A MySQL dump and a copy of your web root in the same archive.
- Backups stored offsite, automatically.
- Control over how many backups are kept and for how long.
- A notification when something goes wrong, rather than discovering a gap weeks later.
- A free storage destination. Google Drive offers a free storage tier, with paid plans available if you need more space.
What It Does
Each run of the script:
- Optionally copies specified filesystem paths into a staging directory.
- Dumps one or all MySQL databases using credentials stored in a config file.
- Packages everything into a compressed
.tar.gzarchive. - Optionally encrypts the archive with AES-256-CBC.
- Uploads the archive to a Google Drive folder, with automatic retry on failure.
- Deletes old backups from Drive according to your retention setting.
- 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
- Operating System: Linux (GNU/Linux). Not compatible with macOS or BSD since several GNU-specific tools are used.
- Shell: bash 4.0+.
- Required tools:
mysqldump(MySQL or MariaDB client),gdrive(any fork; see compatibility note below),openssl,flock. - Optional tools:
mail(for e-mail notifications),curl(for webhook notifications). - Config files:
.backup.cnfand.mysqldump.cnfin the same directory as the script. - Tested on: Ubuntu 20.04+, Debian 11+. Other GNU/Linux distributions with bash 4.0+ and GNU
coreutilsare expected to work.
NOTE: This script works with any fork of the
gdriveGoogle Drive CLI, but it depends on the output format ofgdrive 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
gdriverequires 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
| Flag | Argument | Description |
|---|---|---|
-h | Show help and exit. | |
-f | Full backup. Include database and filesystem paths. | |
-e | Encrypt archive with AES-256-CBC (requires pass in .backup.cnf). | |
-n | NUM | Number of backups to retain on Google Drive (default: 30). |
-d | DIR | Google Drive folder name (default: backup). |
-p | PREFIX | Archive filename prefix (default: backup). |
-m | DBNAME | Database name to dump (default: all databases). |
-P | PATH | Extra path to include. Repeat for multiple. Glob patterns supported. |
-E | PATTERN | Exclude pattern. Repeat for multiple. Glob syntax. |
-N | EMAIL | Send failure notification e-mail (requires mail). |
-w | URL | POST failure notification to a webhook URL (requires curl). |
NOTE: Glob patterns in
-Pdo 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
- Linux only. Several GNU-specific tools are used that behave differently or are unavailable on macOS and BSD.
- Single database per run. Use
-m DBNAMEfor a specific database, or omit-mto dump all databases. Backing up a subset of databases requires multiple invocations. - Glob patterns with spaces. The
-Poption does not support glob patterns where the directory component contains spaces. Literal paths with spaces work correctly. - Google Drive folder cap.
gdrive listis called with-m 1000. If your Drive account contains more than 1000 folders, or your backup folder contains more than 1000 files, the cap may need to be increased in the script. gdriveoutput format. The script parsesgdrive listoutput by column position. Changes in output format acrossgdriveversions or forks may require adjustment of theawkexpressions.
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.