Automating a Minecraft Server Deployment on AWS

Posted June 1, 2024 by Trevor Roberts Jr ‐ 5 min read

"Hey Dad! Can I have a Minecraft server?" My daughter reached the next level of gaming. I was excited for her, yet uncertain on how to get started. After considering my options, I decided to self-host on AWS...

Introduction

My daughter recently got serious about Minecraft. She requested a server to play with her friends and I. So, I scoured the Internet for economical options. Prices ranged from $3-$8. Given that she plays, at most, 4 hours a week, I figured I could do better with Amazon EC2!

I looked at the t4g.small based on an AWS blog covering such a setup. With a monthly change rate of 30 MB, the monthly cost is $0.40. If my daughter ends up installing a bunch of mods/plugins and/or adds more friends to the servers, I saw that an r8g.large is roughly $2.16 a month. Not bad given the prices that I saw for other offerings online.

Planning the Deployment

The Amazon EC2 instance deployment is fairly straightforward with Pulumi. You can check out that portion of the code and the interesting bits of the userdata script on the GitHub repo. For the code samples below, I focus on automation to control costs: namely, the Lambda function that I use to start and stop the Minecraft server.

The Lambda Function

The Lambda function code examines the triggering event for a key called Action that either has the value of start or stop; the instance is either started or stopped accordingly. There is an environment variable INSTANCE_ID that is set by the Pulumi automation, and the Lambda function uses that variable to determine which instance to manage:

// Event represents the incoming event with an action
type Event struct {
	Action string `json:"action"`
}

// Handler handles the incoming Lambda event
func Handler(ctx context.Context, event Event) (string, error) {
	instanceID := os.Getenv("INSTANCE_ID")
	if instanceID == "" {
		return "", fmt.Errorf("INSTANCE_ID environment variable is not set")
	}
	instanceIDs := []string{instanceID}
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return "", fmt.Errorf("unable to load SDK config, %v", err)
	}
	svc := ec2.NewFromConfig(cfg)
	switch event.Action {
	case "start":
		_, err := svc.StartInstances(ctx, &ec2.StartInstancesInput{
			InstanceIds: instanceIDs,
		})
		if err != nil {
			return "", fmt.Errorf("failed to start instance: %v", err)
		}
		return fmt.Sprintf("Started instance %s", instanceID), nil
	case "stop":
		_, err := svc.StopInstances(ctx, &ec2.StopInstancesInput{
			InstanceIds: instanceIDs,
		})
		if err != nil {
			return "", fmt.Errorf("failed to stop instance: %v", err)
		}
		return fmt.Sprintf("Stopped instance %s", instanceID), nil
	default:
		return "", fmt.Errorf("unknown action: %s", event.Action)
	}
}

The Makefile

I created a Makefile to simplify the compilation and packaging of the Lambda function:

# Makefile for compiling Go Lambda function for ARM architecture
# Define variables
BINARY_NAME = bootstrap
ZIP_NAME = main.zip
GOOS = linux
GOARCH = arm64

# Default target
all: cleanall build zip

# Build target
build:
	GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(BINARY_NAME) main.go

# Zip target
zip: build
	zip $(ZIP_NAME) $(BINARY_NAME)

# Clean target
clean:
	rm -f $(BINARY_NAME)

# Clean target
cleanall:
	rm -f $(BINARY_NAME) $(ZIP_NAME)

Automating the Lambda function deployment with Pulumi

Finally, I added the following code to my Pulumi automation to handle executing the Makefile, creating the Lambda function with required permissions, and cleaning up the Makefile artifacts:

Using the os/exec go module, I run the Makefile to compile my go Lambda function and package it in a zip file.

// Path to the Go Lambda project
lambdaDir := "./lambda"
// Run `make` to build and package the Lambda function
makeCmd := exec.Command("make", "all")
makeCmd.Dir = lambdaDir
makeCmd.Stdout = os.Stdout
makeCmd.Stderr = os.Stderr
if err := makeCmd.Run(); err != nil {
	return fmt.Errorf("failed to build and package Lambda function: %w", err)
}

Later on in the code, I create my Amazon IAM policy and role that the Lambda function will use to start/stop the EC2 instance.

// IAM Role for Lambda
role, err := iam.NewRole(ctx, "lambda-exec-role", &iam.RoleArgs{
	AssumeRolePolicy: pulumi.String(`{
		"Version": "2012-10-17",
		"Statement": [{
			"Effect": "Allow",
			"Principal": {
				"Service": "lambda.amazonaws.com"
			},
			"Action": "sts:AssumeRole"
		}]
	}`),
})
if err != nil {
	return err
}

// Create the policy document with the substituted instance ID
// Use Pulumi's ApplyT method because the instance ID is not known at runtime
// This delays the IAM policy creation until the instance is deployed and the ID is known
instance.ID().ApplyT(func(instanceID string) (string, error) {
	policy := fmt.Sprintf(`{
		"Version": "2012-10-17",
		"Statement": [{
			"Effect": "Allow",
			"Action": [
				"ec2:StartInstances",
				"ec2:StopInstances"
			],
			"Resource": "arn:aws:ec2:%s:%s:instance/%s"
		}]
	}`, region, accountID, instanceID)
	return policy, nil
}).(pulumi.StringOutput).ToStringOutput().ApplyT(func(policy string) (interface{}, error) {
	_, err := iam.NewRolePolicy(ctx, "lambdaPolicy", &iam.RolePolicyArgs{
		Role:   role.ID(),
		Policy: pulumi.String(policy),
	})
	return nil, err
})

_, err = iam.NewRolePolicyAttachment(ctx, "lambda-policy-attach", &iam.RolePolicyAttachmentArgs{
	Role:      role.Name,
	PolicyArn: pulumi.String("arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"),
})
if err != nil {
	return err
}

Next, I deploy the Lambda function

// Create the Lambda function
lambdaFunc, err := lambda.NewFunction(ctx, "minecraft-start-stop", &lambda.FunctionArgs{
	Role:    role.Arn,
	Runtime: pulumi.String("provided.al2023"),
	Handler: pulumi.String("main"),
	Code:    pulumi.NewFileArchive("lambda/main.zip"),
	Architectures: pulumi.StringArray{
		pulumi.String("arm64"),
	},
	Environment: &lambda.FunctionEnvironmentArgs{
		Variables: pulumi.StringMap{
			"INSTANCE_ID": instance.ID(),
		},
	},
})
if err != nil {
	return err
}

Finally, I use Pulumi's ApplyT method with the lambdaFunc resource I created to ensure the Makefile cleanup is only executed after the Lambda function resource successfully deploys.

// Execute lambda binary clean-up only after the Lambda function is created.
_ = lambdaFunc.Arn.ApplyT(func(arn string) (string, error) {
	fmt.Println("All resources created, running post-deployment `make` task...")
	postMakeCmd := exec.Command("make", "cleanall")
	postMakeCmd.Dir = lambdaDir
	postMakeCmd.Stdout = os.Stdout
	postMakeCmd.Stderr = os.Stderr
	if err := postMakeCmd.Run(); err != nil {
		return "", fmt.Errorf("failed to run post-deployment task: %w", err)
	}
	return "", nil
}).(pulumi.StringOutput)

Now, I can either have CloudWatch Events use the Lambda function to start/stop the Minecraft server on a schedule, or I can manually start/stop the instance using AWS CLI syntax similar to the following:

aws --region=us-east-1 lambda invoke \
--function-name minecraft-start-stop-276f830 \
--payload eyJhY3Rpb24iOiAic3RvcCJ9 ./minecraftblogoutput.json

Wrapping Things Up...

In this blog post, we discussed how to deploy a Minecraft server with Pulumi including considerations of which Amazon EC2 instance type to choose for your deployment. Further, we examined instance management automation using AWS Lambda to control your costs when the server is not being used.

If you found this article useful, let me know on BlueSky or on LinkedIn!