Minecraft

Hardening the Box: Minecraft Server Security Below the Game

A Minecraft host has one port that must face the internet and a dozen that must not. The JVM flag that turns your server into a remote shell, the protocol that broadcasts its own password, the SSH default that is actually the problem, and the second computer inside every dedicated box.

Hardening the Box: Minecraft Server Security Below the Game
Photo by Connor Gan / Unsplash

Most server hardening advice assumes you are protecting a web server, where the job is to expose two ports carefully and nothing else. A Minecraft host inverts that. You have one port that must be reachable by strangers on the open internet, a JVM behind it running code you did not write, and a surrounding machine that has no business being reachable at all.

That shape changes the priorities. The game port is not the risk. The risk is everything that ends up listening beside it.

The Only Port That Should Be Open

Start with an inventory, because almost every real compromise of a self-hosted Minecraft box begins with something listening that nobody meant to expose.

Port What it is Public?
25565/tcp The game Yes — this is the job
22/tcp SSH Your addresses only, or behind a tunnel
25575/tcp RCON Never — it leaks its own password
25566+/tcp Proxied backends Never
25565/udp GS4 query Only if something needs it
3306, 5432 Database Loopback or private interface
5005, 9010 JDWP, JMX Never, and see below
623/udp IPMI / BMC Never — and check, see below
623/udp IPMI / BMC Never — dedicated hardware only

Default-deny, then open the one thing:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 25565/tcp
sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp
sudo ufw enable

Then verify against the kernel rather than against the firewall, because a firewall reports its own intentions and the socket table reports reality:

ss -tulnp

Anything bound to 0.0.0.0 or ::: that is not 25565 is a finding.

On a Docker host, treat that output as the only answer that counts. Docker's own documentation is explicit: "Docker and ufw use firewall rules in ways that make them incompatible with each other."

The mechanism, from the same page: "Docker routes container traffic in the nat table, which means that packets are diverted before it reaches the INPUT and OUTPUT chains that ufw uses." And the port-publishing page gives the blunt version — "Publishing container ports is insecure by default."

So ufw status will report a port as blocked while the internet connects to it. The socket table is the only thing telling you the truth.

If you run a proxy with backends, the ports above are only half the story and the other half is proxy forwarding security — backends that run with authentication disabled, which is what a proxied setup requires.

RCON Broadcasts Its Own Password

RCON gets its own section because "never expose it" undersells the problem. It is not merely another service that could be attacked — it leaks the credential that protects it.

The protocol is Valve's Source RCON over plain TCP, and authentication is one packet containing the password. Here is the whole of it, from gorcon, a Go client implementing the protocol:

func (c *Conn) auth(password string) error {
	if err := c.write(SERVERDATA_AUTH, SERVERDATA_AUTH_ID, password); err != nil {
		return err
	}

The password goes straight into the packet body, which the same file documents as "a null-terminated string encoded in ASCII (i.e. ASCIIZ)." No hashing, no challenge-response, no transport encryption anywhere in the protocol. Anyone positioned on the path between client and server reads your RCON password off the wire the first time you use it — and what that password buys is console access, every server command, including /op.

So three rules, in order of how much they matter:

enable-rcon=false

Off, unless something actually needs it. If something does, bind it to loopback and reach it through an SSH tunnel, which gives you the encryption the protocol lacks:

ssh -N -L 25575:127.0.0.1:25575 minecraft@your-host

And treat the password as already compromised if it has ever crossed a network you do not own. On shared or multi-tenant infrastructure, that includes the segment you share with strangers.

The JVM Is a Remote Shell If You Let It

This is the one that deserves the most attention, because it is a total compromise, it has no exploit attached, and the circumstances that produce it are completely ordinary: your server lags, you attach a debugger, you forget to remove the flag.

The Java Debug Wire Protocol has no authentication. None. A debugger that connects can set fields, invoke arbitrary methods, and load classes. Connecting to an open JDWP port is code execution as the server user — there is nothing to bypass.

Modern JDKs are safe by default here, and the detail is worth knowing precisely because it is the difference between a localhost-only debug port and an open door. Measured on OpenJDK 21.0.12.1, with the listener read back from /proc/net/tcp:

Flag Binds to
-agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n 127.0.0.1:5005
-agentlib:jdwp=transport=dt_socket,server=y,address=0.0.0.0:5007,suspend=n 0.0.0.0:5007

A bare port number binds loopback only. Adding a host part — 0.0.0.0:, *:, or the machine's address — is what publishes it. So the dangerous version of this flag is the one people copy from a Stack Overflow answer written for a JDK that bound everything by default, or paste from a guide that includes address=*:5005 because that is what made it work from their laptop.

Two rules follow. Debug over an SSH tunnel to the loopback port rather than exposing the port:

ssh -N -L 5005:127.0.0.1:5005 minecraft@your-host

And grep your start script before every deploy:

grep -nE 'jdwp|jmxremote|\*:[0-9]+' start.sh

JMX is the same risk with the opposite default, and that asymmetry is the part to remember.

Start a JVM with a management port and leave authentication alone, and it refuses outright:

Error: Password file not found: /usr/lib/jvm/java-21-openjdk-amd64/conf/management/jmxremote.password
jdk.internal.agent.AgentConfigurationError

The secure default fails loudly, which is correct behaviour and also the whole problem — because the fix that makes monitoring work is one search away, and it is authenticate=false. Add it and the JVM starts cleanly, says nothing, and binds this:

LISTEN 0.0.0.0:9013

No host part required. Unlike JDWP, a bare JMX port is not loopback-scoped: it is on every interface, unauthenticated, from the moment the server boots. JDWP you have to work to expose; JMX you have to work to contain.

It gets worse, and this is the part that changed my own advice. The obvious fix is to bind the thing to loopback:

-Dcom.sun.management.jmxremote.host=127.0.0.1

That works, as far as it goes — the registry moves to 127.0.0.1:9020. But enumerate every listening socket rather than grepping for the port you configured, and a second one appears:

  127.0.0.1:9020
  0.0.0.0:38913

Across three runs on OpenJDK 21 that second port came up as 38913, then 36671, then 38811 — a different high port each boot, on every interface, present only while the JMX agent was loaded and absent from a plain JVM with no management flags. Pinning jmxremote.rmi.port to the registry port does not remove it either.

Which means you cannot firewall JMX by port number, because the number is not stable. The only things that contain it are a default-deny inbound policy — the ufw default deny incoming from the first section, doing the actual work — or not enabling JMX at all. Prefer a profiler that reports outward over a management port that accepts connections inward; profiling a network covers that side.

The reason this sits above everything else: once someone has code execution inside the JVM, every control that assumes the server process is trustworthy is gone — your database credentials, your proxy forwarding secret, your RCON password, all of it readable. It is the same blast radius as a malicious plugin, reached by a config flag instead of a supply chain.

What SSH Actually Leaves Open

The common advice — "disable root login" — aims at a door that is already mostly shut. From OpenSSH's own sshd_config manual:

  • PermitRootLogin defaults to prohibit-password, under which "password and keyboard-interactive authentication are disabled for root."
  • PasswordAuthentication defaults to yes.
  • KbdInteractiveAuthentication defaults to yes.
  • MaxAuthTries defaults to 6, and: "Once the number of failures reaches half this value, additional failures are logged."
    Read those together. Root is not accepting passwords on a stock configuration. Every other account is. So the setting that matters is PasswordAuthentication, and the one almost everybody forgets is KbdInteractiveAuthentication — turn off the first and leave the second, and on a PAM-enabled distro you may still have an interactive password path open. Close both:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3

Confirm what the daemon actually resolved, rather than what the file says:

sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractive|maxauthtries'

That sshd -T habit is worth forming. Config files on a server that has been administered for a year contain settings that are shadowed, overridden by a Match block, or living in an Included file nobody remembers.

On moving SSH to a high port: it is not security, and it is not useless either. It removes you from the indiscriminate scanning that fills logs, which makes the remaining failures worth reading. Call it noise reduction and keep key-only auth as the actual control.

On fail2ban: with passwords disabled it is doing almost nothing for authentication, because there is no credential to guess. Its value is log volume and the occasional badly-behaved scanner. Install it if you like tidy logs; do not install it and consider SSH handled.

For anything beyond this — not exposing SSH at all, identity-aware access, short-lived certificates — zero-trust access for homelabbers is the better model, and it applies to a game server as well as anything else.

Memory Is How This Box Dies

On a VPS, the most likely cause of an unplanned outage is not an attacker. It is the kernel deciding your JVM is the largest thing it can kill.

This happens because -Xmx is not the process footprint. Reading back the defaults on a 6 GB-heap OpenJDK 21 process, outside the heap entirely: ReservedCodeCacheSize is 251658240 bytes — 240 MB — of JIT code ceiling, MaxMetaspaceSize is unbounded and grows with plugin count, ThreadStackSize is 1024 KB per thread, and CompressedClassSpaceSize reserves a further gigabyte of address space. Size a 7 GB heap on an 8 GB VPS and the OOM killer is not a risk, it is a schedule.

Leave real headroom, and let systemd enforce it rather than hoping. From systemd's resource-control documentation:

[Service]
MemoryMax=7G
OOMPolicy=stop
Restart=on-failure
RestartSec=15s

MemoryMax is "the absolute limit on memory usage of the executed processes in this unit. If memory usage cannot be contained under the limit, out-of-memory killer is invoked inside the unit." That last clause is the point: the kill happens inside your unit instead of the kernel surveying the whole machine and picking your server because it is the biggest thing running. Your SSH session survives.

Note which knob to use. The same documentation recommends MemoryHigh= "as the main control mechanism and use MemoryMax= as the last line of defense" — but read what MemoryHigh does before taking that advice for a game server. Exceeding it means "the processes are heavily slowed down and memory is taken away aggressively," which in a JVM holding a live world is indistinguishable from the server freezing. For a Minecraft backend, a hard MemoryMax that contains the damage is the right tool and MemoryHigh throttling is a worse outcome than the kill.

OOMPolicy decides what happens afterwards, and it is worth setting explicitly rather than inheriting. With stop or kill, per the documentation, "the service ultimately ends up in the oom-kill failed state after which Restart= may apply" — so your restart policy fires. With continue, the kill "is logged but the unit continues running," which gives you a unit systemd believes is fine containing nothing at all. The default comes from DefaultOOMPolicy= in systemd-system.conf, which ships as stop on Ubuntu 24.04. Check yours rather than assuming, and set it in the unit so the question does not arise:

systemctl show your-server.service -p OOMPolicy -p MemoryMax

The heap arithmetic itself — how much to give each process when several share a host — is the operations side of this.

Patching, and the Reboot You Keep Postponing

Unattended security updates are the highest-value control on this list and the one most often skipped on game servers, for an understandable reason: nobody wants a package manager restarting things mid-session. The fix is to separate installing from rebooting.

On Debian and Ubuntu, unattended-upgrades already does the first half sensibly. From its documentation: "The default setup auto-updates packages in the main and security archives, which means that only stable and security updates are applied." That is the behaviour you want — security fixes land, feature churn does not.

The second half is the part that catches people. Unattended-Upgrade::Automatic-Reboot defaults to False, documented as "Automatically reboot WITHOUT CONFIRMATION if the file /var/run/reboot-required is found after the upgrade." Leave it false — you do not want an unannounced reboot during peak hours. If you ever do turn it on, know that its companions default the unfriendly way: Automatic-Reboot-WithUsers is True ("Automatically reboot even if users are logged in") and Automatic-Reboot-Time is "now".

But understand what leaving it false means. A kernel update installs, the marker file appears, and your box keeps running the old vulnerable kernel until something reboots it. Indefinitely. That is the gap where "we have automatic updates" turns into a year-old kernel.

Here is the part that makes this easy on a Minecraft host specifically. Your server already needs periodic restarts — for memory, for world saves, for plugin updates. That restart window and your reboot window are the same window. Check for the marker and act on it there:

[ -f /var/run/reboot-required ] && systemctl reboot

One configuration note from the same documentation, because it bites silently. Override settings in a new file rather than editing the shipped one: the maintainers recommend a separate fragment such as 52unattended-upgrades-local, on the grounds that updates to the shipped file can conflict with local changes and block unattended-upgrades from updating itself. Edit the stock file and you can end up with the tool that applies your updates unable to update itself.

Patch the JVM on the same discipline. A Minecraft server pins a Java major version, which people read as a reason not to touch it — but security releases within a major version are exactly the updates you want, and they are the ones that fix the runtime your untrusted plugin code executes inside.

Backups You Have Actually Restored

A Minecraft world is a directory of region files that the server is writing to continuously. Copy it while the server runs and you get a backup of a world mid-write, which restores into corrupt chunks often enough to matter and rarely enough that you will not find out until the day you need it.

Quiesce first, then copy. The console sequence is save-off to stop the server writing, save-all flush to force what is pending out to disk, your copy, then save-on to resume. Run it through RCON on loopback or your console wrapper, not by hand at three in the morning.

If you would rather take saving out of the server's hands entirely, Paper's autosave interval lives in bukkit.yml as ticks-per: autosave, which ships at 6000 ticks and documents -1 to disable — at which point your backup script owns the schedule and you had better be sure it runs.

Three properties separate a backup from a folder you copied once:

It leaves the machine. A snapshot on the same VPS protects you from your own mistakes and from nothing else. Ransomware, a compromised server user, and a provider incident all take the local copy with them.

The server user cannot delete it. If the account running the JVM can write to the backup destination, then code execution in the JVM reaches your backups. Push from the backup host by pulling, or use append-only credentials.

You have restored from it. Once. On purpose, into a throwaway server, with a stopwatch. An untested backup is a belief, and the thing you will discover is never that the files are missing — it is that nobody wrote down which directories matter or what version of the server JAR the world needs.

Who Else Can Reach the Hardware

What you rent decides who else can touch the machine, and both answers sit below everything configured above.

The control panel bypasses everything above. Your provider's dashboard can usually mount a rescue image, open a VNC console, or reset the root password. Every control in this article sits inside the operating system and none of them survive that. The dashboard credential is therefore more powerful than root on the box — put the strongest second factor it offers on it, and put it somewhere other than the password manager you access from the server.

On shared infrastructure, your neighbours are nearby. Only allow connections from this IP is a weaker statement than it looks when anyone can rent a box in the same facility, which is why an IP allowlist is not an authentication mechanism. VPS gives you your own kernel and network namespace; it does not give you a quiet neighbourhood.

Dedicated Servers are at Risk

A dedicated server swaps the provider's dashboard for hardware you control — and inherits a second computer you probably have not thought about. The baseboard management controller runs its own firmware, has its own network presence, and works whether or not the operating system is running.

CISA's standing advisory on IPMI is blunt about what that means: "Attackers can use IPMI to essentially gain physical-level access to the server," compromising data while "bypassing any operating system controls."

Three specifics from the same advisory are worth sitting with:

  • IPMI traffic runs on "usually UDP port 623."
  • "Passwords for IPMI authentication are saved in clear text."
  • "'cipher 0' is an option enabled by default on many IPMI enabled devices that allows authentication to be bypassed."
  • "There are few, if any, monitoring tools available to detect if the BMC is compromised."
    Read that list against the rest of this article. Every control above — the firewall, SSH keys, systemd confinement — lives inside the operating system, and a BMC sits underneath all of it. An authentication bypass enabled by default, on a service that keeps its password in cleartext, on a port most people have never checked, with no good way to tell if it has already been used.

So for a dedicated box, three things before anything in the earlier sections matters:

  1. Find out whether the BMC is reachable from the internet. Scan UDP 623 from outside. Many providers put it on a separate management network; some do not.
  2. Change the credentials and disable cipher 0 and anonymous login. CISA's recommendation is explicit on both.
  3. Keep it off the public internet entirely. The advisory's first mitigation is exactly that: "Restrict IPMI traffic to trusted internal networks." A management VLAN, a VPN, or the provider's own management network — never a public address.
    This advisory is long-standing rather than new, and that is the point: the architecture has not changed, and BMC firmware remains a soft target.

On DDoS, be realistic. Minecraft servers get attacked for sport, volumetric attacks are not something you configure your way out of, and the honest answer is that mitigation is bought rather than built — either from the provider or from a filtering service in front of the game port. What you can do on the box is make sure an attack costs you only bandwidth: connection limits at the proxy, and nothing listening that did not need to be.

What This Doesn't Cover

Application-layer exploits. Nothing here protects against a protocol bug in the server software itself — the Bedrock RCE is the reminder that the wire format is its own attack surface, and the mitigation for that class is patching, not firewalling.

Which JAR to run. Fork choice has security consequences, and the server software guide covers who maintains what.

$ tail -f /forums

Discussion

Comments live on the community forums — sign in there to reply.