DEV Community

Cover image for How I Built a Self-Hosted Minecraft Server on AWS in One Day — With a Discord Bot, Auto-Security, and Almost Zero Cost
Biraj De
Biraj De

Posted on

How I Built a Self-Hosted Minecraft Server on AWS in One Day — With a Discord Bot, Auto-Security, and Almost Zero Cost

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then accept the EULA:

nano eula.txt
# Change: eula=false → eula=true
Enter fullscreen mode Exit fullscreen mode

And configure the server:

nano server.properties
Enter fullscreen mode Exit fullscreen mode

Key settings:

online-mode=false     # Allows cracked/unlicensed clients
max-players=5         # Max players joining the game at a time
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
[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
Enter fullscreen mode Exit fullscreen mode
sudo systemctl daemon-reload
sudo systemctl enable minecraft
Enter fullscreen mode Exit fullscreen mode

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.

Discord Bot Working

Creating the Bot

  1. Go to discord.com/developers/applications
  2. Create a new application → go to Bot → copy the token
  3. Enable Message Content Intent under Privileged Gateway Intents
  4. Generate an invite URL under OAuth2 → URL Generator with bot scope and Send Messages + Read Messages + View Channel permissions

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %}
```")
Enter fullscreen mode Exit fullscreen mode

Now any friend can whitelist themselves:

  1. Go to whatismyip.com
  2. Type !addip YOUR.IP.HERE in Discord
  3. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. EC2 Dashboard → Elastic IPsAllocate Elastic IP
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Kill all duplicate Java processes (see Problem 2)
  2. Switch to PaperMC — uses ~320MB vs vanilla's ~480MB
  3. Upgrade from t3.micro (1GB) to t3.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[{}]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Technical Architecture


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. ⛏️

Mining with Friends

Top comments (0)