GCP Minecraft Server Architecture

How did I separate player access, boot, external recovery, backups, and Discord operations on one stateful Compute Engine VM?

After moving the world and server files from a Windows PC to Compute Engine, I added a static external IP, Java and Bedrock access, preemption recovery, remote backups, and Discord operations. Purpur, Geyser, the Discord bot, and the backup job all ran on the VM that stored the world. If preemption stopped that VM, a recovery control plane outside it restarted the same instance.

Minecraft server architecture combining VPC, Compute Engine VM, Cloud Storage, and self-healing control plane within GCP boundaries

Player and Discord requests cross the VPC boundary into the Compute Engine VM. Inside the VM, systemd starts and restarts Purpur and the Discord bot and runs the backup timer. Geyser and Floodgate are loaded by Purpur as plugins rather than running as separate systemd services.

World backups leave the VM for Cloud Storage. The automatic recovery control plane also lives outside the VM: it detects a shutdown activity log and calls the Compute API to start the same instance. Terraform defines these resources but does not participate in the runtime path, so it is not shown in the runtime architecture diagram.

PathStarting pointCore componentsCompletion conditionRepresentative failure
AccessMinecraft clientExternal IP, firewall, Purpur, Geyser, FloodgateJava and Bedrock protocol responses plus an actual connectionBind failure, missing TCP or UDP rule
BootCompute APIOS, startup script, systemd, JVMReady log plus an external status responseVM is RUNNING while the service restart-loops
RecoveryShutdown activity logLogging, Monitoring, Pub/Sub, recovery functionThe same VM responds to Minecraft requests againManual stop mistaken for preemption, duplicate execution
Preservationsystemd timerRCON, working copy, tar/pigz, Cloud StorageRemote archive plus an isolated restore testFiles change during copy, archive format mismatch

All four paths converge on the same VM, but they do not execute in the same place. Access and boot lead to processes inside the VM; the recovery executor and backup destination remain outside it. In particular, the restore test required to complete the preservation path was not verified during the original operation.

Boundaries Defined by Terraform

Terraform did not process game traffic. It declared the static external IP, firewall, VM, bucket, function, and IAM relationships, then passed startup scripts and configuration through VM metadata. The boundary between declared resources and runtime processes looked like this:

flowchart TD
  accTitle: Terraform resources and runtime dependencies
  accDescr: Terraform creates addresses, firewalls, VMs, buckets, and recovery resources, and after booting the VM, systemd runs Minecraft and Discord bots.
  TF[Terraform] --> IP[Static External IP]
  TF --> FW[Java TCP and Bedrock UDP firewall]
  TF --> VM[Compute Engine VM]
  TF --> GCS[Backup Cloud Storage]
  TF --> CTRL[Logging, Monitoring, Pub/Sub, and Function]
  IP --> NIC[VM access_config]
  FW --> TAG[VM network tag]
  NIC --> VM
  TAG --> VM
  VM --> START[startup script]
  START --> SD[systemd]
  SD --> MC[Purpur]
  MC --> GEY[Geyser plugin]
  MC --> FLOOD[Floodgate plugin]
  SD --> BOT[Discord bot]
  SD --> BACKUP[backup timer]
  BACKUP --> GCS
  CTRL --> VM

The static address is attached to the VM through access_config.nat_ip. Firewall rules do not attach to the address; they target the VM’s network tag. If the address, rule, or tag is missing, the external route is incomplete.

resource "google_compute_address" "minecraft" {
  name = "minecraft-public-ip"
  region = var.region
}

resource "google_compute_firewall" "minecraft_java" {
  name = "minecraft-java"
  network = var.network
  source_ranges = ["0.0.0.0/0"]
  target_tags = ["minecraft-server"]

  allow {
    protocol = "tcp"
    ports = ["25565"]
  }
}

Bedrock uses an equivalent rule with protocol = "udp" and the Bedrock port. Copying the Java rule and leaving it on TCP prevents Bedrock packets from reaching an otherwise healthy Geyser plugin. RCON and SSH should remain separate from public game ports and be allowed only from an administrative range or an internal path.

A Stateful VM Execution Node

World and plugin state lived on the VM’s persistent disk. The recovery function therefore restarted the stopped VM instead of assembling a replacement. The Terraform configuration used the legacy preemptible option with automatic restart disabled.

resource "google_compute_instance" "minecraft" {
  name = "minecraft-server"
  machine_type = var.machine_type
  zone = var.zone
  tags = ["minecraft-server"]

  scheduling {
    preemptible = true
    automatic_restart = false
  }

  network_interface {
    network = var.network
    access_config {
      nat_ip = google_compute_address.minecraft.address
    }
  }
}

The current representation is provisioning_model = "SPOT". Unlike legacy preemptible VMs, Spot VMs have no 24-hour limit, but they can still be reclaimed at any time and provide neither an SLA nor automatic restart. A stopped Spot VM can be started again when capacity is available, so the same external recovery principle still applies. The termination action makes the lifecycle explicit: use STOP to preserve the VM and DELETE for disposable instances.

scheduling {
  provisioning_model = "SPOT"
  instance_termination_action = "STOP"
  automatic_restart = false
  on_host_maintenance = "TERMINATE"
}

Restarting the same VM is simple for a single Minecraft server that must retain local disk state and a fixed address. A stateless proxy or an interchangeable image is a better fit for a Managed Instance Group. In either design, keeping the world only on one boot disk makes deletion and recreation dangerous, so data disks and backups need explicit boundaries.

Between RUNNING and Game Readiness

After Compute Engine reports RUNNING, the OS, startup script, systemd, and JVM still need to initialize. The original startup script installed packages, restored server files, created service units, and started them in one pass. A slow download or failed extraction could therefore leave VM status and game readiness far apart.

sequenceDiagram
  accTitle: From starting the VM to preparing to connect to Minecraft
  accDescr: After the Compute API starts the VM, the operating system, startup script, systemd, JVM, and plugins are prepared in order, and external status inquiry becomes the final completion condition.
  participant API as Compute API
  participant VM as VM and OS
  Participant Boot as startup script
  participant SD as systemd
  participant Java as Purpur JVM
  participant probe as external probe
  API->>VM: instances.start
  VM-->>API: RUNNING
  VM->>Boot: metadata startup script
  Boot->>SD: unit installation/enable
  SD->>Java: ExecStart
  Java->>Java: Load world/plugin
  Java-->>SD: Keep process running
  Probe->>Java: Minecraft status
  Java-->>Probe: protocol response

systemd owns the process lifecycle. Restart=always is useful when the JVM exits unexpectedly, but it also repeats a process that immediately fails because of bad configuration. Readiness should not stop at systemctl is-active, a Java PID, or a listening socket. It should include the server’s ready log and a Minecraft protocol response.

[Service]
WorkingDirectory=/opt/minecraft
ExecStart=/usr/bin/java -Xms8G -Xmx8G -jar purpur.jar nogui
Restart=always
RestartSec=10

The original unit ran as root. A dedicated Unix user and group would limit which files a plugin or an RCON-related failure could modify. A clearer boundary gives that user ownership of the world, plugins, and logs while keeping systemd units and operational scripts under root control.

Recovery Control Plane Outside the VM

When preemption stops the VM, Minecraft, the Discord bot, timers, and systemd all stop with it. The executor that observes the shutdown and starts the VM must therefore live elsewhere. The original path converted Compute Engine activity logs into a log-based metric, sent a Monitoring notification to Pub/Sub, and invoked a first-generation Cloud Function that called the Compute API.

flowchart LR
  accTitle: VM preemption self-healing control path
  accDescr: Compute Engine shutdown log passes through Logging and Monitoring and is delivered to Pub/Sub, and an external function calls the Compute API to restart the VM.
  A[Compute Engine shutdown logs] --> B[Logging-based metrics]
  B --> C[Monitoring notification policy]
  C --> D[Pub/Sub topic]
  D --> E[recovery function]
  E --> F[Compute Engine API]
  F --> G[boot same VM]
  G --> H[systemd and Minecraft]

This path has four different success levels. Log detection means the shutdown signal was found. Function success means the API-calling code finished. VM RUNNING means the guest is executing. Minecraft readiness means service has recovered from a player’s perspective. Notifications should name the level they report.

A current implementation can use an event-driven Cloud Run function with Pub/Sub or an Eventarc trigger. Eventarc delivery is not exactly once, so the function still needs idempotent state checks and an external readiness probe.

Preserving the World Outside the VM

The backup timer requested a save through RCON, copied the world and configuration into a working directory, compressed it with tar and pigz, and uploaded the archive to Cloud Storage. A Scheduler job and a separate function deleted old objects.

flowchart LR
  accTitle: World Backup and Remote Preservation Path
  accDescr: systemd timer runs the backup script, saves RCON, compresses the working copy, and uploads it to Cloud Storage.
  T[systemd timer] --> S[backup.sh]
  S --> R[RCON save-all]
  R --> W[running world directory]
  W --> C[local working copy]
  C --> A[tar.gz archive]
  A --> G[Cloud Storage]
  G --> L[Retention period summary]

A working copy is not a snapshot. If Minecraft writes while cp -r walks multiple files, those files can represent different moments. A safer sequence uses save-off, save-all flush, copy, and save-on, with cleanup that restores saving even after failure. Contract mismatches—such as creating tar.gz while the restore path calls unzip—must be caught before an incident.

If retention depends only on object age, a Cloud Storage lifecycle Delete rule can replace Scheduler, Pub/Sub, and a cleanup function. A separate job remains useful when retention depends on per-object state such as restore verification.

Original Design and Current Baseline

AreaOriginal operationCurrent baseline
VMlegacy preemptible = trueSpot provisioning model and explicit shutdown behavior
Recovery functionFirst-generation Cloud Function, Python 3.10Cloud Run function, Eventarc, dedicated service account
PermissionsProject-wide Instance AdminRole limited to instance read, start, and operation read
SecretsTerraform variables and state, metadata, .envSecret Manager versions and runtime access
BootInstallation and downloads repeated on every bootDependencies fixed in an image or disk, short initialization
ReadinessVM RUNNING and Discord notificationExternal Minecraft protocol probe
Backup retentionScheduler, Pub/Sub, delete functionStorage lifecycle for a simple age policy

Terraform should declare secret IDs and access relationships rather than secret values. Attach a dedicated service account to the VM and review its cloud-platform OAuth scope together with IAM roles. IAM can still deny access under a broad scope, but a broadly privileged default service account removes that protection.

References

Share

Share

Image preview