How I Host Multiple Minecraft Servers at Home (Safely)
August 1, 2025 · 12 min · 2449 words · dei | Suggest Changes
Table of Contents
Last updated: August 2025
When I first started hosting Minecraft at home, I wanted three things: performance that felt local, the convenience of one‑click updates, and zero exposure of my home IP. This post walks through how I pieced that together—Docker for isolation, Discord bots for control, and a WireGuard+VPS hop so the world never sees where I live. Any infrastructure details below (like ports/domains) are examples, not my actual values.
The host is Arch Linux running Docker and Docker Compose. Each server lives in its own Compose stack using the excellent itzg/minecraft-server images. That gives me reproducible configs, clean upgrades, and a way to run multiple servers in parallel without dependency drama.
I keep persistent world data under ./data, mount it into the container, and let the image handle the rest (EULA, JVM flags, healthchecks, etc.). For modded packs, the CurseForge integration is a lifesaver—point it at a pack file or a list of mods and it fetches everything.
Each server runs its own compose stack similar to this one (my wolds modpack compose):
I rotate between a few worlds depending on what friends want to play:
Wolds (Forge 1.18.2, 12G RAM) on a dedicated host port mapped to the game’s container port.
Moni (1.20.1 modpack, 8G) with AUTO_CURSEFORGE. I pin the exact pack file (CF_SLUG=monifactory, CF_FILE_ID=6660745) so the environment is deterministic.
Omni (Forge 1.12.2, 8G) and Nomi‑CE (Forge 1.12.2, 4G) when we want that classic era.
Each server gets its own host port (examples shown); SRV records map friendly names to the right place.
Typing into a shell is fine, but Discord is where everyone is. I wrote small watchdog bots in Python that sit next to each server:
The Wolds bot watches Docker health every 30s, updates presence, and exposes !start, !stop, and !update. The update command performs a clean, progress‑reported sync of a new pack, using the same logic as my standalone updater script.
The other servers have lightweight bots with a !start command and status messages.
Logs rotate to disk, and the bots automatically restart daily to stay fresh.
Discord watchdog posting status and responding to commands.
A CLI update.py that downloads a zip, unpacks it under update/, and rsyncs into data/ with a set of guarded exclusions (databases, configs, server icon, etc.).
The Discord !update command, which triggers the same logic from inside the watchdog with progress messages.
Both approaches stop the server cleanly, sync files, and then optionally restart if it was running.
importosimportrequestsimportzipfileimportshutilimportsubprocessimportsys# --- Configuration ---SCRIPT_DIR=os.path.dirname(os.path.abspath(__file__))# Directory where the script livesROOT_DIR=SCRIPT_DIR# Assumes script is in the server root directory (e.g., wolds/)UPDATE_DIR_NAME="update"DATA_DIR_NAME="data"DOWNLOAD_URL="https://example.invalid/path/to/latest-pack.zip"# TODO: set your pack URLZIP_FILE_NAME="latest-pack.zip"# Delete mods folder before syncing for a clean installDELETE_MODS_FOLDER=True# Files under data/ that should NOT be overwritten during updatesCONFIG_EXCLUSIONS=["server-icon.png","config/luckperms/luckperms-h2.mv.db","config/minimotd/main.conf",# add more as needed (paths relative to data/)]UPDATE_DIR=os.path.join(ROOT_DIR,UPDATE_DIR_NAME)DATA_DIR=os.path.join(ROOT_DIR,DATA_DIR_NAME)ZIP_FILE_PATH=os.path.join(ROOT_DIR,ZIP_FILE_NAME)# --- End Configuration ---defbuild_rsync_exclusions():args=[]forrelpathinCONFIG_EXCLUSIONS:args.extend(["--exclude",relpath])returnargsdefconfirm_deletion(path,kind="file"):ifnotos.path.exists(path):returnTrueprint(f"\n=== DELETION CONFIRMATION ===\nAbout to delete {kind}: {path}")whileTrue:resp=input(f"Are you sure you want to delete this {kind}? (y/n): ").lower().strip()ifresp=="y":returnTrueifresp=="n":print(f"Skipping deletion of {path}")returnFalsedefrun_command(cmd):print("Running:"," ".join(cmd))try:res=subprocess.run(cmd,check=True,text=True,capture_output=True,encoding="utf-8")ifres.stdout:print(res.stdout.strip())ifres.stderr:print(res.stderr.strip())returnTrueexceptFileNotFoundError:print(f"ERROR: Command not found: {cmd[0]}. Is rsync installed?",file=sys.stderr)returnFalseexceptsubprocess.CalledProcessErrorase:print(f"ERROR: Command failed with exit code {e.returncode}",file=sys.stderr)ife.stdout:print(e.stdout.strip(),file=sys.stderr)ife.stderr:print(e.stderr.strip(),file=sys.stderr)returnFalsedefcleanup_update_dir(recreate=True):ifnotconfirm_deletion(UPDATE_DIR,"directory"):returnFalseifos.path.exists(UPDATE_DIR):shutil.rmtree(UPDATE_DIR)ifrecreate:os.makedirs(UPDATE_DIR,exist_ok=True)returnTruedefis_dir_empty(path):returnnot(os.path.isdir(path)andos.listdir(path))defmain():print("--- Starting Server Update Process ---")ifnotos.path.isdir(DATA_DIR):print(f"ERROR: Data directory '{DATA_DIR}' does not exist.",file=sys.stderr)sys.exit(1)# Reuse existing zip or download a fresh oneskip_download=Falseifos.path.exists(ZIP_FILE_PATH):print(f"Found existing zip: {ZIP_FILE_PATH}")resp=input("Use it and skip download? (y/n): ").lower().strip()skip_download=(resp=="y")ifnotskip_downloadandconfirm_deletion(ZIP_FILE_PATH,"file"):os.remove(ZIP_FILE_PATH)skip_download_extract=Falseifos.path.isdir(UPDATE_DIR)andnotis_dir_empty(UPDATE_DIR):print(f"Existing update files in: {UPDATE_DIR}")resp=input("Use existing files (skip download & extract)? (y/n): ").lower().strip()ifresp=="y":skip_download_extract=Trueelse:ifnotcleanup_update_dir(recreate=True):print("Cannot proceed without clearing update directory.")sys.exit(1)elifnotos.path.exists(UPDATE_DIR):os.makedirs(UPDATE_DIR)ifnotskip_download_extract:ifnotskip_download:print(f"Ready to download: {DOWNLOAD_URL}\nSaving to: {ZIP_FILE_PATH}")resp=input("Proceed with download? (y/n): ").lower().strip()ifresp!="y":print("Cancelled.")sys.exit(0)try:withrequests.get(DOWNLOAD_URL,stream=True,timeout=60)asr:r.raise_for_status()withopen(ZIP_FILE_PATH,"wb")asf:forchunkinr.iter_content(chunk_size=8192):ifchunk:f.write(chunk)print("Download complete.")exceptExceptionase:print(f"ERROR: download failed: {e}",file=sys.stderr)sys.exit(1)print(f"Extracting {ZIP_FILE_PATH} to {UPDATE_DIR}…")try:withzipfile.ZipFile(ZIP_FILE_PATH,"r")asz:z.extractall(UPDATE_DIR)exceptExceptionase:print(f"ERROR: extract failed: {e}",file=sys.stderr)cleanup_update_dir(recreate=False)sys.exit(1)ifos.path.exists(ZIP_FILE_PATH)andconfirm_deletion(ZIP_FILE_PATH,"file"):try:os.remove(ZIP_FILE_PATH)exceptExceptionase:print(f"WARNING: could not delete zip: {e}",file=sys.stderr)print("\n=== RSYNC PREVIEW ===")preview_cmd=["rsync","-avu","--dry-run"]preview_cmd.extend(build_rsync_exclusions())preview_cmd.extend([f"{UPDATE_DIR}/",f"{DATA_DIR}/"])ifnotrun_command(preview_cmd):sys.exit(1)print("\n=== RSYNC CONFIRMATION ===")print("This will update files in data/ and preserve timestamps; it will NOT delete extra files.")ifDELETE_MODS_FOLDER:print("It will also DELETE data/mods/ for a clean mod install.")resp=input("Proceed? (y/n): ").lower().strip()ifresp!="y":print("Cancelled.")sys.exit(0)ifDELETE_MODS_FOLDER:mods_dir=os.path.join(DATA_DIR,"mods")ifos.path.isdir(mods_dir)andconfirm_deletion(mods_dir,"directory"):shutil.rmtree(mods_dir)sync_cmd=["rsync","-avu"]sync_cmd.extend(build_rsync_exclusions())sync_cmd.extend([f"{UPDATE_DIR}/",f"{DATA_DIR}/"])ifnotrun_command(sync_cmd):print("ERROR: rsync failed.",file=sys.stderr)sys.exit(1)print("\nUpdate synchronization completed successfully!")cleanup_update_dir(recreate=False)print("--- Server Update Process Completed Successfully ---")if__name__=="__main__":# Ensure the Minecraft server container is STOPPED before running,# and RESTARTED after completion.main()
I enable ENABLE_AUTOSTOP=true with a small helper that toggles a .skip-stop sentinel. When friends are offline, servers pause themselves; when someone pings !start, they come back. Idle hardware means less heat, less noise, and lower costs.
Exposing it to the internet… without exposing home#
The public edge lives on a tiny VPS in a cloud region. It peers into my home over WireGuard and uses kernel‑level DNAT/MASQUERADE rules (iptables) to forward Minecraft TCP connections through the tunnel directly to the right server. Dropping the user‑space proxy layer (like nginx stream) trims latency and simplifies the data path. From the world’s perspective, only the VPS exists; my home IP stays private.
My DNS provider handles DNS and SRV records (e.g., mc.example.com). SRV targets point to the VPS IP and the appropriate public port. The VPS translates that to the internal game port on the home host via WireGuard.
Public clients → DNS/SRV → VPS (iptables DNAT) → WireGuard → home server.
Below is the redacted/templated script I use to manage mappings. It auto‑detects your WAN and WireGuard interfaces when possible and applies per‑port DNAT rules from a simple config file. Edit placeholders as needed.
sudo ./mc-forward add 25565 35565 forwards VPS port 25565 to the game server’s container port 35565 at BACKEND_IP.
sudo ./mc-forward add 25566 10.0.0.191 35565 forwards to a different home host.
Security is straightforward: online-mode=true, no RCON ports exposed publicly, and only WireGuard is open at home. If you self‑host, that last part is critical—don’t punch holes for game ports on your home router.
Spinning up a new server is just copying a Compose file, picking a free host port, dropping in a small Discord bot if needed, and updating my VPS to accommodate. As friends rotate packs, I can rotate stacks without really touching the rest of the pipeline.
If you’re curious about specifics (Compose snippets, watchdog code structure, or SRV examples), some of that stuff is already on my GitHub like the wolds watchdog. Keep an eye out for more as I am constantly evolving and pushing new changes!