Minecraft World Backups and Isolated Restore

A verifiable backup contract connecting RCON saves, live file copies, tar archives, Cloud Storage retention, and isolated restore.

For operational backups, a systemd timer ran a Bash script. When no players were connected, the script called RCON save-all, copied world*, configuration, and plugins into a working directory, compressed the copy with tar and pigz, and uploaded it to Cloud Storage.

The remote archive was not guaranteed to represent a consistent recovery point. Minecraft writes continued during cp -r, and some copy failures were logged without stopping the job. The archive was tar.gz, while the startup script’s restore branch called unzip. These are gaps visible in the source, not evidence that a real restore incident occurred.

The Backup Pipeline and Its Failure Points

flowchart LR
  accTitle: Production world-backup pipeline and failure points
  accDescr: A systemd timer checks connected players, runs save-all, copies the live world, creates a tar.gz archive, and uploads it to Cloud Storage. Consistency and error propagation can fail between those stages.
  T[systemd timer] --> P[Check connected players]
  P --> S[RCON save-all]
  S --> C[cp -r working copy]
  C --> A[tar + pigz]
  A --> U[gsutil upload]
  U --> N[Discord notification]
  S -. Autosave continues .-> C
  C -. Some errors ignored .-> A
  A -. tar gzip archive .-> R[Restore branch]
  R -. expects ZIP .-> X[Format mismatch]

The timer used UTC to run in the early morning in Korea. Persistent=true caused a missed run to execute on the next boot.

[Unit]
Description=Daily Minecraft backup

[Timer]
OnCalendar=*-*-* 19:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target

If the VM was off at the scheduled time, Persistent=true runs the missed job when the VM next starts. That can make backup race with Minecraft restoration and initialization immediately after boot. After=minecraft.service alone only orders unit startup; it does not prove readiness. The timer needs a delay or readiness gate, and the script must fail safely when RCON is unavailable.

Preventing duplicate execution was implemented with flock.

LOCKFILE=/run/lock/minecraft-backup.lock
exec 200>"$LOCKFILE"
flock -n 200 || {
  echo "backup already running" >&2
  exit 1
}

For one VM, this is enough to prevent overlap between timer and manual runs. Do not delete the lock file to release it; let the kernel release the lock when the file descriptor closes.

Why save-all Does Not Freeze a Point in Time

Minecraft’s latest state is split between JVM memory and multiple files on disk. Region, entity, player-data, and level files are not replaced atomically. save-all flushes memory to disk, but it does not prevent a later autosave from changing those files.

The actual core code was in the following order:

execute_rcon_command "save-all"

cp -r "$MINECRAFT_DIR/world"* "$TMP_BACKUP_DIR/" \
  2>/dev/null || log_error "world copy failed"
cp "$MINECRAFT_DIR/"{ops.json,whitelist.json,server.properties} \
  "$TMP_BACKUP_DIR/" 2>/dev/null || log_error "config copy failed"

tar -I 'pigz -9' -cf "$BACKUP_PATH" -C "$TMP_BACKUP_DIR" .

Neither cp failure exits the script, so an archive missing a required world can still be compressed and uploaded. If an autosave occurs while a large world is being copied, early region files and later player-data files may represent different points in time. cp -r produces a copy, not a snapshot.

Zero connected players does not mean zero writes. Ticks, plugins, autosave, and scheduled tasks can continue changing files. Checking player count is a useful load-management policy, but consistency requires a separate write boundary.

Ending the Write Pause Even When Copying Fails

For a small server, the working copy can follow this order: save-off, save-all flush, copy, then save-on.

sequenceDiagram
  accTitle: application-consistent working copy creation sequence
  accDescr: The job disables autosave, flushes state to disk, copies the required files, and re-enables autosave on both success and failure.
  participant Job as backup job
  participant MC as Minecraft
  participant Disk as live world
  participant Work as working copy
  Job->>MC: save-off
  Job->>MC: save-all flush
  MC-->>Job: Save completion response
  Job->>Disk: Verify list of required files
  Disk->>Work: copy
  alt copy success
    Job->>MC: save-on
    Job->>Work: Create manifest and checksum
  else command or copy failure
    Job->>MC: save-on
    Job-->>Job: Exit failed and block upload
  end

Put save-on in the cleanup handler, not only at the end of the success path. A flag can avoid issuing it when writes were never disabled.

set -Eeuo pipefail
save_disabled=0
work_dir=$(mktemp -d /backup/minecraft.XXXXXX)

cleanup() {
  status=$?
  if (( save_disabled == 1 )); then
    rcon "save-on" || echo "CRITICAL: save-on failed" >&2
  fi
  rm -rf -- "$work_dir"
  exit "$status"
}
trap cleanup EXIT INT TERM

rcon "save-off"
save_disabled=1
rcon "save-all flush"
cp -a -- /opt/minecraft/world /opt/minecraft/world_nether \
  /opt/minecraft/world_the_end "$work_dir/"
rcon "save-on"
save_disabled=0

set -e cannot catch every failure, but it does prevent the old pattern of turning a required copy failure into success with || log. Keep explicit lists of required and optional files, and do not create an archive when a required file is missing. A save-on failure in cleanup threatens the live game state and deserves a higher-severity alert than an ordinary backup failure.

Autosave remains disabled for the duration of the copy, so the gameplay risk grows with the world. Persistent Disk or filesystem snapshots shorten that pause, but Minecraft should still be flushed first to approach an application-consistent point. A raw disk snapshot may otherwise resemble the state after a crash.

A Verifiable Archive from the Working Copy

Once the working copy exists, Minecraft writes can resume before compression. That keeps a long compression step out of the write pause. Check size and file inventory before and after creating the archive.

archive="minecraft-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
tar --create --gzip --file "$archive" \
  --directory "$work_dir" \
  world world_nether world_the_end server.properties plugins

sha256sum "$archive" > "$archive.sha256"
tar --list --gzip --file "$archive" >/dev/null

The production code used tar -I 'pigz -9'. File extension, archive format, and restoration tool must agree. Extract tar.gz with tar -xzf or an equivalent tar API; reserve unzip for ZIP archives.

The manifest beside the archive carries the minimum information needed to make a restore decision.

{
  "schema_version": 1,
  "archive_format": "tar.gz",
  "created_at": "2026-07-23T00:00:00Z",
  "server_version": "example-version",
  "worlds": ["world", "world_nether", "world_the_end"],
  "sha256": "example-sha256",
  "uncompressed_bytes": 123456789
}

Real project, bucket, username, and RCON credentials do not belong in the manifest. If a plugin uses an external database, its backup and consistency point must be part of the same manifest. Restoring only file-based state may open the world while silently losing operational plugin state.

Upload completion and storage policy

A Cloud Storage upload is complete only when the archive and its checksum or manifest both succeed. Upload under a temporary name and promote it to the final prefix after verification, or upload the manifest last so only bundles with a manifest count as complete. Discord should summarize the final object generation and verification result without exposing signed URLs or secrets.

In production, Cloud Scheduler published to Pub/Sub and a separate function deleted old objects. If retention depends only on age, a Cloud Storage lifecycle rule expresses the same policy with fewer resources and permissions.

resource "google_storage_bucket" "backup" {
  name = "example-minecraft-backups"
  location = var.region
  uniform_bucket_level_access = true

  versioning {
    enabled = true
  }

  lifecycle_rule {
    condition {
      age = 30
      matches_prefix = ["verified/"]
    }
    action {
      type = "Delete"
    }
  }
}

A simple age rule cannot express a policy such as “retain recent restore-tested monthly backups for longer.” A cleanup function and state store are justified only for object-specific decisions such as verification status, legal holds, or monthly and annual retention. In either design, the production VM service account should only create and read objects under its prefix; bucket administration and bulk deletion remain separate.

Restore as a Separate State Machine

Downloading a backup must not overwrite the live world. Verify its integrity and boot it from a quarantine path first.

stateDiagram-v2
  accTitle: Minecraft Backup Quarantine Restore State Machine
  accDescr: A selected backup becomes a production replacement candidate only after its manifest, checksum, archive, file structure, and quarantined server boot have been verified.
  [*] --> SELECTED: select backup
  SELECTED --> MANIFEST_OK: check schema and format
  MANIFEST_OK --> CHECKSUM_OK: SHA-256 match
  CHECKSUM_OK --> EXTRACTED: extract into quarantine
  EXTRACTED --> STRUCTURE_OK: check required worlds and configuration
  STRUCTURE_OK --> BOOTED: start server on a separate port
  BOOTED --> VERIFIED: inspect protocol, world, and plugins
  VERIFIED --> CANDIDATE: production replacement candidate
  SELECTED --> FAILED: verification failed
  MANIFEST_OK --> FAILED
  CHECKSUM_OK --> FAILED
  EXTRACTED --> FAILED
  STRUCTURE_OK --> FAILED
  BOOTED --> FAILED

Run the restore on a different path and port from production, preferably on a temporary VM. Keep it off the public network and use the same Java and server versions. Check startup logs, Java protocol status, world seed and sample coordinates, player data, whitelist, and critical plugin state.

If production must be replaced, do not delete the current directory. Stop Minecraft cleanly, move the live directory to a rollback name, and atomically rename the verified restore into place on the same filesystem. Check ownership and SELinux or AppArmor policy before starting. If validation fails, quarantine the restored copy and rename the original directory back.

RPO and RTO calculated from actual restores

RPO is not the backup interval. It is the distance between the failure and the newest point that can actually be restored. Daily backups still yield a longer RPO if the three most recent archives fail integrity checks. RTO includes selection, download, verification, extraction, server startup, world validation, and cutover—not just file transfer.

flowchart LR
  accTitle: Backup retention and restore verification
  accDescr: A new archive moves to the verified prefix after checksum inspection. Only an archive that also passes a scheduled quarantine restore is considered recoverable.
  NEW[New archive] --> HASH[Checksum and manifest check]
  HASH -- Failure --> BAD[Quarantine and alert]
  HASH -- Success --> VERIFIED[Verified prefix]
  VERIFIED --> RESTORE[Scheduled quarantine restore]
  RESTORE -- Failure --> BAD
  RESTORE -- Success --> PROVEN[Proven recoverable]
  PROVEN --> LIFE[Retention policy]

Restore records should capture object generation, creation and verification times, server version, restore duration, test results, and operator. The useful operational question is not “Does a file exist?” but “Which archive was successfully opened by the real server software, and when?”

References

Share

Share

Image preview