Terraforming Minecraft on Azure
I recently gave a talk at HashiConf 2024 titled “Let’s Punch Some Trees: Learning Infrastructure as Code Through Minecraft.” It was a lighthearted exploration into the world of Infrastructure-as-Code (IaC), using Minecraft as our application of choice to deploy onto Azure infrastructure. In this scenario, Minecraft didn’t just serve as a game; it acted as a higher-level control plane, offering a unique way to demonstrate how applications or services (not necessarily infrastructure) can be both consumers and providers of control planes to which we provision resources to with Terraform.
Terraform and the Power of Providers
Terraform is a fantastic tool for automating APIs — provided there’s a provider available. It adapts to various APIs, smoothing out the configuration process for the automation engineer by abstracting the underlying implementation details of the target platform. Most of these APIs are RESTful, meaning developers typically interact with them via HTTP web requests, often orchestrated through an SDK or even just curl command. This is standard for public cloud platforms like Azure, AWS, and GCP, and even for platforms like Kubernetes which host their own API control plane and have their own command line interface (CLI) tool that is used to interact with it.
However, Minecraft doesn’t have a REST API. Instead, it uses a protocol called RCON, which allows for remote execution of server commands — not at the operating system level, but at the application server level — at the Minecraft level. This unique characteristic made Minecraft an intriguing choice for demonstrating how Terraform can interact with different types of APIs and protocols in addition to it being a second layer of control plane that Terraform helps us automate.
Building the Minecraft Server with Packer
Our journey begins with building a virtual machine (VM) and installing the Minecraft application server on it. We’ll use Packer to bake an image that includes Minecraft and all its dependencies. Packer provisions a temporary VM, executes commands to install software, moves files around, and updates configurations.
We need to install the Java runtime.
provisioner "shell" {
execute_command = local.execute_command
inline = [
"add-apt-repository ppa:openjdk-r/ppa",
"apt-get update",
"apt-get -y install openjdk-21-jdk"
]
}
Download the latest bits (as of writing v1.21.1)
provisioner "shell" {
execute_command = local.execute_command
inline = [
"DOWNLOAD_URL=https://piston-data.mojang.com/v1/objects/59353fb40c36d304f2035d51e7d6e6baa98dc05c/server.jar",
"wget $DOWNLOAD_URL -O /home/mcserver/${local.server_folder_name}/server.jar",
"chown -R mcserver: /home/mcserver/"
]
}
I setup a Linux Service so that the server can start everytime the machine reboots.
#!/usr/bin/env bash
SERVER_PATH=/home/mcserver/minecraft_java/
/usr/bin/screen -dmS mcserver /bin/bash -c "cd $SERVER_PATH; java -Xmx1024M -Xms1024M -jar server.jar nogui"
/usr/bin/screen -rD mcserver -X multiuser on
/usr/bin/screen -rD mcserver -X acladd root
Essentially I am running this command to start the server:
java -Xmx1024M -Xms1024M -jar server.jar nogui
I found out that you actually need to start Minecraft to have it generate the server configuration files. I could eliminate the need for this with a little bit better planning in my Packer template and my “last mile configuration” in Terraform.
provisioner "shell" {
execute_command = local.execute_command
inline = [
"systemctl enable mcjava",
"systemctl start mcjava",
"sleep 60",
"systemctl stop mcjava",
"systemctl disable mcjava"
]
}
I basically enable the Minecraft Server and start it, wait 60 seconds, and then turn it off and disable it (we’ll enable it later in the Terraform code).
I also have to update the EULA to say that we agree with it.
provisioner "file" {
source = "./files/eula.txt"
destination = "/tmp/eula.txt"
}
provisioner "shell" {
execute_command = local.execute_command
inline = [
"cp /tmp/eula.txt /home/mcserver/${local.server_folder_name}/"
]
}
I use two provisioners, one to copy my version of the eula.txt file to a temp directory and another to move it to the correct location where I plan on installing the Minecraft application server. As you can see the EULA file is not very complex. Here are the contents of the eula.txt file:
eula=true
Minecraft’s server configuration relies heavily on files like server.properties, which contains a list of key-value pairs, and other files controlling role-based access control like allowlist.json, ops.json, and permissions.json. During the image baking process, we sometimes embed placeholder values into these configurations. This strategy allows us to replace those values at runtime, getting us 95% of the way there with the image bake and leaving the final 5% for when we launch the VMs in our target cloud platform.
I do the same thing with my own copy of the server.properties file.
provisioner "file" {
source = "./files/server.properties"
destination = "/tmp/server.properties"
}
provisioner "shell" {
execute_command = local.execute_command
inline = [
"cp /tmp/server.properties /home/mcserver/${local.server_folder_name}/"
]
}
While baking most configuration into the image is efficient, you might not want to include everything. For ongoing management and rapid updates without rebooting VMs, tools like Ansible can be invaluable. This approach is particularly useful when you anticipate continuous configuration refinement over time.
Handling Stateful Workloads
When dealing with stateful workloads, it’s critical to ensure that existing data is reestablished upon launch otherwise you might be susceptible to downtime or worse — data loss. In Minecraft’s case, this data resides in the worlds directory. We need to consider two scenarios:
- ”New New”: Launching the VM for the first time in this environment.
- ”Back from the dead”: Launching a new VM when another VM was previously running in the environment.
Depending on your Recovery Time Objectives (RTO) and Recovery Point Objectives (RPO), we might rely on periodic backups or implement a more sophisticated recovery solution to enhance the availability of our application or service.
Provisioning Azure Infrastructure with Terraform
With our image ready, we move on to provisioning the Azure infrastructure using the azurerm Terraform provider. Inside Packer, we setup a Linux service to manage the start and stop of the Minecraft server, but we didn’t configure it to launch automatically. Instead, we handle this in our Terraform code, which provisions the long-lived VM where the service will run.
To ensure the Minecraft service restarts if the VM reboots, we use Azure’s Virtual Machine Extension resource — which has many types. We’ll use one type of these called the “Custom Script Extension” — of which you are only allowed one. This extension attaches to the VM and executes a script to finalize the setup of our Linux service.
In this first layer of our infrastructure stack, we provision all necessary Azure resources. Which includes a KeyVault to securely store secrets such as the SSH Key we generated for administrative access and our Virtual Network which includes a Network Security Group (NSG) that allows traffic in on the ports that Mincraft needs. The Minecraft client (i.e., the game itself) connects on port 25565 while the next layer of Terraform communicate on port 25575.
In order to ensure this next layer of Terraform will execute successfully we need the following outputs.
- Public IP Address: Where the Minecraft server will be accessible.
- Password Injection: Injected into the Minecraft server configuration via the custom script extension.
These outputs provide endpoint and authentication details that are needed for the Minecraft Terraform providers. When creating Terraform Stacks by layering components, you almost always need both of these two pieces of information: an endpoint and authentication details. Of course, they vary in form depending on the target platform in your secondary layer.
Manipulating the Minecraft World with Terraform
Now comes the fun part: manipulating the state of the Minecraft world. Using the Minecraft Terraform provider, we can place blocks within Minecraft’s world. A block represents a single cube in three-dimensional space, located using coordinates (x, y, z). Blocks can be various materials — wood, stone, metal, and more exotic types.
You can see the above example is placing a Stone block at location (-5, 68, -15). You might need to explore your world a bit before you find a good starting place to start Terraforming your structures.
resource "minecraft_block" "stone" {
material = "minecraft:stone"
position = {
x = -5
y = 68
z = -15
}
}
Just as we group primitive resources into modules in Terraform to create more complex and useful infrastructure, we can do the same in Minecraft. Placing an individual block isn’t particularly exciting, but combining many blocks allows us to build structures with real utility, like a house or even a castle.
Building a House: The Monolithic Module
When starting out, we might create a simple structure — a house — to provide shelter from the elements (and pesky skeleton archers). For this, a monolithic module suffices, encompassing everything our house needs: the floor, walls, and roof. Since the structure is relatively simple, we don’t need to compartmentalize elements into separate modules.
I designed my house module with a very simple interface:
module "house" {
source = "./modules/house"
start_x = 70
start_y = 71
start_z = -28
house_width = 5
house_length = 5
wall_height = 3
ceiling_type = "minecraft:gold_block"
wall_type = "minecraft:gold_block"
floor_type = "minecraft:gold_block"
}
It takes in some simple parameters such as a start position (x, y, z), dimensions (length * width * height) and what material to use for each part of the house.
Inside the module I do some fancy logic in locals to produce a list of locations where I want to place blocks for each component of my house.
locals {
floor_positions = flatten([
for x in range(var.start_x, var.start_x + var.house_width) : [
for z in range(var.start_z, var.start_z + var.house_length) : {
x = x
y = var.start_y
z = z
}
]
])
}
The above code is going to keep the y axis constant while it iterates across the other two creating a two dimensional plane of my house’s desired length and width. Then I iterate over this collection using the minecraft_block resource.
resource "minecraft_block" "floor" {
for_each = { for pos in local.floor_positions : "${pos.x}_${pos.y}_${pos.z}" => pos }
material = var.floor_type
position = {
x = each.value.x
y = each.value.y
z = each.value.z
}
}
I convert the list into a map with a key generated from the unique (x, y, z) coordinate of the block.
I repeat this process for the four walls and [flat] ceiling of my house. I could create more sophisticated house with a A-frame roof for example but I think next I’ll prioritize creating windows and doors — pretty critical features in the construction of a house.
Building a Castle: Modular Complexity
As our ambitions grow and we take on the challenge of building more advanced structures like a castle for example. The complexity increases. However, we can counteract this complexity by identifying common patterns within these more sophisticated architectures.
When we think about castles, we can easily recognize some of the common patterns — walls with lookout spots, tall towers (round or square), and other features designed for defense and grandeur. Here, we find opportunities to create reusable modules:
- Wall Modules: Sections of walls that can be repeated and connected.
- Tower Modules: Different styles of towers that can be placed at strategic points.
- Gate Modules: Entrances with mechanisms like drawbridges or portcullises.
By breaking down the castle into these sub-modules, we can build and scale our solution more efficiently and manage complexity more effectively. I hope you see where I am going with this. While, Terraforming Minecraft is pretty neato it highlights foundational principals that all good Terraform module authors use when they design their modules for cloud architectures — replacing towers and portcullises with IAM policies and network connectivity.
Conclusion
Through this exercise, I hope you see how Terraform and Infrastructure-as-Code principles can be applied beyond traditional cloud infrastructure in a super fun way using Minecraft. By abstracting and automating the provisioning process — from baking a Minecraft server image with Packer to deploying Azure resources and manipulating the game world — we see enterprise practices play out but within this rather silly context.
This little romp also highlights Terraform’s adaptability in allowing us to interact with various APIs — not just ones in the traditional sense, like the RESTful APIs of the public cloud providers AWS and Azure.
Likewise, the modular approach enhances reusability and scalability to our Infrastructure-as-Code solutions. Whether we’re building simple houses or complex castles, the principles remain the same: start small, identify patterns, and create modules to build faster and smarter.
Check out my code repository (one that I actively continue to evolve and use) that has all the Packer and Terraform code for both Azure and Minecraft.
Happy Azure Terraforming!!!