Post

Game Server Backup and Update Scripts with Automatic Rollback

Game Server Backup and Update Scripts with Automatic Rollback

Game server updates break worlds. A mod incompatibility corrupts player data. A config change causes crashes. You need to restore the backup—which you forgot to take, or took three days ago, or can’t verify isn’t also corrupted. Meanwhile, players are messaging you asking when the server will be back up.

Self-hosted game servers need automated backups before every update, retention policies to manage disk space, integrity verification to catch corruption, and automatic rollback when updates fail. For Minecraft and Vintage Story specifically, this means handling their world formats, respecting server-side vs. client-side mods, and avoiding backups during chunk generation.

This guide provides production-ready scripts for both servers: backup creation with compression and verification, retention policies that keep daily/weekly/monthly snapshots, systemd timers for scheduled updates, and rollback automation that detects crashes and restores the last known-good state within minutes.

Problem Statement

Self-hosted game servers require:

  • Regular backups: World data must be preserved
  • Retention policy: Disk space management through backup rotation
  • Safe updates: Pre-update backups with rollback on failure
  • Automation: Scheduled backups without manual intervention

Part 1: Generic Backup Script

A template applicable to any game server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!/bin/bash
# game_backup.sh - Backup game server data with retention
set -euo pipefail

# =============================================================================
# Configuration (override via environment)
# =============================================================================

DATA_DIR="${DATA_DIR:?ERROR: DATA_DIR not set}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/gameserver}"
PREFIX="${PREFIX:-gamedata}"
RETENTION="${RETENTION:-14}"  # Keep last N backups
COMPRESS="${COMPRESS:-true}"

# =============================================================================
# Main
# =============================================================================

# Ensure backup directory exists
install -d -m 0755 "$BACKUP_DIR"

# Create timestamped archive
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

if [[ "$COMPRESS" == "true" ]]; then
    ARCHIVE="${BACKUP_DIR}/${PREFIX}_${TIMESTAMP}.tar.gz"
    echo "Creating compressed backup: $ARCHIVE"
    tar -C "$DATA_DIR" -czf "$ARCHIVE" .
else
    ARCHIVE="${BACKUP_DIR}/${PREFIX}_${TIMESTAMP}.tar"
    echo "Creating backup: $ARCHIVE"
    tar -C "$DATA_DIR" -cf "$ARCHIVE" .
fi

# Set permissions
chmod 0640 "$ARCHIVE"

# Verify archive integrity
echo "Verifying archive..."
if ! tar -tf "$ARCHIVE" >/dev/null 2>&1; then
    echo "ERROR: Archive verification failed!"
    rm -f "$ARCHIVE"
    exit 1
fi

# Show size
SIZE=$(du -h "$ARCHIVE" | cut -f1)
echo "Backup complete: $ARCHIVE ($SIZE)"

# Retention: delete oldest backups beyond limit
PATTERN="${BACKUP_DIR}/${PREFIX}_*.tar*"
BACKUP_COUNT=$(ls -1 $PATTERN 2>/dev/null | wc -l)

if [[ $BACKUP_COUNT -gt $RETENTION ]]; then
    echo "Applying retention policy (keeping $RETENTION)..."
    ls -1t $PATTERN | tail -n +$((RETENTION + 1)) | xargs -r rm -f
    echo "Deleted $((BACKUP_COUNT - RETENTION)) old backups"
fi

echo "Backups retained: $(ls -1 $PATTERN 2>/dev/null | wc -l)"

Usage

1
2
3
4
5
6
7
8
# Vintage Story
DATA_DIR=/var/vintagestory/data PREFIX=vsdata ./game_backup.sh

# Minecraft
DATA_DIR=/srv/minecraft/world PREFIX=mcworld ./game_backup.sh

# Custom retention
DATA_DIR=/var/gamedata RETENTION=30 ./game_backup.sh

Part 2: Vintage Story Server

Backup Script

/usr/local/bin/vs_backup.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/bin/bash
# vs_backup.sh - Vintage Story server backup
set -euo pipefail

DATA_DIR="/var/vintagestory/data"
BACKUP_DIR="/var/backups/vintagestory"
RETENTION=14

install -d -m 0755 "$BACKUP_DIR"

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ARCHIVE="${BACKUP_DIR}/vsdata_${TIMESTAMP}.tar.gz"

echo "[$(date)] Starting Vintage Story backup"

# Create archive
tar -C "$DATA_DIR" -czf "$ARCHIVE" .
chmod 0640 "$ARCHIVE"

# Verify
if ! tar -tzf "$ARCHIVE" >/dev/null 2>&1; then
    echo "ERROR: Archive corrupt"
    rm -f "$ARCHIVE"
    exit 1
fi

echo "Backup: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"

# Retention
ls -1t "${BACKUP_DIR}"/vsdata_*.tar.gz 2>/dev/null | \
    tail -n +$((RETENTION + 1)) | \
    xargs -r rm -f

echo "[$(date)] Backup complete"

Update Script with Rollback

/usr/local/bin/vs_update.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#!/bin/bash
# vs_update.sh - Update Vintage Story server with automatic rollback
set -euo pipefail

# =============================================================================
# Configuration
# =============================================================================

VS_USER="vintagestory"
VS_SERVER_DIR="/srv/vintagestory/server"
VS_DATA_DIR="/var/vintagestory/data"
VS_BACKUP_DIR="/srv/vintagestory"
SERVICE_NAME="vintagestory.service"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log_info()  { echo -e "${BLUE}[INFO]${NC} $1"; }
log_ok()    { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
die()       { log_error "$1"; exit 1; }

BACKUP_PATH=""

# =============================================================================
# Pre-flight checks
# =============================================================================

preflight() {
    [[ $EUID -eq 0 ]] || die "Must run as root"
    id "$VS_USER" &>/dev/null || die "User '$VS_USER' not found"
    [[ -d "$VS_SERVER_DIR" ]] || die "Server not found: $VS_SERVER_DIR"
    [[ -d "$VS_DATA_DIR" ]] || die "Data dir not found: $VS_DATA_DIR"
    log_ok "Pre-flight checks passed"
}

# =============================================================================
# Tarball validation
# =============================================================================

validate_tarball() {
    local tarball="$1"
    [[ -f "$tarball" ]] || die "File not found: $tarball"
    [[ "$tarball" == *.tar.gz ]] || die "Expected .tar.gz file"

    if ! tar -tzf "$tarball" | grep -q "VintagestoryServer.dll"; then
        die "Invalid archive (missing VintagestoryServer.dll)"
    fi
    log_ok "Tarball validated"
}

extract_version() {
    basename "$1" | sed -E 's/vs_server_linux-x64_([0-9.]+)\.tar\.gz/\1/'
}

# =============================================================================
# Server control
# =============================================================================

server_running() {
    pgrep -u "$VS_USER" -f "VintagestoryServer.dll" &>/dev/null
}

stop_server() {
    log_info "Stopping server..."

    if ! server_running; then
        log_info "Server not running"
        return 0
    fi

    systemctl stop "$SERVICE_NAME" 2>/dev/null || \
        pkill -SIGINT -u "$VS_USER" -f "VintagestoryServer.dll"

    # Wait for graceful shutdown
    local retries=30
    while server_running && ((retries-- > 0)); do
        sleep 1
    done

    if server_running; then
        log_warn "Force killing..."
        pkill -SIGKILL -u "$VS_USER" -f "VintagestoryServer.dll" || true
        sleep 2
    fi

    server_running && die "Failed to stop server"
    log_ok "Server stopped"
}

start_server() {
    log_info "Starting server..."
    systemctl start "$SERVICE_NAME"
    sleep 3

    if systemctl is-active "$SERVICE_NAME" &>/dev/null; then
        log_ok "Server started"
        return 0
    else
        log_error "Server failed to start"
        return 1
    fi
}

# =============================================================================
# Backup and update
# =============================================================================

backup_server() {
    BACKUP_PATH="${VS_BACKUP_DIR}/server.backup.$(date +%Y%m%d_%H%M%S)"
    log_info "Creating backup: $BACKUP_PATH"
    cp -a "$VS_SERVER_DIR" "$BACKUP_PATH"
    chown -R "${VS_USER}:${VS_USER}" "$BACKUP_PATH"
    log_ok "Backup created"
}

update_server() {
    local tarball="$1"
    local version
    version=$(extract_version "$tarball")

    log_info "Updating to version: $version"

    # Clear server directory and extract new files
    find "$VS_SERVER_DIR" -mindepth 1 -delete
    tar -xzf "$tarball" -C "$VS_SERVER_DIR"

    # Configure paths in server.sh
    sed -i \
        -e "s|^USERNAME=.*|USERNAME='${VS_USER}'|" \
        -e "s|^VSPATH=.*|VSPATH='${VS_SERVER_DIR}'|" \
        -e "s|^DATAPATH=.*|DATAPATH='${VS_DATA_DIR}'|" \
        "$VS_SERVER_DIR/server.sh"

    # Fix permissions
    chown -R "${VS_USER}:${VS_USER}" "$VS_SERVER_DIR"
    chmod +x "$VS_SERVER_DIR/server.sh"

    log_ok "Updated to $version"
}

rollback() {
    log_error "Update failed! Rolling back..."

    if [[ -z "$BACKUP_PATH" || ! -d "$BACKUP_PATH" ]]; then
        die "No backup available for rollback!"
    fi

    rm -rf "$VS_SERVER_DIR"
    mv "$BACKUP_PATH" "$VS_SERVER_DIR"

    log_info "Restored from backup, attempting to start..."
    start_server || true

    die "Rolled back to previous version"
}

# =============================================================================
# Main
# =============================================================================

main() {
    local tarball="${1:-}"

    echo "╔════════════════════════════════════════════╗"
    echo "║   Vintage Story Server Update Script       ║"
    echo "╚════════════════════════════════════════════╝"
    echo

    # Get tarball path
    if [[ -z "$tarball" ]]; then
        echo "Download from: https://account.vintagestory.at/"
        echo
        read -rp "Path to server tarball: " tarball
        [[ -n "$tarball" ]] || die "No tarball specified"
    fi

    # Resolve to absolute path
    tarball=$(realpath "$tarball") || die "Invalid path"

    # Validate
    preflight
    validate_tarball "$tarball"

    local version
    version=$(extract_version "$tarball")

    echo
    echo "  Server directory: $VS_SERVER_DIR"
    echo "  Data directory:   $VS_DATA_DIR"
    echo "  New version:      $version"
    echo
    read -rp "Proceed with update? (y/N): " confirm
    [[ "${confirm,,}" == "y" ]] || die "Cancelled"
    echo

    # Execute update
    stop_server
    backup_server

    if ! update_server "$tarball"; then
        rollback
    fi

    if ! start_server; then
        rollback
    fi

    echo
    log_ok "Update complete!"
    echo
    echo "  Backup:  $BACKUP_PATH"
    echo "  Logs:    journalctl -u $SERVICE_NAME -f"
    echo "  Console: screen -r vintagestory (if using screen)"
}

main "$@"

Systemd Service

/etc/systemd/system/vintagestory.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[Unit]
Description=Vintage Story Dedicated Server
After=network-online.target
Wants=network-online.target

[Service]
User=vintagestory
Group=vintagestory
WorkingDirectory=/srv/vintagestory/server

ExecStart=/usr/bin/dotnet VintagestoryServer.dll --dataPath /var/vintagestory/data

Type=simple
Restart=always
RestartSec=5s
KillSignal=SIGINT
TimeoutStopSec=90s

# Logging
StandardOutput=journal
StandardError=journal

# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/vintagestory /srv/vintagestory/server

[Install]
WantedBy=multi-user.target

Backup Timer

/etc/systemd/system/vs-backup.service:

1
2
3
4
5
6
[Unit]
Description=Vintage Story backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/vs_backup.sh

/etc/systemd/system/vs-backup.timer:

1
2
3
4
5
6
7
8
9
[Unit]
Description=Daily Vintage Story backup at 4am

[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer:

1
sudo systemctl enable --now vs-backup.timer

Part 3: Minecraft Server

Server Management Script

/usr/local/bin/mc_server.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#!/bin/bash
# mc_server.sh - Minecraft server management with tmux
set -euo pipefail

# =============================================================================
# Configuration
# =============================================================================

SESSION="${MC_SESSION:-minecraft}"
SERVER_DIR="${MC_SERVER_DIR:-/srv/minecraft}"
JAR="${MC_JAR:-server.jar}"
JVM_ARGS="${MC_JVM_ARGS:--Xmx4G -Xms2G}"
BACKUP_DIR="${MC_BACKUP_DIR:-/var/backups/minecraft}"
RETENTION="${MC_RETENTION:-14}"

# =============================================================================
# Commands
# =============================================================================

start_server() {
    cd "$SERVER_DIR" || exit 1

    if tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Server already running in session '$SESSION'"
        echo "Attach: tmux attach -t $SESSION"
        exit 0
    fi

    echo "Starting Minecraft server..."
    tmux new-session -d -s "$SESSION"
    tmux send-keys -t "$SESSION" "java $JVM_ARGS -jar $JAR nogui" C-m

    sleep 3
    if tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Server started in tmux session '$SESSION'"
        echo "Attach: tmux attach -t $SESSION"
    else
        echo "ERROR: Server failed to start"
        exit 1
    fi
}

stop_server() {
    if ! tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Server not running"
        return 0
    fi

    echo "Sending stop command..."
    tmux send-keys -t "$SESSION" "stop" C-m

    # Wait for graceful shutdown
    local retries=60
    while tmux has-session -t "$SESSION" 2>/dev/null && ((retries-- > 0)); do
        sleep 1
    done

    if tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Force killing session..."
        tmux kill-session -t "$SESSION"
    fi

    echo "Server stopped"
}

status_server() {
    if tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Server: RUNNING"
        echo "Session: $SESSION"
        echo "Attach: tmux attach -t $SESSION"
    else
        echo "Server: STOPPED"
    fi
}

send_command() {
    local cmd="$*"
    if ! tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Server not running"
        exit 1
    fi
    tmux send-keys -t "$SESSION" "$cmd" C-m
    echo "Sent: $cmd"
}

backup_server() {
    mkdir -p "$BACKUP_DIR"

    # Save world if server is running
    if tmux has-session -t "$SESSION" 2>/dev/null; then
        echo "Saving world..."
        tmux send-keys -t "$SESSION" "save-all" C-m
        sleep 5
        tmux send-keys -t "$SESSION" "save-off" C-m
        sleep 2
    fi

    # Create backup
    TIMESTAMP=$(date +%Y%m%d_%H%M%S)
    ARCHIVE="${BACKUP_DIR}/world_${TIMESTAMP}.tar.gz"

    echo "Creating backup: $ARCHIVE"

    # Backup world directories (handle different world structures)
    cd "$SERVER_DIR"
    if [[ -d "world" ]]; then
        tar -czf "$ARCHIVE" world world_nether world_the_end 2>/dev/null || \
            tar -czf "$ARCHIVE" world
    else
        echo "ERROR: World directory not found"
        [[ -n "${SAVE_OFF:-}" ]] && tmux send-keys -t "$SESSION" "save-on" C-m
        exit 1
    fi

    # Re-enable saving
    if tmux has-session -t "$SESSION" 2>/dev/null; then
        tmux send-keys -t "$SESSION" "save-on" C-m
    fi

    echo "Backup: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"

    # Retention
    ls -1t "${BACKUP_DIR}"/world_*.tar.gz 2>/dev/null | \
        tail -n +$((RETENTION + 1)) | \
        xargs -r rm -f

    echo "Backups retained: $(ls -1 "${BACKUP_DIR}"/world_*.tar.gz 2>/dev/null | wc -l)"
}

show_help() {
    cat <<EOF
Usage: $(basename "$0") {start|stop|restart|status|backup|console|cmd "command"}

Commands:
  start     Start server in tmux session
  stop      Graceful shutdown
  restart   Stop and start
  status    Check if running
  backup    Backup world data
  console   Attach to tmux session
  cmd       Send command to server

Environment Variables:
  MC_SESSION     tmux session name (default: minecraft)
  MC_SERVER_DIR  Server directory (default: /srv/minecraft)
  MC_JAR         Server JAR file (default: server.jar)
  MC_JVM_ARGS    JVM arguments (default: -Xmx4G -Xms2G)
  MC_BACKUP_DIR  Backup directory (default: /var/backups/minecraft)
  MC_RETENTION   Backups to keep (default: 14)

Examples:
  $(basename "$0") start
  $(basename "$0") backup
  $(basename "$0") cmd "say Server restarting in 5 minutes"
  $(basename "$0") cmd "whitelist add PlayerName"
EOF
}

# =============================================================================
# Main
# =============================================================================

case "${1:-help}" in
    start)   start_server ;;
    stop)    stop_server ;;
    restart) stop_server; sleep 2; start_server ;;
    status)  status_server ;;
    backup)  backup_server ;;
    console) tmux attach -t "$SESSION" ;;
    cmd)     shift; send_command "$@" ;;
    help|--help|-h) show_help ;;
    *)
        echo "Unknown command: $1"
        echo "Run '$(basename "$0") help' for usage"
        exit 1
        ;;
esac

Systemd Service

/etc/systemd/system/minecraft.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[Unit]
Description=Minecraft Server
After=network.target

[Service]
Type=forking
User=minecraft
Group=minecraft
WorkingDirectory=/srv/minecraft

ExecStart=/usr/local/bin/mc_server.sh start
ExecStop=/usr/local/bin/mc_server.sh stop

Restart=on-failure
RestartSec=10
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target

Backup Timer

1
2
3
4
5
6
7
8
9
10
# /etc/systemd/system/mc-backup.timer
[Unit]
Description=Minecraft backup every 6 hours

[Timer]
OnCalendar=*-*-* 00,06,12,18:00:00
Persistent=true

[Install]
WantedBy=timers.target

Part 4: Monitoring and Alerts

Service Check Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
# check_game_servers.sh

check_service() {
    local name="$1"
    local service="$2"

    if systemctl is-active "$service" &>/dev/null; then
        echo "[OK] $name is running"
    else
        echo "[FAIL] $name is not running"
        # Send alert (customize for notification system)
        # curl -X POST "https://hooks.slack.com/..." -d "{\"text\":\"$name is down!\"}"
    fi
}

check_service "Vintage Story" "vintagestory.service"
check_service "Minecraft" "minecraft.service"

Disk Space Alert Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash
# check_backup_disk.sh

BACKUP_DIRS="/var/backups/vintagestory /var/backups/minecraft"
THRESHOLD=90  # Percent

for dir in $BACKUP_DIRS; do
    if [[ -d "$dir" ]]; then
        USAGE=$(df "$dir" | tail -1 | awk '{print $5}' | tr -d '%')
        if [[ $USAGE -gt $THRESHOLD ]]; then
            echo "WARNING: $dir is ${USAGE}% full"
        fi
    fi
done

Quick Reference

TaskVintage StoryMinecraft
Startsystemctl start vintagestorymc_server.sh start
Stopsystemctl stop vintagestorymc_server.sh stop
Logsjournalctl -u vintagestory -fmc_server.sh console
Backupvs_backup.shmc_server.sh backup
Updatevs_update.sh /path/to/server.tar.gzManual JAR replacement

Summary

These scripts provide:

  • Automated backups with configurable retention
  • Integrity verification before backup completion
  • Safe updates with automatic rollback on failure
  • Systemd integration for reliability and scheduling
  • tmux management for Minecraft console access
  • Graceful shutdown to prevent data corruption

The patterns adapt to any game server. Path and command modifications enable support for Factorio, Valheim, Terraria, or other self-hosted games.

This post is licensed under CC BY 4.0 by the author.