#!/usr/bin/env bash SCRIPT_NAME=$(basename "$0") log_fatal() { echo "$SCRIPT_NAME: $*" >&2 exit 1 } usage() { cat< $SCRIPT_NAME help Commands: list List available backups restore Restore a backup help Show this usage message Restore options: --all --bluemap --fabric --fabric-essentials --fabric-g4mespeed --fabric-world --luckperms --paper --paper-multiverse-inventories --paper-essentials --paper-plot-squared --paper-worlds --paper-creative --paper-creative-overworld --paper-creative-nether --paper-survival --paper-survival-overworld --paper-survival-nether --paper-survival-the-end --schematics --velocity --velocity-dclink Examples: $SCRIPT_NAME list $SCRIPT_NAME restore --fabric 2026-01-20T07-00-49Z $SCRIPT_NAME restore --all latest EOF } cmd_list() { docker compose run --rm init ls -1 /backups || log_fatal "failed to list backups" } cmd_restore() { # Cleanup Flags CLEANUP_VOLUMES=0 # Default flags ## Bluemap RESTORE_BLUEMAP=0 ## Fabric server RESTORE_FABRIC_ESSENTIALS=0 RESTORE_FABRIC_G4MESPEED=0 RESTORE_FABRIC_WORLD=0 ## LuckPerms RESTORE_LUCKPERMS=0 ## Paper server RESTORE_PAPER_MULTIVERSE_INVENTORIES=0 RESTORE_PAPER_ESSENTIALS=0 RESTORE_PAPER_PLOT_SQUARED=0 RESTORE_PAPER_CREATIVE_OVERWORLD=0 RESTORE_PAPER_CREATIVE_NETHER=0 RESTORE_PAPER_SURVIVAL_OVERWORLD=0 RESTORE_PAPER_SURVIVAL_NETHER=0 RESTORE_PAPER_SURVIVAL_THE_END=0 ## Schematics RESTORE_SCHEMATICS=0 ## Velocity proxy RESTORE_VELOCITY_DCLINK=0 # Argument parsing POS_ARGS="" while [ $# -gt 0 ]; do case "$1" in --all) RESTORE_BLUEMAP=1 RESTORE_FABRIC_ESSENTIALS=1 RESTORE_FABRIC_G4MESPEED=1 RESTORE_FABRIC_WORLD=1 RESTORE_LUCKPERMS=1 RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 RESTORE_PAPER_ESSENTIALS=1 RESTORE_PAPER_PLOT_SQUARED=1 RESTORE_PAPER_CREATIVE_OVERWORLD=1 RESTORE_PAPER_CREATIVE_NETHER=1 RESTORE_PAPER_SURVIVAL_OVERWORLD=1 RESTORE_PAPER_SURVIVAL_NETHER=1 RESTORE_PAPER_SURVIVAL_THE_END=1 RESTORE_SCHEMATICS=1 RESTORE_VELOCITY_DCLINK=1 ;; --bluemap) RESTORE_BLUEMAP=1 ;; --fabric) RESTORE_FABRIC_ESSENTIALS=1 RESTORE_FABRIC_G4MESPEED=1 RESTORE_FABRIC_WORLD=1 ;; --fabric-essentials) RESTORE_FABRIC_ESSENTIALS=1 ;; --fabric-g4mespeed) RESTORE_FABRIC_G4MESPEED=1 ;; --fabric-world) RESTORE_FABRIC_WORLD=1 ;; --luckperms) RESTORE_LUCKPERMS=1 ;; --paper) RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 RESTORE_PAPER_ESSENTIALS=1 RESTORE_PAPER_PLOT_SQUARED=1 RESTORE_PAPER_CREATIVE_OVERWORLD=1 RESTORE_PAPER_CREATIVE_NETHER=1 RESTORE_PAPER_SURVIVAL_OVERWORLD=1 RESTORE_PAPER_SURVIVAL_NETHER=1 RESTORE_PAPER_SURVIVAL_THE_END=1 ;; --paper-multiverse-inventories) RESTORE_PAPER_MULTIVERSE_INVENTORIES=1 ;; --paper-essentials) RESTORE_PAPER_ESSENTIALS=1 ;; --paper-plot-squared) RESTORE_PAPER_PLOT_SQUARED=1 ;; --paper-worlds) RESTORE_PAPER_CREATIVE_OVERWORLD=1 RESTORE_PAPER_CREATIVE_NETHER=1 RESTORE_PAPER_SURVIVAL_OVERWORLD=1 RESTORE_PAPER_SURVIVAL_NETHER=1 RESTORE_PAPER_SURVIVAL_THE_END=1 ;; --paper-creative) RESTORE_PAPER_CREATIVE_OVERWORLD=1 RESTORE_PAPER_CREATIVE_NETHER=1 ;; --paper-creative-overworld) RESTORE_PAPER_CREATIVE_OVERWORLD=1 ;; --paper-creative-nether) RESTORE_PAPER_CREATIVE_NETHER=1 ;; --paper-survival) RESTORE_PAPER_SURVIVAL_OVERWORLD=1 RESTORE_PAPER_SURVIVAL_NETHER=1 RESTORE_PAPER_SURVIVAL_THE_END=1 ;; --paper-survival-overworld) RESTORE_PAPER_SURVIVAL_OVERWORLD=1 ;; --paper-survival-nether) RESTORE_PAPER_SURVIVAL_NETHER=1 ;; --paper-survival-the-end) RESTORE_PAPER_SURVIVAL_THE_END=1 ;; --schematics) RESTORE_SCHEMATICS=1 ;; --velocity) RESTORE_VELOCITY_DCLINK=1 ;; --velocity-dclink) RESTORE_VELOCITY_DCLINK=1 ;; --help) usage exit 0 ;; *) POS_ARGS="$POS_ARGS $1" ;; esac shift done # Restore positional arguments set -- $POS_ARGS # Validate and resolve backup id [ $# -ge 1 ] || log_fatal "missing backup id" [ $# -le 1 ] || log_fatal "unexpected argument: $2" BACKUP_ID="$1" BACKUP_DIR=$(docker compose run --rm init sh -c ' TARGET=/backups/"$1" if [ ! -e "$TARGET" ]; then exit 1 fi # Resolve symlinks to real paths REALPATH=$(readlink -f "$TARGET") echo "$REALPATH" ' -- "$BACKUP_ID") || log_fatal "backup '$BACKUP_ID' not found" # Update BACKUP_ID to the actual directory name BACKUP_ID=$(basename "$BACKUP_DIR") # Validate options if [ "$RESTORE_BLUEMAP" -eq 0 ] \ && [ "$RESTORE_FABRIC_ESSENTIALS" -eq 0 ] \ && [ "$RESTORE_FABRIC_G4MESPEED" -eq 0 ] \ && [ "$RESTORE_FABRIC_WORLD" -eq 0 ] \ && [ "$RESTORE_LUCKPERMS" -eq 0 ] \ && [ "$RESTORE_PAPER_MULTIVERSE_INVENTORIES" -eq 0 ] \ && [ "$RESTORE_PAPER_ESSENTIALS" -eq 0 ] \ && [ "$RESTORE_PAPER_PLOT_SQUARED" -eq 0 ] \ && [ "$RESTORE_PAPER_CREATIVE_OVERWORLD" -eq 0 ] \ && [ "$RESTORE_PAPER_CREATIVE_NETHER" -eq 0 ] \ && [ "$RESTORE_PAPER_SURVIVAL_OVERWORLD" -eq 0 ] \ && [ "$RESTORE_PAPER_SURVIVAL_NETHER" -eq 0 ] \ && [ "$RESTORE_PAPER_SURVIVAL_THE_END" -eq 0 ] \ && [ "$RESTORE_SCHEMATICS" -eq 0 ] \ && [ "$RESTORE_VELOCITY_DCLINK" -eq 0 ]; then log_fatal "no restore options specified" fi echo "=== Restore started at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" echo "Restoring from backup: $BACKUP_DIR" # Ensure stack is not running docker compose down > /dev/null 2>&1 # Create snapshot to revert to on failure trap restore_cleanup_trap HUP INT QUIT ABRT TERM create_snapshot || restore_cleanup_failure restore_bluemap || restore_cleanup_failure restore_fabric || restore_cleanup_failure restore_luckperms || restore_cleanup_failure restore_paper || restore_cleanup_failure restore_schematics || restore_cleanup_failure restore_velocity || restore_cleanup_failure restore_cleanup_success echo "=== Restore completed successfully at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" } create_snapshot() { echo "Creating snapshot..." # Map: source volume name -> cloned volume name # Source names must match the mount points inside init declare -Ag VOLUMES=( [bluemap_data]="bluemap_data_$BACKUP_ID" [bluemap_maps]="bluemap_maps_$BACKUP_ID" [bluemap_web]="bluemap_web_$BACKUP_ID" [fabric_data]="fabric_data_$BACKUP_ID" [luckperms_data]="luckperms_data_$BACKUP_ID" [paper_data]="paper_data_$BACKUP_ID" [schematics]="schematics_$BACKUP_ID" [velocity_data]="velocity_data_$BACKUP_ID" ) MOUNTS=() for VOLUME in "${!VOLUMES[@]}"; do MOUNTS+=(-v "${VOLUMES[$VOLUME]}:/backup_$VOLUME") done # Volumes have started being created # Cleanup should delete volumes CLEANUP_VOLUMES=1 for VOLUME in "${VOLUMES[@]}"; do docker volume create "$VOLUME" > /dev/null || return 1 done docker compose run --rm \ "${MOUNTS[@]}" \ init sh -ec ' for DIR in \ bluemap_data \ bluemap_maps \ bluemap_web \ fabric_data \ luckperms_data \ paper_data \ schematics \ velocity_data do cp -a --reflink=auto "/$DIR/." "/backup_$DIR" done ' || return 1 # Volumes have been successfully cloned # Cleanup should restore contents to originals, then delete clones CLEANUP_VOLUMES=2 echo "Created snapshot successfully" } restore_bluemap() { if [ "$RESTORE_BLUEMAP" -eq "1" ]; then echo "Restoring BlueMap data..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/bluemap_data/. /bluemap_data && cp -a --reflink=auto "$1"/bluemap_maps/. /bluemap_maps && cp -a --reflink=auto "$1"/bluemap_web/. /bluemap_web ' -- "$BACKUP_DIR" || return 1 fi } restore_fabric() { if [ "$RESTORE_FABRIC_ESSENTIALS" -eq 1 ]; then echo "Restoring Fabric server's Essentials mod data..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/fabric_data/fabric-essentials.json /fabric_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_FABRIC_G4MESPEED" -eq 1 ]; then echo "Restoring Fabric server's G4mespeed mod data..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/fabric_data/g4mespeed /fabric_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_FABRIC_WORLD" -eq 1 ]; then echo "Restoring Fabric server's world..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/fabric_data/world /fabric_data ' -- "$BACKUP_DIR" || return 1 fi } restore_luckperms() { if [ "$RESTORE_LUCKPERMS" -eq 1 ]; then echo "Restoring LuckPerms database..." docker compose up --wait luckperms > /dev/null 2>&1 docker compose exec -T luckperms sh -c ' psql -U luckperms postgres -c "DROP DATABASE IF EXISTS luckperms;" > /dev/null 2>&1 && psql -U luckperms postgres -c "CREATE DATABASE luckperms OWNER luckperms;" > /dev/null 2>&1 && psql -U luckperms luckperms < "$1"/luckperms.sql > /dev/null 2>&1 ' -- "$BACKUP_DIR" || return 1 fi } restore_paper() { if [ "$RESTORE_PAPER_MULTIVERSE_INVENTORIES" -eq 1 ]; then echo "Restoring Paper server's Multiverse-Inventories plugin data..." docker compose run --rm init sh -c ' mkdir -p /paper_data/plugins && chown minecraft_server:minecraft_server /paper_data/plugins && cp -a --reflink=auto "$1"/paper_data/plugins/Multiverse-Inventories /paper_data/plugins ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_ESSENTIALS" -eq 1 ]; then echo "Restoring Paper server's Essentials plugin data..." docker compose run --rm init sh -c ' mkdir -p /paper_data/plugins && chown minecraft_server:minecraft_server /paper_data/plugins && cp -a --reflink=auto "$1"/paper_data/plugins/Essentials /paper_data/plugins ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_PLOT_SQUARED" -eq 1 ]; then echo "Restoring Paper server's PlotSquared plugin data..." docker compose run --rm init sh -c ' mkdir -p /paper_data/plugins && chown minecraft_server:minecraft_server /paper_data/plugins && cp -a --reflink=auto "$1"/paper_data/plugins/PlotSquared /paper_data/plugins ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_CREATIVE_OVERWORLD" -eq 1 ]; then echo "Restoring Paper server's creative overworld..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/paper_data/creative /paper_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_CREATIVE_NETHER" -eq 1 ]; then echo "Restoring Paper server's creative nether..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/paper_data/creative_nether /paper_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_SURVIVAL_OVERWORLD" -eq 1 ]; then echo "Restoring Paper server's survival overworld..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/paper_data/survival /paper_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_SURVIVAL_NETHER" -eq 1 ]; then echo "Restoring Paper server's survival nether..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/paper_data/survival_nether /paper_data ' -- "$BACKUP_DIR" || return 1 fi if [ "$RESTORE_PAPER_SURVIVAL_THE_END" -eq 1 ]; then echo "Restoring Paper server's survival the end..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/paper_data/survival_the_end /paper_data ' -- "$BACKUP_DIR" || return 1 fi } restore_schematics() { if [ "$RESTORE_SCHEMATICS" -eq 1 ]; then echo "Restoring WorldEdit schematics..." docker compose run --rm init sh -c ' cp -a --reflink=auto "$1"/schematics/. /schematics ' -- "$BACKUP_DIR" || return 1 fi } restore_velocity() { if [ "$RESTORE_VELOCITY_DCLINK" -eq 1 ]; then echo "Restoring Velocity proxy's dclink database..." docker compose run --rm init sh -c ' mkdir -p /velocity_data/plugins/dclink-velocity && chown minecraft_server:minecraft_server /velocity_data/plugins && chown minecraft_server:minecraft_server /velocity_data/plugins/dclink-velocity && cp -a --reflink=auto "$1"/velocity_data/plugins/dclink-velocity/dclink.db /velocity_data/plugins/dclink-velocity ' -- "$BACKUP_DIR" || return 1 fi } restore_cleanup_success() { CLEANUP_VOLUMES=1 restore_cleanup } restore_cleanup_trap() { restore_cleanup echo "=== Restore cancelled at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" exit 0 } restore_cleanup_failure() { restore_cleanup echo "=== Restore completed unsuccessfully at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" exit 1 } restore_cleanup() { # Ignore specified conditions to ensure cleanup runs to completion uninterrupted trap '' HUP INT QUIT ABRT TERM echo "Running cleanup..." docker compose down > /dev/null 2>&1 if [ "$CLEANUP_VOLUMES" -ge "2" ]; then echo "Reverting to snapshot..." docker compose run --rm \ "${MOUNTS[@]}" \ init sh -ec ' for DIR in \ bluemap_data \ bluemap_maps \ bluemap_web \ fabric_data \ luckperms_data \ paper_data \ schematics \ velocity_data do find "${DIR:?}" -mindepth 1 -delete && cp -a --reflink=auto "/backup_$DIR/." "/$DIR" done ' || return 1 echo "Reverted to snapshot successfully" fi if [ "$CLEANUP_VOLUMES" -ge "1" ]; then echo "Removing snapshot..." for VOLUME in "${VOLUMES[@]}"; do docker volume rm "$VOLUME" > /dev/null 2>&1 done echo "Removed snapshot successfully" fi echo "Finished cleanup successfully" } if [ "$EUID" -ne 0 ]; then log_fatal "This command must be run by the root user" fi [ $# -ge 1 ] || { usage log_fatal "expected argument" } COMMAND=$1 shift case "$COMMAND" in list) cmd_list "$@" ;; restore) cmd_restore "$@" ;; help) usage ;; *) log_fatal "unknown command: $COMMAND" ;; esac