This guide will cover a backup solution for Docker environments on a VPS to a local storage system, without exposing local storage to the internet and keeping connections secure throughout the backup and restore processes. I’ve been using Hetzner and Vultr for a while now and like their built-in backup tools, but I wanted to ensure local backups are also available for continuity.
Workflow Diagram: The VPS is accessible over the internet, but local storage is isolated from external access. We use a gateway (jump) account on the VPS for SSH access, secured with SSH keys. Backups are transferred to the local storage array using SCP through a secure SSH connection. Backup Process on the VPS: This section details the setup and script for backing up Docker containers, images, volumes, networks, and system configurations directly on the VPS to a local folder.
Create a folder owned by the gateway account to keep the backup data in:mkdir -p /home/gatewayaccount/backups chown gatewayaccount:gatewayaccount /home/gatewayaccount/backups
Create a backup script (/home/backup.sh
) to:Gracefully stop Docker containers. Back up Docker images, containers, volumes, networks. Back up system configurations and other important data. Restart Docker containers after backup. Ensure backup files are accessible to the gateway account. #!/bin/bash
###############################################################################
# Script: /home/backup.sh
# Purpose: Prepare full backup of the VPS, including Docker environments
# Run as: Root user on VPS
# Schedule: Via cron (e.g., daily at 1 AM)
###############################################################################
# Variables
BACKUP_DIR="/home/gatewayaccount/backups"
DATE=$(date +%F)
DOCKER_CONTAINERS=$(docker ps -q)
LOGFILE="/var/log/backup.log"
WEBHOOK_URL="YOUR EXTERNAL WEBHOOK URL FOR NOTIFICATION PURPOSES"
# Function to send a notification
send_notification() {
curl -X POST -H "Content-Type: application/json" -d "{\"text\": \"$1\"}" "$WEBHOOK_URL"
}
# Ensure the backup directory exists and is owned by the gatewayaccount
mkdir -p "$BACKUP_DIR"
chown gatewayaccount:gatewayaccount "$BACKUP_DIR"
{
echo "------------------------------------------------------------"
echo "Starting full system backup at $(date)"
# Stop Docker containers
if [ -n "$DOCKER_CONTAINERS" ]; then
echo "Stopping Docker containers..."
docker stop $DOCKER_CONTAINERS || {
send_notification "Error: Failed to stop Docker containers. Backup aborted."
exit 1
}
# Wait for containers to stop
sleep 10
else
echo "No running Docker containers to stop."
fi
# Backup Docker images
echo "Backing up Docker images..."
docker save $(docker images -q) -o "$BACKUP_DIR/docker_images_$DATE.tar" || {
send_notification "Error: Docker image backup failed. Check logs for details."
exit 1
}
# Backup Docker container configurations
echo "Backing up Docker container configurations..."
docker ps -a --no-trunc --format '{
"ID": "{{.ID}}",
"Image": "{{.Image}}",
"Command": "{{.Command}}",
"CreatedAt": "{{.CreatedAt}}",
"Status": "{{.Status}}",
"Ports": "{{.Ports}}",
"Names": "{{.Names}}"
}' > "$BACKUP_DIR/docker_containers_$DATE.json" || {
send_notification "Error: Docker container configurations backup failed."
exit 1
}
# Backup Docker volumes
echo "Backing up Docker volumes..."
VOLUMES=$(docker volume ls -q)
for VOLUME in $VOLUMES; do
echo "Backing up volume: $VOLUME"
docker run --rm -v $VOLUME:/volume -v "$BACKUP_DIR":/backup alpine \
tar -czf /backup/volume_${VOLUME}_$DATE.tar.gz -C /volume . || {
send_notification "Error: Failed to backup Docker volume $VOLUME."
exit 1
}
done
# Backup Docker networks
echo "Backing up Docker networks..."
docker network ls --no-trunc > "$BACKUP_DIR/docker_networks_$DATE.txt" || {
send_notification "Error: Docker networks backup failed."
exit 1
}
# Backup system configurations
echo "Backing up system configurations..."
tar -czf "$BACKUP_DIR/etc_backup_$DATE.tar.gz" /etc || {
send_notification "Error: System configuration backup failed."
exit 1
}
# Start Docker containers back up
if [ -n "$DOCKER_CONTAINERS" ]; then
echo "Starting Docker containers..."
docker start $DOCKER_CONTAINERS || {
send_notification "Error: Failed to restart Docker containers after backup."
exit 1
}
else
echo "No Docker containers to start."
fi
# Check if all containers are running
if ! docker ps -q &>/dev/null; then
send_notification "Warning: Some Docker containers may not have restarted properly."
fi
# Set permissions so the middleman user can access the backups
chown gatewayaccount:gatewayaccount "$BACKUP_DIR"/*
echo "Full system backup completed at $(date)"
send_notification "Success: Full system backup completed at $(date)"
} >> "$LOGFILE" 2>&1
replace the placeholder gateway user account, webhook url and backup path
Make it executable with chmod +x backup.sh
Schedule the backup with cron:crontab-e
30 1 * * * /home/backup.sh
The local VM will pull backups from the VPS and store them on a mounted NAS or external drive.
Mount the external drive or NAS to the local system through fstab
for persistance. Create a config file for storing sensitive information:nano ~/.backup_config
export TEAMS_WEBHOOK_URL="YOUR WEBHOOK URL" export SSH_KEY_PASSPHRASE="YOUR KEY PASSPHRASE"
chmod 600 ~/.backup_config
Place the private SSH key in ~/.ssh/ Create a backup script (/home/backup.sh
) to:Send notifications to a webhook. Pull the backups from the VPS using SCP. Send notifications at key points (start, success, failure). Implement error handling and logging. #!/bin/bash
###############################################################################
# Script: /home/backup.sh
# Purpose: Pull backups from VPS, remove them from VPS, manage local backups, and send notifications to a webhook
# Run as: User on local VM
# Schedule: Via cron (e.g., daily at 2 AM)
###############################################################################
# Load sensitive variables from a secure file
source ~/.backup_config
# Variables
REMOTE_USER="GATEWAY ACCOUNT USERNAME"
REMOTE_HOST="VPS IP"
REMOTE_PORT="VPS SSH PORT"
REMOTE_BACKUP_DIR="VPS BACKUP DIRECTORY"
LOCAL_BACKUP_DIR="LOCAL BACKUP DIRECTORY"
LOGFILE="/var/log/backup_vps.log"
SSH_KEY="/root/.ssh/My-SSH-Private.key"
# Function to send notifications to Microsoft Teams
send_teams_notification() {
local message="$1"
local payload=$(printf '{"text": "%s"}' "$message")
curl -s -H "Content-Type: application/json" -d "$payload" "$TEAMS_WEBHOOK_URL"
}
# Start logging
{
echo "------------------------------------------------------------"
echo "Backup started at $(date)"
send_teams_notification "Backup started at $(date)"
# Ensure local backup directory exists
mkdir -p "$LOCAL_BACKUP_DIR"
# Unlock SSH key with ssh-agent and expect (if passphrase is needed)
echo "Adding SSH key to ssh-agent..."
eval "$(ssh-agent -s)"
export SSH_KEY
export SSH_KEY_PASSPHRASE
/usr/bin/expect << EOF
spawn ssh-add "$SSH_KEY"
expect "Enter passphrase for *:"
send -- "$SSH_KEY_PASSPHRASE\r"
expect eof
EOF
if [ $? -ne 0 ]; then
echo "Failed to add SSH key to ssh-agent."
send_teams_notification "Backup failed at $(date): Unable to add SSH key."
exit 1
fi
# Copy backup files from VPS to local NAS
echo "Copying backups from VPS to local NAS..."
scp -P "$REMOTE_PORT" -i "$SSH_KEY" -o StrictHostKeyChecking=no -r "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_BACKUP_DIR}*" "$LOCAL_BACKUP_DIR"
if [ $? -ne 0 ]; then
echo "Failed to copy backups from VPS."
send_teams_notification "Backup failed at $(date): Unable to copy backups from VPS."
exit 1
fi
# Remove backup files from VPS
echo "Removing backups from VPS..."
ssh -p "$REMOTE_PORT" -i "$SSH_KEY" -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_BACKUP_DIR}*"
if [ $? -ne 0 ]; then
echo "Failed to remove backups from VPS."
send_teams_notification "Backup completed at $(date) but failed to remove backups from VPS."
else
echo "Backups removed from VPS."
fi
# Delete local backups older than 3 days
echo "Deleting local backups older than 3 days..."
find "$LOCAL_BACKUP_DIR" -type f -mtime +3 -exec rm {} \;
if [ $? -ne 0 ]; then
echo "Failed to delete old local backups."
send_teams_notification "Backup completed at $(date) but failed to delete old local backups."
else
echo "Old local backups deleted."
fi
# Cleanup ssh-agent
eval "$(ssh-agent -k)"
echo "Backup process completed successfully at $(date)"
send_teams_notification "Backup process completed successfully at $(date)"
} >> "$LOGFILE" 2>&1
replace the placeholder gateway user account, webhook url, local backup path, remote backup path and connectivity details
Make it executable with chmod +x backup.sh
Schedule the backup after the server-side backup is complete with cron:crontab-e
30 2 * * * /home/backup.sh
Restore: Restore the selected backup to the VPS from the local NAS. Restore the selected backup on the VPS. Restore Process: Retrieve the Backup File from Local Storage to VPS using the restore.sh
script:In case there are multiple backups available on the local storage, the script will prompt you to select the backup for a specific date. You will need to call this script first when initiating a restore session. #!/bin/bash
###############################################################################
# Script: /home/restore.sh
# Purpose: List available backup dates, allow user to select a date,
# and restore files from the selected backup to the VPS server
# Run as: User on local VM
###############################################################################
# Load sensitive variables from a secure file
source ~/.backup_config
# Variables
REMOTE_USER="GATEWAY ACCOUNT USERNAME"
REMOTE_HOST="VPS IP"
REMOTE_PORT="VPS SSH PORT"
REMOTE_BACKUP_DIR="VPS BACKUP DIRECTORY"
LOCAL_BACKUP_DIR="LOCAL BACKUP DIRECTORY"
LOGFILE="/var/log/backup_vps.log"
SSH_KEY="/root/.ssh/My-SSH-Private.key"
# Function to list available backup dates
list_backup_dates() {
ls $LOCAL_BACKUP_DIR | grep -oP '\d{4}-\d{2}-\d{2}' | sort -u
}
# Function to send notifications to Microsoft Teams
send_teams_notification() {
local message="$1"
local payload=$(printf '{"text": "%s"}' "$message")
curl -s -H "Content-Type: application/json" -d "$payload" "$TEAMS_WEBHOOK_URL"
}
# Start restore process
echo "------------------------------------------------------------"
echo "Restore process started at $(date)"
send_teams_notification "Restore process started at $(date)"
# List available dates
echo "Available backup dates:"
list_backup_dates
echo ""
# Prompt user to select a date
read -p "Enter the backup date to restore (YYYY-MM-DD): " restore_date
# Verify selected date exists
if ! ls $LOCAL_BACKUP_DIR | grep -q "$restore_date"; then
echo "No backups found for the selected date: $restore_date"
send_teams_notification "Restore failed: No backups found for the selected date $restore_date"
exit 1
fi
# Unlock SSH key using expect for the passphrase
echo "Adding SSH key to ssh-agent..."
eval "$(ssh-agent -s)"
/usr/bin/expect << EOF
spawn ssh-add "$SSH_KEY"
expect "Enter passphrase for *:"
send -- "$SSH_KEY_PASSPHRASE\r"
expect eof
EOF
if [ $? -ne 0 ]; then
echo "Failed to add SSH key to ssh-agent."
send_teams_notification "Restore failed: Unable to add SSH key."
exit 1
fi
# Find and restore files
echo "Restoring files from $restore_date..."
for file in $LOCAL_BACKUP_DIR/*$restore_date*; do
echo "Copying $file to VPS backups folder..."
scp -P "$REMOTE_PORT" -i "$SSH_KEY" -o StrictHostKeyChecking=no "$file" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_BACKUP_DIR}"
if [ $? -ne 0 ]; then
echo "Failed to copy $file to VPS backups folder."
send_teams_notification "Restore failed: Unable to copy $file to VPS backups folder."
exit 1
fi
done
# Cleanup ssh-agent
eval "$(ssh-agent -k)"
echo "All files for $restore_date have been restored to VPS backups folder."
send_teams_notification "Restore process for $restore_date completed successfully."
echo "Restore process completed at $(date)"
replace the placeholder gateway user account, webhook url, local backup path, remote backup path and connectivity details
Make the restore.sh
script executable with chmod +x restore.sh
Restore the Selected Backup on the VPS.The restore script must be ran after the local backup has been restored on the VPS. This will also prompt the user to specify the date of the backup they want to restore. #!/bin/bash
###############################################################################
# Script: /home/restore.sh
# Purpose: Restore full backup of the VPS, including Docker environments
# Run as: Root user on VPS
###############################################################################
# Variables
BACKUP_DIR="/home/gatewayaccount/backups"
DATE=$(date +%F)
DOCKER_CONTAINERS=$(docker ps -q)
LOGFILE="/var/log/backup.log"
WEBHOOK_URL="YOUR EXTERNAL WEBHOOK URL FOR NOTIFICATION PURPOSES"
# Prompt for the date of the backup to restore
read -p "Enter the backup date to restore (format: YYYY-MM-DD): " DATE
# Function to send a notification
send_notification() {
curl -X POST -H "Content-Type: application/json" -d "{\"text\": \"$1\"}" "$WEBHOOK_URL"
}
{
echo "------------------------------------------------------------"
echo "Starting full system restore from backup on $DATE at $(date)"
# Stop any running Docker containers
echo "Stopping Docker containers..."
docker stop $(docker ps -q) || {
send_notification "Error: Failed to stop Docker containers. Restore aborted."
exit 1
}
# Restore Docker images
echo "Restoring Docker images..."
docker load -i "$BACKUP_DIR/docker_images_$DATE.tar" || {
send_notification "Error: Docker image restore failed."
exit 1
}
# Restore Docker container configurations
echo "Restoring Docker container configurations..."
DOCKER_CONFIG_FILE="$BACKUP_DIR/docker_containers_$DATE.json"
if [ -f "$DOCKER_CONFIG_FILE" ]; then
while read -r line; do
IMAGE=$(echo $line | jq -r '.Image')
NAME=$(echo $line | jq -r '.Names')
if [ -n "$IMAGE" ] && [ -n "$NAME" ]; then
docker run -d --name "$NAME" "$IMAGE" || {
send_notification "Error: Failed to restore Docker container $NAME."
exit 1
}
fi
done < <(jq -c '.[]' "$DOCKER_CONFIG_FILE")
else
echo "Docker container configuration file not found. Skipping container restore."
fi
# Restore Docker volumes
echo "Restoring Docker volumes..."
for VOLUME in $(docker volume ls -q); do
BACKUP_VOLUME="$BACKUP_DIR/volume_${VOLUME}_$DATE.tar.gz"
if [ -f "$BACKUP_VOLUME" ]; then
docker run --rm -v $VOLUME:/volume -v "$BACKUP_DIR":/backup alpine \
tar -xzf "/backup/volume_${VOLUME}_$DATE.tar.gz" -C /volume || {
send_notification "Error: Failed to restore Docker volume $VOLUME."
exit 1
}
else
echo "Backup file for volume $VOLUME not found. Skipping."
fi
done
# Restore system configurations
echo "Restoring system configurations..."
if [ -f "$BACKUP_DIR/etc_backup_$DATE.tar.gz" ]; then
tar -xzf "$BACKUP_DIR/etc_backup_$DATE.tar.gz" -C / || {
send_notification "Error: System configuration restore failed."
exit 1
}
else
echo "System configuration backup not found. Skipping."
fi
# Restart Docker containers
echo "Starting Docker containers..."
docker start $(docker ps -a -q) || {
send_notification "Error: Failed to start Docker containers after restore."
exit 1
}
echo "Full system restore from $DATE completed at $(date)"
send_notification "Success: Full system restore from $DATE completed at $(date)"
} >> "$LOGFILE" 2>&1
replace the placeholder gateway user account, webhook url, local backup path, remote backup path and connectivity details
Make the restore.sh
script executable with chmod +x restore.sh
Run the script after the client side backup has been restored to the original folder on the VPS to initiate the restore process on the server side. Remember to update any placeholders for usernames, paths, and webhook URLs and keep an eye on log files to quickly identify any issues.
And don't forget to test your backup :)