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.

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.
| Path | Starting point | Core components | Completion condition | Representative failure |
|---|---|---|---|---|
| Access | Minecraft client | External IP, firewall, Purpur, Geyser, Floodgate | Java and Bedrock protocol responses plus an actual connection | Bind failure, missing TCP or UDP rule |
| Boot | Compute API | OS, startup script, systemd, JVM | Ready log plus an external status response | VM is RUNNING while the service restart-loops |
| Recovery | Shutdown activity log | Logging, Monitoring, Pub/Sub, recovery function | The same VM responds to Minecraft requests again | Manual stop mistaken for preemption, duplicate execution |
| Preservation | systemd timer | RCON, working copy, tar/pigz, Cloud Storage | Remote archive plus an isolated restore test | Files 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
| Area | Original operation | Current baseline |
|---|---|---|
| VM | legacy preemptible = true | Spot provisioning model and explicit shutdown behavior |
| Recovery function | First-generation Cloud Function, Python 3.10 | Cloud Run function, Eventarc, dedicated service account |
| Permissions | Project-wide Instance Admin | Role limited to instance read, start, and operation read |
| Secrets | Terraform variables and state, metadata, .env | Secret Manager versions and runtime access |
| Boot | Installation and downloads repeated on every boot | Dependencies fixed in an image or disk, short initialization |
| Readiness | VM RUNNING and Discord notification | External Minecraft protocol probe |
| Backup retention | Scheduler, Pub/Sub, delete function | Storage 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
- Google Cloud: Spot VMs
- Google Cloud: Create and use Spot VMs
- Google Cloud: Compute Engine provisioning model
- Google Cloud: Cloud Run functions generation comparison
- Google Cloud: Cloud Run function triggers
- Google Cloud: Eventarc retry and duplicate events
- Google Cloud: Pub/Sub notification channels
- Google Cloud: Object Lifecycle Management
- Google Cloud: Compute Engine service accounts
Share
No comments yet. Be the first to leave one.
Pending review