Developer self-service is great!
Imagine being a developer in need of a new cloud environment to test your code. You browse to an Internal Developer Platform (IDP), find the cloud environment offering your platform team has prepared, and click on “Order now”.
A few minutes later you get a notification from the friendly Platform BotTM on Slack. Platform BotTM informs you that your new cloud environment is ready, providing you with a link to where you can reach it.
In this blog post I will show how to set up a developer self-service solution using Terraform and Waypoint. But we will not be provisioning cloud environments, at least that is not the main point: instead we will provision a Minecraft world (a server) and then infrastructure (bridges, houses, pyramids, …) in this world.
Minecraft should be familiar to most people. If not, think of it almost like Lego in a computer world. There are some striking differences to Lego though, but at least to me the joyful feeling of building something in Minecraft is the same as when I was young and build things in Lego.
To put it another way: Minecraft is a game where you mine blocks and build stuff using these blocks.
Waypoint (or HCP Waypoint) is one of the more recent additions to the HashiCorp product portfolio. It’s an IDP solution backed by Terraform modules. You create Terraform modules (called no-code modules) and connect them to HCP Waypoint to allow developers to provision infrastructure easily in a graphical interface (the IDP!).
Waypoint comes with its own domain of concepts that can be hard to grasp at first. However, explaining them with Minecraft makes things easier:
- We will create a Waypoint Template that defines what our self-service infrastructure consists of. In this demo we will have a template for a Minecraft server. This template defines what a Minecraft server looks like. The template is a layer on top of a no-code Terraform module.
- A Waypoint application is an instance of this Minecraft server based on the template.
- We will create a number of Waypoint Add-Ons to extend the functionality of our applications. An add-on is based on another Terraform no-code module. Add-ons can be enhancements to the application, things like databases, caches, message queues. In this demo we will build add-ons for structures in our Minecraft world: houses, bridges, castles. This is how the add-ons extend our base infrastructure (the Minecraft world).
- Waypoint actions are used for day-2 operations on our infrastructure. In our Minecraft world, Waypoint actions are things like broadcasting a message to all players, setting the weather, setting the current world time, and taking a backup of the server.
- A Waypoint agent is a small service you install and run anywhere you require it. It allows you to run actions on your infrastructure that is not directly accessible from the outside. For instance, a Waypoint action will be installed on our Minecraft server that can interect directly with the server.
In the following sections I will go through the high-level steps required to set everything up.
This work is based and inspired by the work by Mark Tinderholt, Terraforming Minecraft on Azure, that he also presented live on HashiConf 2024 in Boston.
Note: Mark uses Packer for building a server image. I decided to not use Packer for my demo in the end.
The reason for this is that I wanted to set up a self-contained demo where I used HCP Packer and a corresponding data source querying the correct image ID in my Terraform code. This worked great! However, in the end it turned out that there is no way to enable the HCP Packer registry for a new HCP project using Terraform.
Using plain Packer could have worked, but would have required some additional steps that I wanted to avoid.
So, to make my life easier I didn’t use Packer, and now the demo is self-contained and fast to provision on a moments notice.
You can find an accompanying GitHub repository for the code that I will explain in this blog post:
An accompanying repository for my presentation at the HashiCorp User Group Göteborg on May 22, 2025
Prerequisites#
To follow along in this walkthrough you will need:
- A Minecraft account. I will be using the Java edition of Minecraft.
- An organization with Premium tier license on HCP Terraform.
- An organization on HCP.
- One or more AWS Route 53 Hosted Zones. With some work you can add code to use DNS zones from other cloud providers.
Note, HCP Waypoint actions went GA at HashiDays 2025 in London. Along with the GA the license requirements were changed. You now need HCP Terraform Premium. The Plus tier is no longer enough.
I think this change is a bit unfortunate, but here we are.
As you will learn later, each block in Minecraft is one Terraform resource. This means you will be managing many Terraform resources if you install many add-ons in your Minecraft world. This will result in a large cost for managed resources if you leave them for a long time in your HCP Terraform environment.
As an example, I install a relatively small bridge add-on and it ended up being around 3000 blocks.
You have been warned!
Configure HCP Terraform, HCP Waypoint, and GitHub for developer self-service#
Since I’ve been writing more than one post on Waypoint lately I already have a good walkthrough on how to configure HCP Waypoint and HCP Terraform.
I will repeat the important details below.
There is currently only one option for how to connect HCP Waypoint and HCP Terraform: using a token. This token is provided to Waypoint to allow it to interact with the required resources on HCP Terraform. I create a dedicated team on HCP Terraform and create a token for the team that I then use to configure the connection to Waypoint:
resource "tfe_team" "waypoint" {
name = "waypoint"
}
resource "tfe_team_token" "waypoint" {
team_id = tfe_team.waypoint.id
}
resource "hcp_waypoint_tfc_config" "default" {
tfc_org_name = var.tfe_organization
token = tfe_team_token.waypoint.token
}
I also configure a dedicated project on HCP Terraform where I give the Waypoint team maintainer access:
resource "tfe_project" "waypoint" {
name = "waypoint"
description = "Project for Waypoint workspaces"
}
resource "tfe_team_project_access" "waypoint" {
project_id = tfe_project.waypoint.id
team_id = tfe_team.waypoint.id
access = "maintain"
}
Both Waypoint templates and add-ons are built on Terraform no-code modules. To create these modules we need to connect our VCS system (GitHub in my case) to HCP Terraform.
ATerraform no-code modules and regular Terraform modules differ only in that no-code modules must configure the providers that it uses. For normal modules you configure the providers in the root module and pass them to the modules. No-code modules must be able to stand on their own.
This does not mean you have to hardcode credentials in the no-code modules, but you need to have a plan for how they get their credentials. In my code I set up credentials (OIDC connections) using variable sets that are applied to the Waypoint project on HCP Terraform. In this way I only configure these settings once, and all workspaces created by Waypoint can use them.
The full details are outside the scope of this blog post. If you have at least once set this connection up and have installed the HCP Terraform app on the GitHub side, then you can re-instate the connection easily using the following Terraform resource:
resource "tfe_oauth_client" "github" {
name = "GitHub Organization (${var.github_organization})"
api_url = "https://api.github.com"
http_url = "https://github.com"
oauth_token = var.github_token
service_provider = "github"
organization_scoped = true
}
You need to provide a GitHub PAT token in the github_token
variable. In this case I set up a connection to a GitHub organization, but it works equally well for a personal account. I could have created the GitHub app using code as well, but I usually have this app installed so I made my life easier. For the full details, see the HCP Terraform documentation.
Now we have connected HCP Terraform with HCP Waypoint, and we have connected HCP Terraform to our GitHub organization where we will store the code for the Terraform no-code modules. We are ready to start building the Waypoint template for our Minecraft server!
Build Waypoint templates and add-ons#
To build a Waypoint template or a Waypoint add-on we will need to configure the following:
- A Terraform no-code module for the template or add-on. This includes writing the configuration for whatever infrastructure that should be part of the module.
- A GitHub repository for the no-code module.
- The Waypoint template or add-on resource itself.
The Terraform code for the Minecraft server (our Waypoint template) and the different add-ons (infrastructure in the Minecraft world) will be covered in the next section, in this section we are just concerned with configuring the resources on HCP and GitHub.
The steps covered here show how the Waypoint template for a Minecraft server is configured. Configuring add-ons follow the same basic steps, with one difference. Where the difference is will be pointed out when it’s time.
I use the GitHub provider for Terraform to create GitHub repositories and add all the module files to it:
resource "github_repository" "server" {
name = "waypoint-minecraft-server"
visibility = "private"
description = "Demo repository for a Minecraft server on AWS (for HCP Waypoint)"
}
locals {
server_path = "${path.module}/repos/minecraft-server"
server_files = fileset(local.server_path, "**")
server_file_paths = { for f in local.server_files : f => "${local.server_path}/${f}" }
}
resource "github_repository_file" "server" {
for_each = local.server_file_paths
repository = github_repository.server.name
file = each.key
content = file(each.value)
}
If you want additional details for how I create repositories like this, see my earlier blog post specifically covering this topic.
As mentioned before, a no-code module is more or less the same as a normal module. You first create the module like any other module. Here I use the tfe
provider to create the module in my private registry:
resource "tfe_registry_module" "server" {
name = "waypoint-minecraft-server"
vcs_repo {
display_identifier = github_repository.server.full_name
identifier = github_repository.server.full_name
oauth_token_id = tfe_oauth_client.github.oauth_token_id
branch = "main"
}
initial_version = "1.0.0"
depends_on = [
github_repository_file.server,
]
lifecycle {
ignore_changes = [
name,
]
}
}
Note the following details:
- I add a
depends_on
for the GitHub repository files (github_repository_file.server
). If I would skip this, then it is possible that the module is created with version1.0.0
before all module source files are in place. - I use my connection to GitHub (i.e. the
tfe_oauth_client.github
resource) in thevcs_repo
block. - I tell Terraform to ignore changes to the
name
attribute. This is due to a bug (I suppose?) in the provider. The name is not returned back with the same value that you set.
Now I can enable the module for no-code provisioning:
resource "tfe_no_code_module" "server" {
registry_module = tfe_registry_module.server.id
version_pin = "1.0.0"
variable_options {
name = "domain"
type = "string"
options = local.hosted_zone_names
}
}
I’ve added a variable_options
block for a variable called domain
. This variable will be used to create a DNS record for the Minecraft server (e.g. my-mc-server.<domain>
). I read my available AWS Route 53 hosted zone names using the following code:
data "aws_route53_zones" "all" {}
data "aws_route53_zone" "all" {
for_each = toset(data.aws_route53_zones.all.ids)
zone_id = each.value
}
locals {
hosted_zone_names = [for zone in data.aws_route53_zone.all : zone.name]
}
The first data source aws_route53_zones.all
is used to get all my available hosted zone IDs. Unfortunately only the IDs are returned and not the names, so we must get the names using an additional aws_route53_zone.all
data source. Finally, I format the data in the hosted_zone_names
local value. This local value was then used when configuring the module for no-code provisioning.
The final resource that must be configured is the Waypoint template itself:
resource "hcp_waypoint_template" "server" {
name = "minecraft-server"
summary = "A Minecraft server running on AWS EC2"
description = "A self-service provisioned Minecraft server on AWS EC2. Runs in a dedicated VPC."
terraform_project_id = tfe_project.waypoint.id
labels = ["minecraft", "aws"]
terraform_no_code_module_source = "${tfe_registry_module.server.registry_name}/${var.tfe_organization}/${tfe_registry_module.server.name}/${tfe_registry_module.server.module_provider}"
terraform_no_code_module_id = tfe_no_code_module.server.id
use_module_readme = true
variable_options = [
{
name = "domain"
variable_type = "string"
user_editable = true
options = local.hosted_zone_names
}
]
# currently you can't create agent based actions using Terraform
# so these are added manually afterwards, so we must ignore changes to the actions argument
lifecycle {
ignore_changes = [
actions,
]
}
depends_on = [
hcp_waypoint_tfc_config.default,
]
}
A few details to note here:
- I configure the same variable options for the
domain
variable as in the no-code module configuration. - I ignore changes to the
actions
argument, because actions are not fully supported with Terraform. We will later configure actions manually in the portal, so to avoid any issues it is best to ignore changes to this argument here. - I tell Terraform to
depends_on
the connection between HCP Waypoint and HCP Terraform to avoid any issues where this connection is created/removed before the corresponding operation on this resource.
That is it! We have configured the Minecraft server template in Waypoint.
When you create a Waypoint add-on, the only difference is the last resource. Instead of creating a hcp_waypoint_template
resource you will create a hcp_waypoint_add_on_definition
resource. As an example, I have an add-on for a bridge in the Minecraft world. The add-on resource is configured like this:
resource "hcp_waypoint_add_on_definition" "bridge" {
name = "minecraft-bridge"
summary = "Create a Minecraft bridge"
description = "Create a Minecraft bridge at a location (x,y,z) in an existing Minecraft world."
terraform_project_id = tfe_project.hug.id
labels = ["minecraft", "aws"]
terraform_no_code_module_source = "${tfe_registry_module.bridge.registry_name}/${var.tfe_organization}/${tfe_registry_module.bridge.name}/${tfe_registry_module.bridge.module_provider}"
terraform_no_code_module_id = tfe_no_code_module.bridge.id
variable_options = [
{
name = "start"
user_editable = true
variable_type = "string"
options = []
},
{
name = "end"
user_editable = true
variable_type = "string"
options = []
}
]
depends_on = [
hcp_waypoint_tfc_config.default,
]
}
The Minecraft server#
The Minecraft server template consists of the following resources:
- A VPC with a subnet.
- An EC2 instance with a security group.
- An S3 bucket for server backups.
- An IAM role and associated resources.
The most interesting component here is the EC2 instance itself. As mentioned previously in this post, I did not use Packer to build a server image template. I base my server on an Ubuntu AMI that I find using the following data source:
data "aws_ami" "ubuntu" {
filter {
name = "name"
values = ["ubuntu/images/*ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
most_recent = true
owners = ["099720109477"]
}
I configure the server and install everything I need using the cloudinit
provider for Terraform to build the cloudinit configuration I provide in the userdata for my EC2 instance. The full details are messy and would not add any real value to this blog post, but you can see it in the accompanying GitHub repository. However, of particular interest is the download and installation of the Minecraft server files:
#!/bin/bash
LAUNCHER_META_URL=https://launchermeta.mojang.com/mc/game/version_manifest.json
DATA=$(curl -s "$LAUNCHER_META_URL" | jq .)
LATEST_RELEASE=$(echo "$DATA" | jq -r .latest.release)
RELEASE_META_URL=$(echo "$DATA" | jq -r --arg LATEST_RELEASE "$LATEST_RELEASE" '.versions[] | select(.id == $LATEST_RELEASE) | .url')
DOWNLOAD_URL=$(curl -s "$RELEASE_META_URL" | jq -r .downloads.server.url)
wget "$DOWNLOAD_URL" -O /home/mcserver/minecraft_java/server.jar
chown -R mcserver: /home/mcserver/
In essence, I query the Minecraft Launcher metadata URL to find the latest release and its download URL. Then I get the file using wget
and move it to the correct location.
In my cloudinit scripts I also create the necessary configuration files for Minecraft. Of particular interest is that I enable RCON (remote console) to allow me to intereact with the Minecraft server from the outside.
# snippet of the Minecraft server.properties file
enable-rcon=true
rcon.password=${hcp_vault_secrets_secret.minecraft_password.secret_value}
rcon.port=25575
As you can see here, I am using Vault secrets to manage the RCON password. The details of this is left out from this blog post, but as usual you can find everything in the GitHub repository.
I also install an RCON-CLI tool that simplifies the interaction with my Minecraft server via RCON:
DOWNLOAD_URL=https://github.com/itzg/rcon-cli/releases/download/1.7.0/rcon-cli_1.7.0_linux_amd64.tar.gz
wget $DOWNLOAD_URL -O rcon.tar.gz
tar -xvf rcon.tar.gz
mv rcon-cli /usr/local/bin
Finally, I also install the HCP CLI. This is because I want to run a Waypoint agent on the machine (see the section on actions later in this post).
The server itself is a normal aws_instance
resource:
resource "aws_instance" "server" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.xlarge"
subnet_id = aws_subnet.public.id
user_data_base64 = data.cloudinit_config.server.rendered
iam_instance_profile = aws_iam_instance_profile.server.name
vpc_security_group_ids = [aws_security_group.server.id]
lifecycle {
ignore_changes = [user_data_base64]
}
}
Provisioning blocks in the Minecraft world#
The Waypoint add-ons provision infrastructure in the Minecraft world. To achieve this I am using the Terraform provider for Minecraft:
This provider is used like any other provider:
terraform {
required_providers {
minecraft = {
source = "HashiCraft/minecraft"
version = "0.1.1"
}
}
}
provider "minecraft" {
address = "https://<minecraft server url>:25575"
password = "<RCON password>"
}
The provider needs the RCON password that you configure for the Minecraft server. In the sample code repository for this blog post you will see that I am reading the password from HCP Vault Secrets. However, you can use environment variables or any other approach you have to provide secret values to your Terraform configurations.
This provider has a single resource type called minecraft_block
:
resource "minecraft_block" "stone" {
material = "minecraft:stone"
position = {
x = 12
y = 65
z = 100
}
}
The minecraft_block
resource type takes a position
in the form of (x,y,z) coordinates and a material. You can also configure things like orientation in the material
argument. However, I leave the details of this out from this blog post. To learn about this read up on how to place blocks using RCON.
For all the add-ons I create I first construct local values for the coordinates of all the blocks I want to place. Each block is configured as a string using the format x;y;z;material
. Then I use a for_each
to go through each block:
resource "minecraft_block" "house" {
for_each = toset(local.coordinates)
material = split(";", each.value)[3]
position = {
x = tonumber(split(";", each.value)[0])
y = tonumber(split(";", each.value)[1])
z = tonumber(split(";", each.value)[2])
}
}
For my house add-on, a small subset of all the coordinates that I create looks like this:
locals {
origin = {
x = tonumber(split(",", var.origin)[0])
y = tonumber(split(",", var.origin)[1])
z = tonumber(split(",", var.origin)[2])
}
coordinates = [
# front
"${local.origin.x + 0};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 1};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 2};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 3};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 4};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 5};${local.origin.y - 2};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 0};${local.origin.y - 1};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 1};${local.origin.y - 1};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 2};${local.origin.y - 1};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 3};${local.origin.y - 1};${local.origin.z + 0};minecraft:stone",
"${local.origin.x + 4};${local.origin.y - 1};${local.origin.z + 0};minecraft:stone",
# ... many more ...
]
}
The house is a bit special, I manually created all the coordinates. For my pyramid add-on I use a different approach, I compute the coordinates:
locals {
input_coordinate = split(",", var.origin)
origin = {
x = tonumber(local.input_coordinate[0])
y = tonumber(local.input_coordinate[1])
z = tonumber(local.input_coordinate[2])
}
width = var.width
height = floor(local.width / 2) + 1
auto_base = compact(flatten([
for h in range(local.height) : [
for x in range(h, local.width - h) : [
for z in range(h, local.width - h) : x == h || x == local.width - h - 1 || z == h || z == local.width - h - 1 ? "${local.origin.x + x},${local.origin.y + h},${local.origin.z + z}" : null
]
]
]))
}
I will not try to explain this code. Suffice to say it creates a pyramid of a given width passed in as a variable. The width is the legth of one side of the base of the pyramid.
Build Waypoint actions for day-2 operations#
At the time of writing there is a PR draft in the HCP provider repository on GitHub that will add support for actions to Terraform. You can see the status of this PR here.
With this change you will be able to create an agent groups as hcp_waypoint_agent_group
resources, and reference them when you create actions using hcp_waypoint_action
resources.
Now we can return to how the AWS EC2 instance (the Minecraft server) was configured. In the userdata script there were a few steps specifically related to Waypoint:
- Install the HCP CLI (see the docs for how to do this).
- Configure the HCP CLI authentication files (I’m using a workload identity federation on HCP to allow my EC2 Minecraft server instance to access HCP).
- Start the Waypoint agent through the HCP CLI (I am setting the agent up as a service on my Ubtunu instance).
I’ve previously written a deep-dive on Waypoint actions, so I will not repeat the full details here.
The actions I have created are:
- Say: broadcast a message to all the players on the Minecraft server (the message is passed as a variable to the action).
- Weather: set the current weather for a given duration (the weather and the duration are passed as variables to the action).
- Time: set the current time in the world (the time value is passed as a variable to the action).
- Backup: perform a backup of the Minecraft world and export the backup to an S3 bucket.
Of these actions, the last one (Backup) is the most involved. We can take a look at a snippet of the Waypoint agent configuration file for the backup action:
group "minecraft" {
# ... other actions left out
action "backup" {
operation {
run {
command = ["rcon-cli", "--", "say", "A server backup will be performed in 3 minutes!"]
}
}
operation {
run {
command = ["sleep", "120"]
}
}
operation {
run {
command = ["rcon-cli", "--", "say", "A server backup will be performed in 1 minute!"]
}
}
operation {
run {
command = ["sleep", "60"]
}
}
operation {
run {
command = ["rcon-cli", "--", "save-all", "flush"]
}
}
operation {
run {
command = ["./backup.sh", "${ application.outputs.bucket }"]
}
}
operation {
run {
command = ["rcon-cli", "--", "say", "Backup complete!"]
}
}
}
}
You can see that this action involves seven operation
blocks. There is first a message saying that the server will be backed up in three minutes, followed by a two-minute sleep, followed by another message sayind the server will be backed up in one minute, followed by a sleep for one minute. Then the actual backup takes place (rcon-cli save-all flush
), and then the backup is exported to S3. The steps for the export are hidden in the backup.sh
script:
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: $0 <s3-bucket-name>"
exit 1
fi
S3_BUCKET_NAME="$1"
DIR_TO_ARCHIVE="/home/mcserver/minecraft_java/world"
DATE_DIR=$(date +%F)
TIME_NAME=$(date +%H%M%S)
ARCHIVE_NAME="${TIME_NAME}.tar.gz"
S3_PATH="s3://${S3_BUCKET_NAME}/backups/${DATE_DIR}/"
tar -czf "$ARCHIVE_NAME" "$DIR_TO_ARCHIVE"
aws s3 cp "$ARCHIVE_NAME" "$S3_PATH"
rm "$ARCHIVE_NAME"
It simply creates an archive of the backup directory and copies it to S3 before it removes the local archive file.
You might have realized that I could have performed all the steps of the backup action in the Bash script. However, I like the clarity of using operation
blocks, so I mixed the two approaches a bit in this action. For more involved actions you will likely stick to using scripts.
Take the solution for a spin#
Our developers are eager to get started playing Minecraft and no longer do real work!
One of the developers (let’s call her Jane) signs in to HCP and goes to Waypoint. Once in the Waypoint starting page Jane clicks on Applications in the menu on the left and then on Create an application:
Jane selects the Minecraft Server template and clicks on Next:
She configures the template with a Name and she selects the umbrellasecurity.cloud domain for her server before she clicks on Create application:
Once the application is created (this takes a few minutes) she goes to the Outputs tab and copies the public_dns output for her Minecraft server:
Jane eagerly opens her Minecraft client and adds the server located on the public DNS she copied (the steps for how to do this in the Minecraft server are left out of this blog post). She connects to the server and can start playing:
Jane wants to install a Waypoint add-on for a house. She finds a good spot in the world, opens the F3 menu and copies the coordinate where the house will be placed:
Back in Waypoint Jane clicks on Add-ons in the menu on the left:
In the Available add-ons menu she finds the house add-on and clicks on Install:
She enters the coordinates comma-separated into the origin input variable and then she clicks on Install:
After a short while, the house magically appears in her Minecraft world - block by block!
She installs a few other add-ons and runs through a few actions. After a while she is tired and no longer want to keep her server. She uninstalls all the add-ons she previously installed, and then deletes her application. This will in turn delete the corresponding workspaces on HCP Terraform and all the provisioned resources.
Summary#
HCP Waypoint is a nice frontend for Terraform no-code modules.
Once you understand how to design no-code modules you can easily build a lot of functionality for your developers and expose it through HCP Waypoint.
In this blog post we touched on the steps required to set up self-service provisioning for Minecraft servers on AWS together with self-service provisioning of infrastructure (e.g. bridges and houses) in the Minecraft world.
One thing I would love to see is a separate entrypoint to HCP Waypoint. What I mean by that is that I would like to be able to expose HCP Waypoint as a standalone service. Perhaps also be able to add my own domain name to it. Right now you have to sign in to HCP and click your way to HCP Waypoint.