Control a Minecraft Server from Discord
Build a signed Discord remote to start, stop, and check a Minecraft EC2 server.
Introduction
30 Second Summary
Coordinating a shared Minecraft server can turn into a scramble whenever its owner is away from their computer. Players need a quick way to wake the world up or check whether it is ready.
In this project, you will build a secure Discord remote for one existing Minecraft server on AWS. Its slash commands will control the Amazon EC2 instance only after AWS verifies that Discord sent the request.
What You'll Build
You will run /mc-start from Discord while the Amazon EC2 console shows the one approved server move from stopped through pending to running.
By the end of this project, you'll have:
- A live server status response from /mc-status that shows the current EC2 state plus a public IP when one is assigned.
- Safe lifecycle controls that let you watch the approved instance move through its real EC2 states.
- A signed HTTP endpoint through Amazon API Gateway that returns HTTP 401 for an unsigned request before AWS Lambda can control EC2.
- Secret Mission: Discord role permissions that hide the Minecraft commands from unapproved members without changing the Lambda code.
Are there any prerequisites?
You need an AWS account with access to an existing EBS-backed Minecraft EC2 instance whose service starts when the operating system boots. You also need the server owner's approval for lifecycle testing plus permission to install a Discord app in a test server.
Before We Start
This commitment check keeps the project focused on using Discord as the player-facing remote before any hands-on work begins. The target is one owner-approved, EBS-backed Minecraft Amazon EC2 instance whose service starts on operating-system boot.
Set Up Discord and AWS Access
You have already chosen one approved Minecraft server. The next layer uses Discord as its player-facing remote without exposing general Amazon EC2 control.
Discord and the AWS Lambda function need separate identities. AWS Identity and Access Management (IAM) gives the function a narrow permission boundary around your approved instance.
This step establishes both identities. It also confirms that AWS CloudShell has the tools needed to build the function package.
In this step, get ready to:
- Create the Discord application and install it in your test guild.
- Create a Lambda execution role that can control only the approved EC2 instance.
- Open CloudShell in the target Region and verify its packaging tools.
Create and install the Discord application
The Discord application owns the slash commands that players use later. Its Application ID identifies the app to Discord while its Public Key lets Lambda verify signed requests.
- Open the Discord Developer Portal in your signed-in browser.
- Click New Application.
- Enter Minecraft Remote as the application name.
- Complete the application creation prompt.
- Select General Information in the application sidebar.
- Record the displayed Application ID: your Discord Application ID.
- Record the displayed Public Key: your Discord Public Key.
The bot token authorizes API calls as your application. This token needs careful handling, but it never enters a project file or screenshot.
- Select Bot in the application sidebar.
- Choose Reset Token.
- Complete Discord's confirmation prompt.
- Store the revealed token directly in your password manager.
Protect the bot token
The bot token is a live credential that authorizes Discord API requests. Keep it out of notes, project files, screenshots, and chat messages.
A guild installation makes the application available inside one Discord server. The applications.commands scope allows that installation to expose slash commands.
- Select Installation in the application sidebar.
- Enable Guild Install.
- Choose Discord Provided Link as the installation method.
- Add the applications.commands scope.
- Save the installation settings.
- Open the generated installation link.
- Choose your test server in the authorization flow.
- Approve the application installation.
Discord hides guild IDs until Developer Mode is enabled, so this value takes a few extra clicks to reveal.
- Open User Settings in Discord.
- Select Advanced.
- Enable Developer Mode.
- Return to your test server.
- Right-click the test server icon.
- Select Copy ID.
- Record the copied value: your Discord guild ID.
- Open Server Settings in the test server.
- Select Integrations.
- Confirm that Minecraft Remote appears as an installed application.
Your Discord identity is ready. The test guild now knows which application owns the Minecraft commands.
Create the instance-scoped Lambda role
The AWS side needs four values that identify the target precisely. The Region and account ID place the instance inside your AWS environment.
- Sign in to the AWS Management Console.
- Select the Region containing your Minecraft EC2 instance from the top navigation bar.
- Record the selected value: your AWS Region.
- Open the account menu in the top navigation bar.
- Record the displayed value: your AWS account ID.
- Switch to the Amazon EC2 console.
- Select your approved Minecraft instance from the Instances list.
- Record the displayed value: your EC2 instance ID.
How does the instance ARN work?
An Amazon Resource Name (ARN) identifies one AWS resource across its service, Region, account, and resource type.
This project uses the template arn:aws:ec2:your-region-here:your-account-id-here:instance/your-ec2-instance-id-here to target one EC2 instance.
- Copy the ARN template into your project notes.
- Replace your-region-here with your AWS Region.
- Replace your-account-id-here with your AWS account ID.
- Replace your-ec2-instance-id-here with your EC2 instance ID.
- Record the completed result: your EC2 instance ARN.
A Lambda service role gives the function an AWS identity. The first policy supplies logging access while the inline policy limits EC2 actions.
- Switch to the IAM console.
- Select Roles in the IAM navigation panel.
- Click Create role.
- Choose AWS service as the trusted entity type.
- Choose Lambda as the service use case.
- Continue to the permissions page.
- Search for AWSLambdaBasicExecutionRole.
- Select AWSLambdaBasicExecutionRole.
What does the basic role allow?
The managed AWSLambdaBasicExecutionRole policy lets Lambda create log groups, create log streams, and write log events.
Those permissions make function failures visible in CloudWatch Logs.
- Continue to the role details page.
- Enter a role name.
- Record the chosen name: your Lambda execution role name.
- Create the role.
- Choose Add permissions from the new role's permissions page.
- Choose Create inline policy.
- Select the JSON editor.
- Load the EC2 policy template by replacing the editor contents with this code:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeInstancesForStatus",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StartStopOnlyMinecraftInstance",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:your-region-here:your-account-id-here:instance/your-ec2-instance-id-here"
}
]
}
What does this policy protect?
The status command needs ec2:DescribeInstances with "Resource": "*" because AWS does not support instance-level resource scoping for that action.
The mutating ec2:StartInstances and ec2:StopInstances actions point to one instance ARN. That boundary prevents this role from starting or stopping other EC2 instances.
- Replace your-region-here with your AWS Region.
- Replace your-account-id-here with your AWS account ID.
- Replace your-ec2-instance-id-here with your EC2 instance ID.
- Continue to the policy details page.
- Enter minecraft-ec2-control-policy.json as the policy name.
- Create the inline policy.
- Confirm that AWSLambdaBasicExecutionRole appears on the role's permissions page.
- Confirm that minecraft-ec2-control-policy.json appears on the same permissions page.
That is the sensitive permission step complete. Your Lambda role can inspect EC2 while its start and stop access remains locked to the approved instance.
Policy validation failing?
Check that every placeholder in the ARN was replaced with the recorded AWS value. Confirm that the ARN contains no spaces.
Verify that the selected Region matches the Region containing your Minecraft instance.
Ask for help with the policy error.
Open CloudShell and verify the tools
CloudShell provides a browser-based Linux environment that already uses your AWS console credentials. Keeping it in the target Region prevents later packaging work from drifting into a different AWS environment.
- Return to the AWS Management Console with your AWS Region selected.
- Launch CloudShell from the console navigation bar.
- Confirm that the console Region still shows your AWS Region.
- Create the policy template file in CloudShell by running this command:
edit minecraft-ec2-control-policy.json
What does this command do?
The edit command opens CloudShell's visual editor. It creates minecraft-ec2-control-policy.json in your CloudShell home directory when the file does not exist.
- Add the reusable policy template to minecraft-ec2-control-policy.json by pasting this code:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeInstancesForStatus",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StartStopOnlyMinecraftInstance",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:your-region-here:your-account-id-here:instance/your-ec2-instance-id-here"
}
]
}
Why keep the template file?
This file preserves the reusable least-privilege policy with its three clear placeholders. The IAM role already contains the instance-specific version you created earlier.
- Save minecraft-ec2-control-policy.json with Cmd+S on macOS or Ctrl+S on Windows.
- Close the CloudShell editor tab.
- Confirm that the policy file exists by running:
ls
What should you see?
You should see minecraft-ec2-control-policy.json in the file list. That confirms the template is stored in your persistent CloudShell home directory.
Policy file missing?
Reopen the editor and confirm that its tab is named minecraft-ec2-control-policy.json.
Save the file before closing the editor tab.
Ask for help locating the CloudShell file.
✔️ Awesome, I've got everything!
Great. Double-check that minecraft-ec2-control-policy.json is saved before continuing.
ⓧ I'd like to double check the full code
Compare your saved policy template with this complete file. Keep the placeholder spellings exactly as shown.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeInstancesForStatus",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StartStopOnlyMinecraftInstance",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:your-region-here:your-account-id-here:instance/your-ec2-instance-id-here"
}
]
}
Before you run the final checks, do you expect CloudShell to return three version lines or report a missing tool?
- Verify Python, pip, and zip by running these commands:
python3 --version
pip3 --version
zip --version
What should you see?
You should see one version result for Python. You should also see version information for pip and zip.
Three successful results prove that CloudShell is ready to build the Lambda deployment package in the next step.
Missing a CloudShell tool?
Confirm that you launched AWS CloudShell inside the AWS Management Console. A local terminal can have a different set of tools.
Restart the CloudShell session if one of the pre-installed commands is unavailable.
Ask for help with the missing command.
That is the security foundation in place: your Discord app is installed. Your Lambda role can mutate only the approved instance.
Next, you will build the signed interaction handler and prove that it rejects an unsigned request.
Build the Signed Lambda Handler
Your Discord app is installed. Its scoped role can change only the approved Amazon EC2 instance.
A public endpoint must reject forged traffic before it reaches Boto3. In this step, you will package an AWS Lambda Event Function that verifies Discord's Ed25519 signature.
In this step, get ready to:
- Create the pinned dependency file plus the signed interaction handler.
- Package the handler with Linux-compatible dependencies.
- Deploy the function for an unsigned request test.
Create the handler files
Discord signs the timestamp plus the untouched request body. The handler must verify those bytes before it reads a command or calls EC2.
- Create the `minecraft-remote` directory in AWS CloudShell by running these commands:
mkdir minecraft-remote
cd minecraft-remote
What does this command do?
The first command creates a dedicated directory. The second command makes it your current location.
- Create requirements.txt in the CloudShell editor by running:
edit requirements.txt
What does edit do?
The CloudShell editor opens the named file in a browser tab. It creates the file when it does not already exist.
- Paste these pinned dependencies into requirements.txt:
boto3==1.43.123
PyNaCl==1.6.2
Why pin both dependencies?
- Boto3 1.43.123 gives the function a known EC2 client version.
- PyNaCl 1.6.2 supplies the signature verifier used for Discord requests.
- Packaging Boto3 prevents dependency misalignment with the copy included in the runtime.
- Save requirements.txt using the editor's Save icon.
- Close the requirements.txt editor tab.
- Confirm the file exists by running:
ls
What should you see?
The directory listing should include requirements.txt. This confirms that CloudShell saved the dependency file in minecraft-remote.
- Create lambda_function.py in the CloudShell editor by running:
edit lambda_function.py
How will you build this file?
You will paste the handler in small sections. Each syntax check keeps the feedback loop close to the code you just added.
- Paste the imports plus the configuration section into lambda_function.py:
import base64
import json
import boto3
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
DISCORD_PUBLIC_KEY = "your-discord-public-key-here"
MINECRAFT_INSTANCE_ID = "your-ec2-instance-id-here"
ec2 = boto3.client("ec2")
def api_response(status_code, payload):
return {
"statusCode": status_code,
"headers": {"content-type": "application/json"},
"body": json.dumps(payload),
}
What does this section do?
- The imports provide body decoding, JSON handling, EC2 access, and Ed25519 verification.
- The two constants identify your Discord application plus the approved EC2 instance.
- The api_response() helper creates the response shape expected from the function.
- Replace your-discord-public-key-here with the Discord Public Key you recorded earlier.
- Replace your-ec2-instance-id-here with the target instance ID you recorded earlier.
- Keep both values inside their existing quotation marks.
- Save lambda_function.py using the editor's Save icon.
- Return to the CloudShell terminal.
- Check the Python syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
A quiet return to the prompt means Python compiled the current file without finding a syntax error. This check does not run the handler.
Seeing a syntax error?
Return to the editor and compare the line number from the error with the first code section. Check the quotation marks around both configuration values.
Ask for help with the exact line that failed: Help me fix the syntax error in the first section of my Lambda handler.
- Return to the open lambda_function.py editor tab.
- Paste the message helper plus the raw-body helper below the existing code:
def interaction_message(content):
return api_response(
200,
{
"type": 4,
"data": {
"content": content,
"allowed_mentions": {"parse": []},
},
},
)
def raw_request_body(event):
body = event.get("body", "")
if event.get("isBase64Encoded", False):
return base64.b64decode(body)
return body.encode("utf-8")
What does this section do?
- The interaction_message() helper formats a Discord message response.
- The empty allowed_mentions list prevents response text from triggering mentions.
- The raw_request_body() helper preserves the request bytes needed for signature verification.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the updated syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The terminal should return to its prompt without output. Python has accepted both response helpers.
Does the helper section fail to compile?
Check the indentation inside both functions. Confirm that each opening bracket has a matching closing bracket.
Use the compiler's line number when asking for help: Help me debug the response and raw-body helpers.
- Return to the open lambda_function.py editor tab.
- Paste the signature check plus the instance lookup below the existing code:
def verify_discord_request(headers, body):
signature = headers.get("x-signature-ed25519")
timestamp = headers.get("x-signature-timestamp")
if not signature or not timestamp:
return False
try:
verify_key = VerifyKey(bytes.fromhex(DISCORD_PUBLIC_KEY))
verify_key.verify(
timestamp.encode("utf-8") + body,
bytes.fromhex(signature),
)
return True
except (BadSignatureError, TypeError, ValueError):
return False
def get_instance():
response = ec2.describe_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
reservations = response.get("Reservations", [])
if not reservations or not reservations[0].get("Instances"):
raise RuntimeError("The configured EC2 instance was not found.")
return reservations[0]["Instances"][0]
How does the trust boundary work?
- The verifier reads both Discord signature headers.
- It verifies the timestamp plus the untouched body against your application's Public Key.
- Any missing or invalid signature returns False before an AWS action can run.
- The instance lookup asks EC2 for only the configured instance ID.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the updated syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The prompt should return without output. The verification plus instance lookup sections are syntactically valid.
Does the verifier fail to compile?
Check the indentation under try plus except. Confirm that the header names remain lowercase.
Share the compiler line number without sharing your Public Key: Help me debug the Discord verification function.
- Return to the open lambda_function.py editor tab.
- Paste the public-address helper plus the status handler below the existing code:
def public_ip_text(instance):
return instance.get("PublicIpAddress", "not assigned")
def handle_status():
instance = get_instance()
state = instance["State"]["Name"]
public_ip = public_ip_text(instance)
return interaction_message(
f"Minecraft server status\nState: **{state}**\nPublic IP: **{public_ip}**"
)
What does the status path report?
The address helper provides a safe fallback when EC2 has no public address. The status handler reports the current lifecycle state plus that address value.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the updated syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The terminal should return to its prompt without output. The status response is ready for the command router.
Does the status section fail to compile?
Check the nested quotation marks around State plus Name. Confirm that the formatted string remains inside one pair of quotation marks.
Ask for help without changing the response wording: Help me fix the status handler syntax.
- Return to the open lambda_function.py editor tab.
- Paste the start handler below the existing code:
def handle_start():
instance = get_instance()
state = instance["State"]["Name"]
if state == "stopped":
response = ec2.start_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
next_state = response["StartingInstances"][0]["CurrentState"]["Name"]
return interaction_message(
"Minecraft server start requested\n"
f"State: **{next_state}**\n"
"Public IP: **not available until the instance is running**\n"
"Run `/mc-status` again after the EC2 status checks pass."
)
if state == "running":
return interaction_message(
"Minecraft server is already running\n"
f"State: **{state}**\n"
f"Public IP: **{public_ip_text(instance)}**"
)
return interaction_message(
"Minecraft server is already changing state\n"
f"State: **{state}**\n"
"Run `/mc-status` again shortly."
)
How does the start handler stay honest?
- A stopped instance receives the start request.
- A running instance returns its current address without starting again.
- A transitional instance reports its real state instead of claiming the Minecraft service is ready.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the updated syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The prompt should return without output. The complete start-state branch is syntactically valid.
Does the start handler fail to compile?
Check the indentation for both state branches. Confirm that every multiline message closes its interaction_message() call.
Ask for a branch-by-branch review: Help me debug handle_start.
- Return to the open lambda_function.py editor tab.
- Paste the stop handler below the existing code:
def handle_stop():
instance = get_instance()
state = instance["State"]["Name"]
if state == "running":
current_ip = public_ip_text(instance)
response = ec2.stop_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
next_state = response["StoppingInstances"][0]["CurrentState"]["Name"]
return interaction_message(
"Minecraft server stop requested\n"
f"State: **{next_state}**\n"
f"Public IP before shutdown: **{current_ip}**"
)
if state == "stopped":
return interaction_message(
"Minecraft server is already stopped\n"
f"State: **{state}**\n"
"Public IP: **not assigned**"
)
return interaction_message(
"Minecraft server is already changing state\n"
f"State: **{state}**\n"
"Run `/mc-status` again shortly."
)
How does the stop handler behave?
- A running instance receives a normal EC2 stop request.
- The response preserves the address that existed before shutdown.
- Stopped plus transitional instances receive accurate status messages without another stop request.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the updated syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The terminal should return to its prompt without output. The stop-state branches are syntactically valid.
Does the stop handler fail to compile?
Compare the StoppingInstances lookup with the code block. Check the closing brackets around CurrentState plus Name.
Ask for help with the exact failing line: Help me debug handle_stop.
- Return to the open lambda_function.py editor tab.
- Paste the request-validation half of the main handler below the existing code:
def lambda_handler(event, context):
headers = {
key.lower(): value
for key, value in (event.get("headers") or {}).items()
}
body = raw_request_body(event)
if not verify_discord_request(headers, body):
return api_response(401, {"error": "invalid request signature"})
try:
interaction = json.loads(body)
except json.JSONDecodeError:
return api_response(400, {"error": "invalid JSON body"})
interaction_type = interaction.get("type")
if interaction_type == 1:
return api_response(200, {"type": 1})
if interaction_type != 2:
return interaction_message("Unsupported interaction type.")
What happens before command routing?
- Header names are normalized to lowercase to match the incoming proxy event.
- An invalid signature returns HTTP 401 before JSON parsing or EC2 access.
- A valid Discord PING receives a PONG response with type 1.
- Unsupported interaction types receive a message response.
- Save lambda_function.py.
- Return to the CloudShell terminal.
- Check the current handler syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The prompt should return without output. The handler can now reject unsigned traffic before any command logic runs.
Does the handler entry point fail to compile?
Check the indentation inside the header dictionary comprehension. Confirm that each response dictionary closes on the same line.
Ask for help without sharing configuration values: Help me debug the first half of lambda_handler.
- Return to the open lambda_function.py editor tab.
- Paste the command router inside lambda_handler() below the unsupported-type branch:
command_name = interaction.get("data", {}).get("name")
try:
if command_name == "mc-status":
return handle_status()
if command_name == "mc-start":
return handle_start()
if command_name == "mc-stop":
return handle_stop()
return interaction_message("Unknown Minecraft command.")
except Exception as error:
print(f"Minecraft command failed: {type(error).__name__}: {error}")
return interaction_message(
"AWS could not complete the request. Check the Lambda logs and IAM policy."
)
How are commands routed?
- The router reads the slash command name from the interaction data.
- Each known command calls its matching handler.
- Unexpected AWS failures are logged without exposing exception details to Discord users.
- Save lambda_function.py.
- Close the lambda_function.py editor tab.
- Return to the CloudShell terminal.
- Check the complete handler syntax by running:
python3 -m py_compile lambda_function.py
What should you see?
The terminal should return to its prompt without output. Your complete handler is syntactically valid.
Does the completed handler fail to compile?
Confirm that command_name remains indented inside lambda_handler(). Check that the final try block has its matching except block.
Ask for a focused comparison against the final section: Help me fix the completed command router.
Build the deployment archive
PyNaCl includes compiled components. The deployment package therefore needs a Linux wheel that matches the function's x86_64 architecture.
- Create the dependency directory by running:
mkdir package
Why use a package directory?
The directory keeps installed libraries separate from your source files. Its contents later become the root of the deployment archive.
The dependency install can take a minute while pip downloads the compatible wheels. A stream of package names means the process is still working.
- Install the pinned dependencies for Python 3.12 on x86_64 Lambda by running:
pip3 install \
--platform manylinux2014_x86_64 \
--target=package \
--implementation cp \
--python-version 3.12 \
--only-binary=:all: --upgrade \
-r requirements.txt
What does this install command control?
- The platform option requests wheels built for Lambda's Linux environment.
- The target option places every dependency inside package.
- The implementation plus version options target CPython 3.12.
- The binary-only option prevents pip from building an incompatible local source package.
The terminal should finish with a successful installation message. The package directory now contains Boto3, PyNaCl, plus their dependencies.
Did the dependency install fail?
Confirm that your prompt is inside minecraft-remote. Check that requirements.txt contains both pinned lines exactly.
Use the final pip error when asking for help: Help me troubleshoot the Lambda dependency install.
- Create function.zip with the dependencies at its root by running these commands:
cd package
zip -r ../function.zip .
cd ..
zip function.zip lambda_function.py
Why zip from inside package?
Zipping from inside package places the libraries at the archive root. The final command places lambda_function.py at that same level.
- Confirm the archive exists by running:
ls
What should you see?
The listing should include function.zip, lambda_function.py, package, plus requirements.txt.
Is function.zip missing?
Check whether the zip command ended with an error. Confirm that you returned to minecraft-remote before adding lambda_function.py.
Ask for help with the final zip output: Help me fix the Lambda archive.
- Open the CloudShell Actions menu.
- Choose Download file.
- Enter minecraft-remote/function.zip in the file path field.
- Choose Download.
- Save function.zip to your computer when your browser asks.
Good progress. Your signed handler plus its Linux-compatible dependencies are now bundled into one deployable archive.
Deploy the function and test the trust boundary
The console test will imitate an API Gateway payload without Discord's signature headers. This reveals whether the handler protects the EC2 actions before the public endpoint exists.
- Switch back to the AWS CloudShell session from earlier.
- Create test-event-unsigned-ping.json in the CloudShell editor by running:
edit test-event-unsigned-ping.json
What is this test file for?
The file models an API Gateway payload format 2.0 request. Its empty headers object means it carries no Discord signature.
- Paste this unsigned PING event into test-event-unsigned-ping.json:
{
"version": "2.0",
"headers": {},
"body": "{\"type\":1}",
"isBase64Encoded": false
}
What does this event contain?
- The body contains a Discord PING interaction with type 1.
- The body remains a JSON string inside the outer proxy event.
- The empty header object deliberately omits the two signature headers.
- Save test-event-unsigned-ping.json.
- Close the editor tab.
- Confirm the test file exists by running:
ls
What should you see?
The listing should now include test-event-unsigned-ping.json beside the deployment files.
✔️ Awesome, I've got everything!
Your source files plus deployment archive are ready. Keep your recorded Public Key plus EC2 instance ID in lambda_function.py.
ⓧ I'd like to double check the full code
Compare every file with these references. The handler reference shows the original placeholders, so keep your own recorded values in those two lines.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeInstancesForStatus",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StartStopOnlyMinecraftInstance",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:your-region-here:your-account-id-here:instance/your-ec2-instance-id-here"
}
]
}
boto3==1.43.123
PyNaCl==1.6.2
import base64
import json
import boto3
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey
DISCORD_PUBLIC_KEY = "your-discord-public-key-here"
MINECRAFT_INSTANCE_ID = "your-ec2-instance-id-here"
ec2 = boto3.client("ec2")
def api_response(status_code, payload):
return {
"statusCode": status_code,
"headers": {"content-type": "application/json"},
"body": json.dumps(payload),
}
def interaction_message(content):
return api_response(
200,
{
"type": 4,
"data": {
"content": content,
"allowed_mentions": {"parse": []},
},
},
)
def raw_request_body(event):
body = event.get("body", "")
if event.get("isBase64Encoded", False):
return base64.b64decode(body)
return body.encode("utf-8")
def verify_discord_request(headers, body):
signature = headers.get("x-signature-ed25519")
timestamp = headers.get("x-signature-timestamp")
if not signature or not timestamp:
return False
try:
verify_key = VerifyKey(bytes.fromhex(DISCORD_PUBLIC_KEY))
verify_key.verify(
timestamp.encode("utf-8") + body,
bytes.fromhex(signature),
)
return True
except (BadSignatureError, TypeError, ValueError):
return False
def get_instance():
response = ec2.describe_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
reservations = response.get("Reservations", [])
if not reservations or not reservations[0].get("Instances"):
raise RuntimeError("The configured EC2 instance was not found.")
return reservations[0]["Instances"][0]
def public_ip_text(instance):
return instance.get("PublicIpAddress", "not assigned")
def handle_status():
instance = get_instance()
state = instance["State"]["Name"]
public_ip = public_ip_text(instance)
return interaction_message(
f"Minecraft server status\nState: **{state}**\nPublic IP: **{public_ip}**"
)
def handle_start():
instance = get_instance()
state = instance["State"]["Name"]
if state == "stopped":
response = ec2.start_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
next_state = response["StartingInstances"][0]["CurrentState"]["Name"]
return interaction_message(
"Minecraft server start requested\n"
f"State: **{next_state}**\n"
"Public IP: **not available until the instance is running**\n"
"Run `/mc-status` again after the EC2 status checks pass."
)
if state == "running":
return interaction_message(
"Minecraft server is already running\n"
f"State: **{state}**\n"
f"Public IP: **{public_ip_text(instance)}**"
)
return interaction_message(
"Minecraft server is already changing state\n"
f"State: **{state}**\n"
"Run `/mc-status` again shortly."
)
def handle_stop():
instance = get_instance()
state = instance["State"]["Name"]
if state == "running":
current_ip = public_ip_text(instance)
response = ec2.stop_instances(InstanceIds=[MINECRAFT_INSTANCE_ID])
next_state = response["StoppingInstances"][0]["CurrentState"]["Name"]
return interaction_message(
"Minecraft server stop requested\n"
f"State: **{next_state}**\n"
f"Public IP before shutdown: **{current_ip}**"
)
if state == "stopped":
return interaction_message(
"Minecraft server is already stopped\n"
f"State: **{state}**\n"
"Public IP: **not assigned**"
)
return interaction_message(
"Minecraft server is already changing state\n"
f"State: **{state}**\n"
"Run `/mc-status` again shortly."
)
def lambda_handler(event, context):
headers = {
key.lower(): value
for key, value in (event.get("headers") or {}).items()
}
body = raw_request_body(event)
if not verify_discord_request(headers, body):
return api_response(401, {"error": "invalid request signature"})
try:
interaction = json.loads(body)
except json.JSONDecodeError:
return api_response(400, {"error": "invalid JSON body"})
interaction_type = interaction.get("type")
if interaction_type == 1:
return api_response(200, {"type": 1})
if interaction_type != 2:
return interaction_message("Unsupported interaction type.")
command_name = interaction.get("data", {}).get("name")
try:
if command_name == "mc-status":
return handle_status()
if command_name == "mc-start":
return handle_start()
if command_name == "mc-stop":
return handle_stop()
return interaction_message("Unknown Minecraft command.")
except Exception as error:
print(f"Minecraft command failed: {type(error).__name__}: {error}")
return interaction_message(
"AWS could not complete the request. Check the Lambda logs and IAM policy."
)
{
"version": "2.0",
"headers": {},
"body": "{\"type\":1}",
"isBase64Encoded": false
}
Creating the function enables usage-based billing. This step sends only one small test invocation.
- Return to the AWS Management Console from earlier.
- Open the Lambda Event Functions page.
- Choose Create Event Function.
- Choose Author from scratch.
- Enter minecraft-remote for Function name.
- Select Python 3.12 for Runtime.
- Select x86_64 for Architecture.
- Expand Change default execution role.
Why keep x86_64?
Your dependency command downloaded the manylinux2014_x86_64 wheel. The function architecture must match that package.
- Choose the option to use an existing execution role.
- Select the Lambda execution role you created earlier.
- Choose Create Event Function.
The function page should load with the existing role attached. Your scoped IAM permissions now sit behind the handler.
- Select the Code tab.
- Choose Upload from in the Code source pane.
- Choose .zip file.
- Choose Upload.
- Select the downloaded function.zip file.
- Choose Open.
- Choose Save.
The code source should now show files from your archive. The runtime handler remains lambda_function.lambda_handler.
Did the archive upload fail?
Confirm that you selected the local copy of function.zip. Rebuild the archive if the console reports a missing handler file.
Ask for help with the upload message: Help me troubleshoot the Lambda zip upload.
- Select the Test tab.
- Choose Create new event under Test event.
- Enter unsigned-ping for the test Name.
- Choose Private under Event sharing settings.
- Replace the sample JSON with the contents of test-event-unsigned-ping.json.
- Choose Save changes.
Before you test it, do you think an unsigned PING reaches the PONG branch or stops at the signature boundary?
- Choose Test.
- Expand Details under Execution result.
You should see statusCode set to 401. The response body should contain invalid request signature.
Why is 401 the successful result?
This console event has no Discord signature headers. The handler rejects it before parsing the PING or calling EC2, which proves the public request boundary is active.
Did the test return a different result?
- Confirm that the saved event has an empty headers object.
- Confirm that the function handler is lambda_function.lambda_handler.
- Re-upload function.zip if the execution result shows the original sample function.
Ask for help with the visible execution result: Help me debug the unsigned Lambda test.
That is the trust boundary working. Next, you will place the handler behind an HTTP API so Discord can send a genuinely signed PING.
Connect the HTTP API
Your Lambda function now returns 401 for unsigned requests. That result proves the handler blocks untrusted traffic.
Discord still needs a public address for signed interactions. An Amazon API Gateway HTTP API gives those requests a route into the function.
In this step, get ready to:
- Create an HTTP API with a Lambda proxy integration.
- Configure the exact POST /interactions route on an automatically deployed $default stage.
- Complete Discord's signed PING validation.
Create the HTTP API integration
A Lambda proxy integration wraps each HTTP request in an event for your function. Payload format 2.0 preserves the body plus the lowercased headers required by your handler.
Making this route public can feel risky. Your signature check remains the gate that decides which requests reach the command logic.
- Return to the AWS Management Console from earlier.
- Enter API Gateway in the top search bar.
- Select API Gateway from the results.
- Choose Create API.
- Under HTTP API, choose Build.
You'll see the HTTP API setup wizard. Its first section asks which backend receives matching requests.
- Under Integrations, choose Add integration.
- Choose Lambda as the integration type.
You'll see a selector for Lambda Event Functions in the current AWS Region.
- Select the Lambda Event Function from the previous step in the Lambda function field.
- Enter a name that identifies this API as the Minecraft remote in the API name field.
The integration summary now shows your existing function as the API backend.
- Choose Next to configure the route.
- Select POST under Method.
- Enter /interactions under Resource path.
The route summary should now read POST /interactions.
- Choose Next to configure the stage.
- Enter $default under Stage name.
- Turn on Auto-deploy for the stage.
The stage summary should show $default with automatic deployment enabled.
- Choose Next to review the API.
- Choose Create to create the HTTP API.
The public bridge is live. API Gateway can now hand matching requests to your Lambda function.
- Select Integrations from the API navigation.
- Select the Lambda integration you just created.
The integration details should show payload format version 2.0.
- Choose the integration's Edit control.
You'll see the editable settings for the Lambda proxy integration.
- Set Payload format version to 2.0.
- Choose Save.
- Select Routes from the API navigation.
- Confirm the route is exactly POST /interactions.
- Select Stages from the API navigation.
- Select $default.
- Confirm automatic deployment is enabled.
You should now see one exact route backed by your Lambda function. Changes to that route deploy automatically through the $default stage.
Discord sends interactions directly from its servers. Browser CORS rules do not govern these requests.
- Leave the CORS configuration unchanged.
Can't Create the HTTP API?
- Check that the AWS console still shows the target Region.
- Re-select the Lambda Event Function from the previous step.
- Ask your AWS account administrator to confirm that you can create HTTP APIs if the console blocks the action.
Help me diagnose why I cannot create this API Gateway HTTP API with my Lambda integration.
Form the Discord endpoint URL
The $default stage serves requests from the API's base URL. Your route path must appear at the end of that URL.
- Return to the API details page.
- Record the API's Invoke URL with /interactions at the end here: your API invoke URL ending in /interactions.
Your completed endpoint should begin with the API's Invoke URL. It should end with /interactions.
Does the URL Look Wrong?
- Remove any duplicate slash before interactions.
- Confirm the URL ends with exactly one /interactions path.
- Copy the Invoke URL again if the API identifier or Region is missing.
Help me check whether my Discord Interactions Endpoint URL is formed correctly.
Complete Discord's signed PING validation
Saving the endpoint makes Discord send a signed type 1 PING.
Your handler verifies the timestamp plus the raw body. A valid request receives a type 1 PONG.
- Open the Discord Developer Portal in a new browser tab.
- Select the Minecraft Remote application from earlier.
- Select General Information.
- Find the Interactions Endpoint URL field.
- Paste your API invoke URL ending in /interactions into the field.
Pause for a prediction about whether Discord's signed PING has everything it needs to pass the public route.
- Save the endpoint setting.
Discord should accept the endpoint without a validation error. That result proves the signed PING reached Lambda and received a valid PONG.
Endpoint Validation Failed?
- Check that the endpoint ends with /interactions.
- Confirm that API Gateway still shows the exact POST /interactions route.
- Compare DISCORD_PUBLIC_KEY in the deployed Lambda package with the application's Public Key.
Help me troubleshoot why Discord cannot validate my signed interactions endpoint.
Once Discord accepts the URL, the public route has passed its first authenticated request.
What Just Happened?
- Discord signed a type 1 PING with the application's private key.
- API Gateway forwarded the request to Lambda through the POST /interactions route.
- Lambda verified the request with DISCORD_PUBLIC_KEY.
- Lambda returned a type 1 PONG with the application/json content type.
Your signed endpoint is live behind signature verification. Next, you'll register the three Minecraft slash commands that turn it into a usable remote.
Register the Slash Commands
Your signed Discord endpoint already proves that a real request can reach Lambda. The signed type 1 PING receives a type 1 PONG.
Players still have no controls because the test guild has no command definitions. You will register three guild slash commands in this step.
In this step, get ready to:
- Create the three Minecraft command definitions.
- Register the commands through Discord's guild bulk-overwrite endpoint.
- Use /mc-status to report the target server state.
Prepare the guild command definitions
A guild command definition is a JSON object that gives Discord a command name. It also supplies the description shown in the command picker.
Each definition uses type 1 for a CHAT_INPUT command. Guild commands update immediately during testing.
- Return to the AWS CloudShell session from earlier.
- Create commands.json with CloudShell's visual editor by running this command:
edit commands.json
What Does This Command Do?
The edit command opens CloudShell's visual editor. Saving the new tab creates commands.json in the same CloudShell directory as your deployment files.
- Paste these three definitions into the editor:
[
{
"name": "mc-start",
"description": "Start the Minecraft EC2 server",
"type": 1
},
{
"name": "mc-stop",
"description": "Stop the Minecraft EC2 server",
"type": 1
},
{
"name": "mc-status",
"description": "Show the Minecraft EC2 state and public IP",
"type": 1
}
]
What Do These Definitions Create?
- The mc-start definition adds the command that requests an instance start.
- The mc-stop definition adds the command that requests an instance stop.
- The mc-status definition adds the read-only command that reports the current state.
- Each type value identifies its object as a slash command.
- Save commands.json by pressing Cmd+S on macOS or Ctrl+S on Windows.
- Close the editor tab after the save completes.
- Confirm the file exists by running this command:
ls
What Does This Check?
The ls command lists the files in your current CloudShell directory. This confirms that the editor saved your command definitions beside the existing deployment files.
You should see commands.json alongside lambda_function.py. The new file is ready to send to Discord.
Cannot Find commands.json?
Return to the editor tab if it is still open. Confirm the filename is exactly commands.json before saving it again.
If the file exists with a different name, reopen it with CloudShell's editor. Save it under the required filename.
Help me check why commands.json is missing from my CloudShell directory.
✔️ Awesome, I've got everything!
Great. Double check that commands.json is saved before you register the commands.
ⓧ I'd like to double check the full code
[
{
"name": "mc-start",
"description": "Start the Minecraft EC2 server",
"type": 1
},
{
"name": "mc-stop",
"description": "Stop the Minecraft EC2 server",
"type": 1
},
{
"name": "mc-status",
"description": "Show the Minecraft EC2 state and public IP",
"type": 1
}
]
Register the commands securely
Discord's guild bulk-overwrite endpoint replaces the application's existing command list in the selected guild. The request returns the complete registered list when it succeeds.
This Replaces Existing Guild Commands
The bulk-overwrite request removes existing application commands that are absent from commands.json. Use the test guild recorded earlier so these three definitions become its complete command set.
Your bot token is as sensitive as a password. This flow keeps it off screen before clearing it from the active shell.
- Prepare CloudShell to read the bot token silently by running this command:
read -rs DISCORD_BOT_TOKEN
How Is the Token Protected?
The -s option prevents the token from appearing as you paste it. The -r option preserves every character exactly.
The token exists only in the temporary DISCORD_BOT_TOKEN shell variable. It never enters a project file.
- Paste the bot token into the empty terminal line.
- Press Enter to finish the hidden input.
Before you send the request, how many command objects do you expect Discord to return?
- Send the secured registration sequence by running this command with your two recorded IDs:
curl --request PUT \
--header "Authorization: Bot $DISCORD_BOT_TOKEN" \
--header "Content-Type: application/json" \
--data-binary @commands.json \
"https://discord.com/api/v10/applications/[[DISCORD_APPLICATION_ID="your Discord Application ID"]]/guilds/[[DISCORD_GUILD_ID="your Discord guild ID"]]/commands"
unset DISCORD_BOT_TOKEN
What Does This Request Do?
- The PUT method replaces the application's command list for the selected guild.
- The Authorization header authenticates the request with the temporary bot token.
- The Content-Type header tells Discord to parse the body as JSON.
- The --data-binary option sends commands.json without changing its contents.
- The final command removes DISCORD_BOT_TOKEN from the active shell.
You should see a JSON array containing three command objects. Their names are mc-start, mc-stop, and mc-status.
That three-object response confirms the guild accepted the complete command set. Guild commands become available immediately.
Did the Registration Request Fail?
- Check that the Application ID belongs to the Discord application installed in your test guild.
- Check that the guild ID belongs to the same test server.
- Read the bot token into DISCORD_BOT_TOKEN again if Discord rejects the authorization.
Help me troubleshoot my Discord guild command registration request.
Run the status command
The command definitions now give players visible controls. The read-only status command is the safest first test because it does not change the instance state.
- Return to the Discord client from earlier.
- Select your test server from the server list.
- Choose a text channel where you can use the installed application.
- Type /mc into the message box.
You should see /mc-start, /mc-stop, and /mc-status in the command picker.
Before you run the status command, which Amazon EC2 state do you expect the remote to report?
- Select /mc-status from the command picker.
- Press Enter to invoke the command.
You'll receive a Minecraft server status response with the configured instance state. You'll also see its current public IP when one is assigned.
A stopped instance reports not assigned for the address. This response proves the signed command reached the existing Lambda handler.
No Status Response?
- Check the two IDs in the registration request if the three commands are missing from the picker.
- Confirm the saved Interactions Endpoint URL still ends with /interactions if Discord reports an interaction failure.
- Review the Lambda execution role if the response says AWS could not complete the request. Check the Lambda logs and IAM policy..
Help me trace why /mc-status is not returning the EC2 state.
That is the first useful version of your remote. Discord now reports the target Minecraft server's current state.
Your three slash commands are live in the test guild. Next, you will validate the approved stop and start paths against the target instance.
Validate the Minecraft Remote
Your Discord guild now exposes three Minecraft controls. The /mc-status command already reaches the Lambda handler.
A visible slash command alone cannot prove that only the configured Amazon EC2 instance changes state. A controlled stop-start cycle checks that boundary through every lifecycle transition.
In this step, get ready to:
- Establish an approved baseline against the target EC2 instance.
- Validate the stop transition through the stopped state.
- Validate the start transition through the refreshed address.
Establish a safe baseline
Lifecycle testing interrupts the game server for connected players. The maintenance window keeps that interruption planned.
- Confirm the server owner has approved this maintenance window.
- Confirm no players are connected to the Minecraft server.
- Confirm the current world data has a recent backup.
- Return to the Discord test guild from earlier.
- Run /mc-status in the server command field.
The response shows the configured instance state. It also shows the current public IP when one is assigned.
- Switch back to the AWS Management Console from earlier.
- Return to Amazon EC2 through the console search.
- Open the instance list to locate the row whose instance ID matches MINECRAFT_INSTANCE_ID.
- Compare the Discord state with the state in the target EC2 row.
- Compare the Discord public IP with the address in the target EC2 row.
- Confirm the target instance is currently running before continuing.
Is the baseline already stopped?
Starting the instance resumes compute charges. Your approved maintenance window keeps this recovery step controlled.
- Run /mc-start in the Discord test guild.
- Wait until the target EC2 row reports running.
- Run /mc-status again to establish the baseline.
Help me establish a running baseline for the lifecycle test.
Validate the stop path
The stop path must report the real EC2 transition without claiming that shutdown has already finished. Your approval and backup make this deliberate shutdown a controlled test.
Before you run the command, which state do you expect Discord to report while EC2 begins shutting down?
- Run /mc-stop in the Discord test guild.
Discord reports stopping. It also includes the public IP from before shutdown.
Why is this a graceful stop?
The StopInstances action performs a graceful operating-system shutdown by default. The handler leaves SkipOsShutdown at its default false value.
Clean Minecraft world saving still depends on the service handling operating-system shutdown correctly. That dependency makes the backup essential.
EC2 can remain in stopping while the operating system shuts down. A changing state means the request is progressing.
- Refresh the target EC2 row until its state reaches stopped.
Before the next check, do you think a stopped instance should still report its previous public IP?
- Run /mc-status again in Discord.
Discord reports stopped. The public IP is reported as not assigned.
Did the instance fail to stop?
- Confirm the baseline state was running when you sent the stop request.
- Verify the execution role policy still points to the exact target instance ARN.
- Compare the target EC2 row with the instance ID configured in MINECRAFT_INSTANCE_ID.
Help me diagnose why the Discord stop command did not move my target EC2 instance to stopped.
Validate the start path
Starting the instance resumes EC2 compute charges with a one-minute minimum. The approved validation window keeps that paid time limited to this test.
Before you run the command, which transition should the remote report before the instance reaches running?
- Run /mc-start in the Discord test guild.
Discord reports pending. This response accurately shows that EC2 is still starting.
The instance can remain in pending while EC2 starts it. The changing state shows that the request is progressing.
- Refresh the target EC2 row until its state reaches running.
Before the final status check, do you expect the previous address or the current EC2 address to appear?
- Run /mc-status in Discord.
Discord reports running. Its public IP matches the current address in the target EC2 row when one is assigned.
Why might the address change?
EC2 releases an automatically assigned public IPv4 address when an instance stops. A new address is normally assigned when the instance starts.
The latest /mc-status response is your source for the current address. This keeps players from reconnecting with a stale address.
EC2 can report running before the Minecraft service finishes its normal boot process. Use the server's usual startup time before testing the game connection.
Before you connect, do you expect Minecraft to be ready the instant EC2 reaches running?
- Connect to Minecraft with the current public IP after the normal server boot process finishes.
A successful connection confirms that the restarted service is reachable at the address reported by /mc-status.
Is the restarted server unreachable?
- Confirm the target EC2 row still reports running.
- Allow the Minecraft service to complete its normal operating-system boot process.
- Use the latest public IP from /mc-status instead of the address from before shutdown.
Help me troubleshoot why Minecraft is unreachable after the target EC2 instance restarted.
That is the remote fully proven: Discord reported every lifecycle transition for the configured instance. The latest status response now gives players the current address after a restart.
Secret mission
Secret Mission: Lock Commands to a Discord Role
Your signed endpoint already proves interactions came from Discord. Add a Minecraft Operators role that limits the three Minecraft commands to approved members.
Clean Up Your Resources
Clean Up Your Resources
Some of the AWS resources behind this remote are usage-priced. Choose whether to keep the remote active, pause it for later, or delete its project resources.
Cost warning
AWS Lambda, Amazon API Gateway, and Amazon CloudWatch Logs are usage-priced services. Their cost depends on request volume and stored logs.
Starting the existing Amazon EC2 instance resumes compute charges. Each start has a one-minute minimum.
Amazon EBS storage continues to incur charges while the instance is stopped.
Choose Pause or Delete if you no longer need the remote active.
Resources you used:
- The Minecraft Remote Discord application with its test-guild installation and three guild slash commands.
- The Minecraft Operators role with app-level command permissions.
- An API Gateway HTTP API with the POST /interactions route.
- A Python 3.12 Lambda function containing the signed interaction handler.
- The CloudWatch Logs log group created by the Lambda function.
- An AWS IAM execution role with AWSLambdaBasicExecutionRole and the inline EC2 policy.
- The package directory with function.zip, commands.json, lambda_function.py, minecraft-ec2-control-policy.json, requirements.txt, and test-event-unsigned-ping.json in AWS CloudShell.
Keep everything running
No action is needed. Choose this if approved players still use the remote to manage the Minecraft server.
- Leave Minecraft Remote installed in the Discord test guild.
- Keep the Minecraft Operators role assigned only to approved members.
- Leave the signed POST /interactions endpoint and Lambda function active.
- Keep the IAM execution role and CloudWatch log group in place.
- Keep the deployment artifacts in CloudShell for future updates.
- Stop the Minecraft instance after each approved play session with /mc-stop.
- Check the latest instance state before connecting with /mc-status.
Pause - I'll come back to this later
Shut down the Minecraft instance to stop its compute usage. The AWS control plane and CloudShell files remain ready for later.
Pause the Remote
- Run /mc-stop in the Discord test guild.
- Repeat /mc-status until Discord reports the stopped state.
- Clear the Interactions Endpoint URL field on the General Information page for Minecraft Remote.
- Save the updated Discord application settings.
- Leave the HTTP API and Lambda function in place.
- Keep the IAM execution role and CloudWatch log group.
Resume the Remote Later
- Record the existing HTTP API's Invoke URL: your HTTP API Invoke URL.
- Record your full interactions endpoint URL as your HTTP API Invoke URL followed by /interactions.
- Paste your full interactions endpoint URL into the Interactions Endpoint URL field.
- Save the restored Discord application settings.
Delete - I don't want to use this again
Deleting the control plane can feel drastic. Your existing Minecraft instance stays intact because this cleanup never terminates it.
This option removes Discord access, AWS control resources, and CloudShell artifacts.
Stop the Existing Server
- Run /mc-stop in the Discord test guild.
- Repeat /mc-status until Discord reports the stopped state.
That is the risk-sensitive part handled. The server is stopped while its EBS-backed world data remains attached.
Remove Discord Access
- Remove Minecraft Remote from the test guild through its Manage page under Server Settings and Integrations.
- Type /mc in the test guild to check the command picker.
You should no longer see /mc-start, /mc-stop, or /mc-status in the command picker.
- Delete Minecraft Operators from the test guild's role settings.
- Delete Minecraft Remote from its Discord Developer Portal application settings.
- Confirm Minecraft Remote no longer appears in your application list.
Remove the Public Endpoint
- Delete the HTTP API containing POST /interactions from the API Gateway API list in the target Region.
The HTTP API should no longer appear in the API list. Discord can no longer send interactions to the deleted route.
Remove the Function Resources
- Delete the Python 3.12 function from the Lambda function list in the target Region.
- Delete the function's log group from the CloudWatch Logs log groups list.
The function should no longer appear in Lambda. Its stored project logs should no longer appear in CloudWatch Logs.
Remove the Execution Role
- Detach AWSLambdaBasicExecutionRole from the Lambda execution role on its IAM permissions page.
- Delete the inline EC2 policy from the same execution role.
- Delete the Lambda execution role from the IAM role list.
The execution role should no longer appear in IAM. The deleted remote no longer has permission to inspect or change the target instance.
Delete the CloudShell Artifacts
The CloudShell project directory from Step 2 contains the deployment package and its source files.
- Switch back to the CloudShell project directory from Step 2.
- Remove the deployment artifacts from that directory by running:
rm -rf package function.zip commands.json lambda_function.py minecraft-ec2-control-policy.json requirements.txt test-event-unsigned-ping.json
What does this command remove?
The command removes only the named deployment files and the package directory from your current CloudShell directory. Unrelated CloudShell files remain untouched.
- Check the remaining files in the current directory by running:
ls
What should you see?
The deleted filenames and the package directory should be absent. Any unrelated files should still appear.
Nice Work!
Nice Work!
Excellent work! Your secure Discord remote now controls one approved Minecraft server on Amazon EC2.
Here's what you learned:
- Connected all three Minecraft commands to the signed POST /interactions route through a serverless command path. Amazon API Gateway forwards each interaction to AWS Lambda.
- Secured the remote with Ed25519 verification. Unsigned requests receive an HTTP 401 before any AWS action runs. Instance-scoped IAM permissions allow changes only on MINECRAFT_INSTANCE_ID.
- Validated EC2 lifecycle reporting for every tested state. Discord reported running, stopping, stopped, or pending as the instance changed state. The /mc-status command showed the current public IP only when one was assigned.
- Completed the Secret Mission by restricting all three Minecraft commands to the Minecraft Operators role. Approved members can use the remote. Other non-administrator members cannot. Server administrators retain command access.
Ready to quiz yourself?