A productive way to spend a weekend: build something real while having fun with friends.
The Problem: "Who's Hosting Tonight?"
Our friend group has been gaming together for years. Valheim, Minecraft, PUBG, Valorant — you name it, we've played it together. But Minecraft and Valheim have an annoying limitation that most multiplayer games don't: someone has to be the host.
Unlike Valorant or PUBG where you just queue up and play, games like Minecraft need a dedicated server running 24/7. That means whoever is hosting has to keep their game open. The moment they log off — everyone else gets kicked. No server, no game.
This led to a frustrating loop:
"Anyone up for Minecraft tonight?"
"Yeah! Who's hosting?"
"Idk, the usual guy is offline"
"Okay never mind then"
We looked at paid Minecraft hosting services. Some were decent, but even the cheapest ones felt like overkill for a small group of friends who just want to hop on occasionally. We weren't running a public server — just 5 people who want to mine some blocks after work.
So we decided: we'll host it ourselves on AWS.
What followed was one of the most satisfying one-day projects I've done — and I want to walk you through exactly how it works, what went wrong, and how AI made it all possible in a single day.
The Goal
By the end of the day, I wanted:
- A Minecraft server running on the cloud that anyone in our friend group could access
- The ability for any friend to start or stop the server — without needing to SSH into a terminal
- Security so that only our friends can connect, not random internet strangers
- Cost as close to zero as possible
Let's get into it.
Step 1: Setting Up AWS Free Tier
The first thing I did was create an AWS Free Tier account. The free tier gives you:
- 750 hours/month of EC2 compute (enough for one instance running 24/7)
- 30 GB of EBS storage
- 100 GB of outbound data transfer
- All free for 6 months
Step 2: Launching an EC2 Instance — The Wrong Region First
Here's my first mistake, and it's a classic one: I launched the instance in the wrong region.
I spun up an EC2 instance in Europe (Stockholm) — eu-north-1. Everything worked technically, but the ping from India to Ireland is around 150-220ms. In Minecraft, that's the difference between placing a block and watching it rubber-band back to where it was.
The fix was to terminate that instance and re-launch in Asia Pacific (Mumbai) — ap-south-1. Mumbai gave us 20-40ms ping, which is smooth enough to play comfortably.
Lesson: Always pick a region closest to your players. For India, Mumbai (ap-south-1) is the right choice.
EC2 Instance Configuration
| Setting | Value |
|---|---|
| AMI | Ubuntu Server 24.04 LTS |
| Instance Type |
t3.small (2GB RAM) |
| Storage | 20 GB EBS |
| Region |
ap-south-1 (Mumbai) |
Security Group Rules
| Type | Protocol | Port | Source |
|---|---|---|---|
| SSH | TCP | 22 | My IP only |
| Custom TCP | TCP | 25565 | Friends' IPs only |
Step 3: Setting Up the Minecraft Server
Once SSH'd into the instance, setting up the server itself is straightforward.
# Update system
sudo apt update && sudo apt upgrade -y
# Install Java (Minecraft requires Java 21)
sudo apt install -y openjdk-25-jre-headless screen
# Create and enter the Minecraft directory
mkdir ~/minecraft && cd ~/minecraft
# Download the Minecraft server jar
wget https://piston-data.mojang.com/v1/objects/97ccd4c0ed3f81bbb7bfacddd1090b0c56f9bc51/server.jar
# First run to generate config files
java -Xmx700M -Xms384M -jar server.jar nogui
Then accept the EULA:
nano eula.txt
# Change: eula=false → eula=true
And configure the server:
nano server.properties
Key settings:
online-mode=false # Allows cracked/unlicensed clients
max-players=5 # Max players joining the game at a time
Setting Up as a System Service
To make the server start automatically and be manageable by our Discord bot, I set it up as a systemd service:
sudo nano /etc/systemd/system/minecraft.service
[Unit]
Description=Minecraft Server
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu/minecraft
ExecStart=/usr/bin/java -Xmx700M -Xms384M -jar server.jar nogui
Restart=no
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable minecraft
Step 4: The Discord Bot — The Star of the Show
This is where things got interesting. The naive approach would be to give everyone SSH access and let them run systemctl start minecraft themselves. But SSH from a phone is a pain, and even I wouldn't like to open the terminals every time I play/stop the game.
The better solution: a Discord bot that anyone in our server can use.
With a simple !start in Discord, the bot spins up the Minecraft server. !stop shuts it down. No terminal needed, no SSH, no dependency on me being online.
Creating the Bot
- Go to discord.com/developers/applications
- Create a new application → go to Bot → copy the token
- Enable Message Content Intent under Privileged Gateway Intents
- Generate an invite URL under OAuth2 → URL Generator with
botscope andSend Messages+Read Messages+View Channelpermissions
The Bot Code
import discord
import subprocess
import asyncio
import json
import os
from datetime import datetime
# ---- CONFIG ----
BOT_TOKEN = "your-bot-token"
SECURITY_GROUP_ID = "sg-xxxxxxxxx"
REGION = "ap-south-1"
ELASTIC_IP = "your-elastic-ip"
MC_LOG = "/home/ubuntu/minecraft/logs/latest.log"
IP_TRACKER_FILE = "/home/ubuntu/ip_tracker.json"
INACTIVE_DAYS = 5
NOTIFY_CHANNEL = "general"
# ----------------
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
def run_command(cmd):
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return result.returncode == 0
def is_server_running():
result = subprocess.run(
"systemctl is-active minecraft",
shell=True, capture_output=True, text=True
)
return result.stdout.strip() == "active"
@client.event
async def on_ready():
print(f"Bot online as {client.user}")
@client.event
async def on_message(message):
if message.author == client.user:
return
if not isinstance(message.channel, discord.TextChannel):
return
if message.content == "!start":
if is_server_running():
await message.channel.send(f"✅ Already running! Connect to: `{ELASTIC_IP}:25565`")
else:
await message.channel.send("⏳ Starting Minecraft server...")
if run_command("sudo systemctl start minecraft"):
await asyncio.sleep(10)
await message.channel.send(f"✅ Server is up! Connect to: `{ELASTIC_IP}:25565`")
else:
await message.channel.send("❌ Failed to start. Check EC2 logs.")
elif message.content == "!stop":
if not is_server_running():
await message.channel.send("⚠️ Server is already stopped.")
else:
await message.channel.send("⏳ Stopping server...")
if run_command("sudo systemctl stop minecraft"):
await message.channel.send("🛑 Server stopped.")
elif message.content == "!status":
if is_server_running():
await message.channel.send(f"🟢 Server **online**! Connect to: `{ELASTIC_IP}:25565`")
else:
await message.channel.send("🔴 Server **offline**. Type `!start` to start it.")
client.run(BOT_TOKEN)
Running the Bot as a Service
Just like the Minecraft server, the bot runs as a systemd service so it auto-starts on reboot:
[Unit]
Description=Minecraft Discord Bot
After=network.target
[Service]
User=ubuntu
WorkingDirectory=/home/ubuntu
ExecStart=/usr/bin/python3 /home/ubuntu/minecraft_bot.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Step 5: Security — Locking Down the Server
With online-mode=false (needed for cracked clients), anyone who reaches port 25565 can technically join with any username. That's not great.
Layer 1: EC2 Security Groups
The most powerful protection is at the network level. In AWS, Security Groups act as a firewall. I configured port 25565 to only accept connections from specific IP addresses — our friends' home IPs.
This means strangers can't even reach the server. Their connection attempt times out before Minecraft ever sees it.
Layer 2: Self-Service IP Whitelisting via Discord
Home internet IPs change occasionally. To handle this without me having to manually update the security group every time, I added an !addip command to the bot:
elif message.content.startswith("!addip "):
ip = message.content.split(" ")[1].strip()
parts = ip.split(".")
if len(parts) != 4 or not all(p.isdigit() for p in parts):
await message.channel.send("❌ Invalid IP format. Use: `!addip 123.456.78.90`")
return
ok, err = add_ip_to_sg(ip)
if ok:
tracker = load_tracker()
tracker[ip] = datetime.now().isoformat()
save_tracker(tracker)
await message.channel.send(
f"✅ {message.author.mention} your IP `{ip}` is whitelisted! "
f"Connect to `{ELASTIC_IP}:25565`"
)
else:
if "InvalidPermission.Duplicate" in err:
await message.channel.send(f"⚠️ `{ip}` is already whitelisted! Try connecting.")
else:
await message.channel.send(f"❌ Failed: ```
{% endraw %}
{err[:300]}
{% raw %}
```")
Now any friend can whitelist themselves:
- Go to whatismyip.com
- Type
!addip YOUR.IP.HEREin Discord - Connect to the server
Step 6: Auto-Cleanup of Inactive IPs
Here's a feature I'm particularly proud of: automatic removal of IPs that haven't been used in 5 days.
Without this, the security group would slowly accumulate old IPs as friends' home IPs change — creating stale entries that could potentially be reassigned to someone else.
The solution: a background task that runs every 24 hours, checks when each IP last appeared in the Minecraft server logs, and removes IPs that haven't been active for 5+ days.
async def auto_remove_inactive():
await client.wait_until_ready()
await asyncio.sleep(30)
while True:
try:
tracker = load_tracker()
whitelisted = get_whitelisted_ips()
now = datetime.now()
removed = []
for ip in whitelisted:
last_seen_str = tracker.get(ip)
if last_seen_str is None:
tracker[ip] = now.isoformat()
continue
days_inactive = (now - datetime.fromisoformat(last_seen_str)).days
if days_inactive >= INACTIVE_DAYS:
ok, _ = remove_ip_from_sg(ip)
if ok:
removed.append((ip, days_inactive))
tracker.pop(ip, None)
save_tracker(tracker)
# Only notify Discord if something was actually removed
if removed:
for guild in client.guilds:
for channel in guild.text_channels:
if channel.name == NOTIFY_CHANNEL:
msg = "🧹 **Auto-cleanup:** Removed inactive IPs:\n"
for ip, days in removed:
msg += f"• `{ip}` — inactive for **{days} days**\n"
msg += "Type `!addip <your-ip>` to get back in!"
await channel.send(msg)
break
except Exception as e:
print(f"[Auto-remove error]: {e}")
await asyncio.sleep(86400) # Run every 24 hours
The hourly log scan that updates last-seen times runs completely silently — no Discord messages, just background bookkeeping.
Step 7: Elastic IP — Never Change the Server Address Again
Every time you stop and start an EC2 instance, AWS assigns it a new public IP. That means updating the Minecraft client, updating the bot, and telling friends the new address every single time.
The fix is an Elastic IP — a static IP address that stays the same regardless of instance restarts:
- EC2 Dashboard → Elastic IPs → Allocate Elastic IP
- Actions → Associate → select your instance
Free while the instance is running. A small charge (~$0.005/hr) applies when the instance is stopped, so release it if you're going offline for an extended period.
Step 8: Daily S3 Backups
A cron job backs up the world folder to S3 every day at 3AM, keeping only the last 3 backups automatically deleting older ones.
# Crontab entry
0 3 * * * /home/ubuntu/backup_minecraft.sh >> /home/ubuntu/backups/backup.log 2>&1
Cost: ~$0.02/month for 1GB on S3.
Step 9: Switching to PaperMC
After running vanilla for a while, we switched to PaperMC — a high-performance drop-in replacement that uses about 30% less RAM with no noticeable gameplay difference for casual players.
The switch is literally one word in the service file:
# Before (vanilla)
ExecStart=/usr/bin/java -Xmx700M -Xms384M -jar server.jar nogui
# After (Paper)
ExecStart=/usr/bin/java -Xmx700M -Xms384M -jar paper.jar nogui
Rolling back to vanilla is equally simple — swap the jar name back. World data is shared between both jars, nothing is ever lost.
The Problems I Hit — And How I Fixed Them
This is the part most blog posts skip. Here's every real issue I ran into post-setup, what caused it, and exactly how I fixed it.
Problem 1: Bot Not Responding to Commands
Symptom: Bot showed as online in Discord but !status got no reply whatsoever.
Root cause 1 — Wrong channel: The bot was configured to only listen in #minecraft but I was typing in #general. Commands in any other channel were silently ignored.
Fix: Remove the channel restriction or update ALLOWED_CHANNEL in the config.
Root cause 2 — DMing the bot: Someone sent commands as a Direct Message to the bot instead of in a server channel. DM channels don't have a .name attribute, causing the bot to crash silently on every message.
Fix: Add this check at the top of on_message:
if not isinstance(message.channel, discord.TextChannel):
return
Problem 2: 15+ Duplicate Java Processes Eating All RAM
Symptom: htop showed 15+ java processes all running server.jar, RAM sitting at 680MB+ before anyone even joined.
What it looked like in htop:
PID USER CPU% MEM% COMMAND
526 ubuntu 0.0 51.8 /usr/bin/java -Xmx768M -Xms512M -jar server.jar
619 ubuntu 0.0 51.8 /usr/bin/java -Xmx768M -Xms512M -jar server.jar
694 ubuntu 0.0 51.8 /usr/bin/java -Xmx768M -Xms512M -jar server.jar
... (12 more identical lines)
Root cause: Every time someone typed !start while the server was still booting (during a blind await asyncio.sleep(10)), the bot fired another systemctl start command. These stacked up across multiple sessions because old Java processes were never cleaned up.
Immediate fix:
sudo systemctl stop minecraft
sudo pkill -9 java
sleep 3
ps aux | grep java | grep -v grep | wc -l # Must print 0
sudo systemctl start minecraft
Permanent fix — replace blind sleep with polling:
# Old (bad) approach:
await asyncio.sleep(10)
await message.channel.send("✅ Server is up!") # Assumed it was up
# New (reliable) approach:
for i in range(6): # Try 6 times, 5 seconds apart = 30 seconds max
await asyncio.sleep(5)
check = subprocess.run("systemctl is-active minecraft", ...)
if check.stdout.strip() == "active":
await message.channel.send("✅ Server is up!")
return
Also added an activating state check so rapid !start calls are blocked while the server is already booting:
if status == "activating":
await message.channel.send("⏳ Server is already starting, please wait...")
return
Service file fix: Added KillMode=control-group to ensure all child processes are killed when the service stops, preventing zombie Java threads from accumulating.
Problem 3: Server Crash — "Can't Keep Up, Running 40 Ticks Behind"
Symptom: Player joined, lag was immediate, then the server printed this in the logs:
[Server thread/WARN]: Can't keep up! Is the server overloaded? Running 2010ms or 40 ticks behind
[Server thread/WARN]: Player1 moved too quickly! -6.79, -1.0, 7.32
Then everyone disconnected. SSH also became unresponsive.
Root cause: RAM was completely exhausted. With duplicate Java processes still running in the background and a player loading chunks, the instance hit ~830MB out of 911MB available. Java started aggressively garbage collecting and couldn't keep up with the game tick loop. The instance became so slow it couldn't even handle SSH connections.
Fix — three things in combination:
- Kill all duplicate Java processes (see Problem 2)
- Switch to PaperMC — uses ~320MB vs vanilla's ~480MB
- Upgrade from
t3.micro(1GB) tot3.small(2GB) for proper headroom
After all three fixes, RAM with 1 players active sits at ~700MB out of 1.86GB — stable and comfortable.
Problem 4: Wrong PaperMC Version — World Failed to Load
Symptom: Paper crashed immediately on startup with:
Failed to load datapacks, can't proceed with server load.
java.lang.IllegalStateException: No key dimensions in MapLike[{}]; No key seed in MapLike[{}]
Root cause: I assumed my server was running Minecraft 1.21.4 and downloaded the Paper build for that version. But Mojang switched to a new versioning format in 2026 — my server was actually running 26.1.2, not 1.21.4. The version mismatch meant Paper couldn't read the world data format.
Fix: Download the Paper build that matches your exact version string. Always verify before downloading.
Problem 5: Unnecessary System Processes Consuming Hundreds of MB
Symptom: With Minecraft stopped and the bot idle, htop showed RAM at 300MB+ — nearly 40% of a 1GB instance.
Investigation: Sorting htop by memory revealed several heavyweight processes with no purpose on a headless game server:
| Process | RAM Used | What it does | Decision |
|---|---|---|---|
amazon-ssm-agent |
~253MB | AWS remote management via Systems Manager | Removed — not needed |
snapd |
~189MB | Snap package manager | Installed AWS CLI directly, Removed Snap |
multipathd |
~26MB | Storage multipathing for enterprise SANs | Disabled — pointless on EBS |
packagekitd |
~80MB | GUI software update daemon | Disabled — no GUI on this server |
udisksd |
~54MB | Desktop disk management | Disabled — not needed |
ModemManager |
~28MB | Mobile modem manager | Disabled — we're on a cloud VM |
Result: Idle RAM dropped from ~300MB to ~195MB — a 105MB saving without touching Minecraft at all.
RAM Usage — Before and After All Optimizations
| State | Before | After |
|---|---|---|
| Idle (Minecraft stopped) | ~300MB | ~195MB |
| Minecraft running, no players | ~680MB | ~560MB |
| 1 players active | ~780MB | ~650MB ✅ |
The combination of killing duplicate processes, disabling unused system services, and switching to PaperMC transformed an unstable setup into a smooth one. I also switched from t3.micro(1 GB RAM) to t3.small(2 GB RAM) which gives a better headroom when multiple players join.
The Final Architecture
Friend wants to play
↓
Types !start in Discord
↓
Bot checks systemctl status — blocks if already starting
↓
Bot polls every 5 seconds until server is confirmed active
↓
Friend types !addip with their IP → added to EC2 Security Group
↓
Friend connects to Elastic IP:25565 in Minecraft client
↓
Everyone plays!
↓
Last person types !stop in Discord
↓
Server shuts down, EC2 stays on (~$0.01/hr)
↓
Daily backup runs at 3AM → uploaded to S3, old ones auto-deleted
↓
After 5 days of inactivity → IP auto-removed from security group
Cost Breakdown
| Item | Cost |
|---|---|
EC2 t3.small — free tier (6 months) |
$0/month |
EC2 t3.small — after free tier |
~$15/month |
| Elastic IP (while instance running) | $0 |
| S3 backup storage (5GB) | ~$0.02/month |
| Realistic total after free tier | ~$15/month |
Split across 3 friends — $5/month each cheaper than other hosted Minecraft service, and we own the whole stack.
The Role of AI in This Project
I want to be honest: I couldn't have built and debugged all of this in a single weekend without AI assistance. I used Claude as a co-pilot throughout — including every debugging session.
AI didn't replace the engineering thinking. I still had to read the logs, understand what was actually failing, and make the architectural decisions. But having something that could instantly explain what Can't keep up! Running 40 ticks behind means, or why 15 Java processes spawned, or what multipathd does and whether I can safely remove it — that saved hours.
The debugging felt collaborative rather than lonely. That's new, and it's genuinely useful.
Key Lessons Learned
Pick your region before anything else. Moving regions after setup means rebuilding from scratch.
Polling beats sleeping. await asyncio.sleep(10) then assuming the server is up is fragile and leads to duplicate processes. Poll and check actual status.
Audit your idle RAM. A headless Ubuntu server runs dozens of services that are completely useless for this use case. Check htop, identify what each process does, and disable what you don't need.
KillMode=control-group in systemd service files ensures all child threads die when you stop a service — not just the parent process.
Always back up before switching server jars. Takes 30 seconds, saves you from potential world loss.
Wrapping Up
What started as a frustrating "who's hosting tonight?" problem turned into a genuinely educational engineering project. The problems I hit post-setup — duplicate processes, RAM exhaustion, version mismatches — were frustrating in the moment but taught me more about Linux process management and AWS than any tutorial would have. Real debugging always does.
We now have a cloud Minecraft server that any friend can start with a single Discord message, costs ~$3-5/month per person, secures itself automatically, backs up daily, and requires zero maintenance from me.
If you're a developer who plays games with friends — build something like this. The skills overlap perfectly with real infrastructure work, and the payoff is immediate.
Happy crafting. ⛏️

Top comments (0)