Running a Minecraft server for "free"
Stop wasting money on managed hosting services if you already have SSH access to some from of computing
For many, the first thing people think of when they access a new computational device is to see whether or not they can run DOOM on it. Being born in the 2000s, the equivalent I think of is whether or not I can run a Minecraft server on it.
In case you aren’t already familiar, running a Java Edition server is relatively simple. All you really need to do is install the Java Runtime and then run the server.jar provided by Mojang. Thanks in large to Notch’s early decisions to write Minecraft in Java, the JVM abstracts much of the cross-platform shenangians meaning that the server executable will run wherever the Java itself is capable of running.
The networking required is also fairly simple. At the bare minimum all Minecraft needs is a singular TCP port to allow for multiplayer functionality.
Running within a constricted environment
Obviously under most conditions you’ll likely have full root access to the machine as well as full admin access to the router. But what I want to show is that in fact neither of the 2 above are hard-set conditions for running a server. Rather all you need is access to internet and the ability to run binary executables.
As an example, I will be using a computational environment that I myself have a user on but I do not control the router nor do I have root access (and let’s just assume that neither can I install user applications via the built-in package manager). This particular machine is running Ubuntu, while the ideas here are also theoretically applicable to a Windows machine; I myself (and most other people) will find that working with Windows Server Edition is more trouble than its worth.
So let’s set out a few of the restrictions and requirements for this particular setup.
- Once again, no root access, no access to router, no access to the package manager
- Other players other than yourself must be able to also join and play simultaneously
- This computational environment only allows a process to run for 2 hours before
renice 19
(the lowest CPU scheduling priority pretty much only runs when CPU is idle) - We want to be nice. Meaning if there are no players, then there is no point in leaving the server running. However, it must still be available on demand
Let’s go through and tackle each of these points above…
1. Getting the thing to run
This is relatively straight forward actually. Instead of relying on the package manager itself you can just grab a pre-built binary of the JDK
which comes with the java
binary itself. Grab the appropriate version according to which Minecraft update you’d like to run.
As for the server JAR itself, most people don’t use the one provided by Mojang but rather elect for one that has been patched with various performance gains. For me, I personally use Purpur which is directly built on top of Paper
Once you have both of these, all you need to do is run the JAR.
PATH_TO_JAVA/java -Xmx1024M -Xms1024M -jar minecraft_server.VERSION.jar nogui
Now you have the server running, but there’s no way to access it since its only listening from localhost
. Luckily as a quick testing method, SSH provides some
level of tunneling that allows you to port forward through SSH
ssh -L 25565:localhost:25565 [email protected]
This will essentially route port 25565 to your local 25565. So once you SSH in using this command and run the server, you’ll be able to play by connecting to localhost:25565
2. Allowing other players to join
This is great, but a Minecraft server for a single person is no better than just running a single-player world. Again, we don’t have access to the router so we can’t
exactly expose this port to the outside world even if we set the server to listen on 0.0.0.0
You could in theory port forward your local port or even share a VPN connection with everyone joining. But this creates a massive bottleneck which is the stability of the connection between you and the server.
This isn’t great if you don’t have stable internet or have bandwidth limitations. Also its not particularly scalable since your home PC needs to be running at all times with this SSH connection on to allow others to play.
The solution is to route the game through a tunnel:
A tunneling service can be running 24/7 making the connection available around the clock. There is still the bottleneck between the server and the tunnel, but as long as you pick a good tunneling service this shouldn’t be much of an issue in terms of latency or bandwidth.
For this I highly reccomend setting up playit.gg which provides a very generous free tier for this sort of game hosting. Setup is also fairly simple since its basically just 1 additional binary to run with your server.
Players connect to this tunnel which then forwards the traffic onto the localhost:25565
on the server machine itself, meaning no need to access the router to port forward
3. Runtime Limitations
One of the unique requirements for my use case is that a process cannot be running for more than 2 hours or else it will degrade in terms of CPU priority.
Your first intuition may be to simply stop and restart the server and tunnel every 2 hours. Maybe even write a bash script to do just that. But the situation is actually not that simple.
We first must understand how UNIX/Linux spawns processes:
A program is a passive entity. It is merely the code that is stored on disk. We can think of this like a recipe written in a cookbook
A process is an active instance of a program that is currently running.
- It has its own memory, CPU state, file descriptors, etc.
- Multiple processes can be created from the same program In the cooking analogy we can think of this as a kitchen where the recipe is being cooked
Processes don’t just spawn from thin air, UNIX/Linux uses 2 syscalls fork()
and exec()
to create new processes.
-
When
fork()
is called on a particular process, it creates an exact duplicate of itself. This new duplicate is called the child while the original is called the parent.- The child inherits various things from the parent such as their process priority (nice value), environment variables, and open file descriptors
-
When
exec()
is called. The program running within that process gets replaced by a different program. It still retains all of the additional metadata attached to that particular process, but now its running some different code.
So then when you launch some program from bash
on Linux, we can think of it at a high level as doing the following:
bash (Parent process)
└── fork()
└── child process (duplicate of bash)
└── exec("nvim")
└── child process becomes nvim
Essentially fork() -> exec()
. You’ll quickly notice that this creates a parent-child relationship for all processes in UNIX/Linux (with the exception of the init process).
This is just the high level idea, an OS course or something similar can teach you all the nitty gritty details.
So what’s the problem?
Well let’s take this situation for example.
At time 0 (on initial SSH):
sshd (SSH Daemon Session for USER) [nice: 0]
└── bash (User Shell) [nice: 0]
When I SSH in to the server, the sshd
process is forked then exec()
is called spawning the shell itself. Then, running the server JAR does something similar where
the bash
process is forked and then exec()
replaces the forked-bash with java
.
Nice-ness is a value we use to denote CPU priority (lower = higher priority). Let’s assume that all processes start with a nice value of 0, indicating the highest CPU priority, and that when a process
runs for 120 time units it gets renice 19
-ed (meaning the nice value gets set to 19).
Now let’s assume that for whatever reason at time 30 I decide to run the server
At time 30 (Running server application)
sshd (SSH Daemon Session for USER) [nice: 0]
└── bash (User Shell) [nice: 0]
└── java -jar app.jar (Java Application) [nice: 0]
OK fine, that’s all good. But let’s progress time a bit more…
At time 120 (Running server application)
sshd (SSH Daemon Session for USER) [nice: 19]
└── bash (User Shell) [nice: 19]
└── java -jar app.jar (Java Application) [nice: 0]
Uh oh, that bash process and ssh process has been running for more the 120 time units. Luckily renice
is not recursive by default so our server is still unaffected by the
lowered CPU priority.
At time 150 (Running server application)
sshd (SSH Daemon Session for USER) [nice: 19]
└── bash (User Shell) [nice: 19]
└── java -jar app.jar (Java Application) [nice: 19]
Alright so we’ll need to restart the server right? Let’s terminate our bash and java processes then re-fork them.
At time 151 (Running server application)
sshd (SSH Daemon Session for USER) [nice: 19]
└── bash (NEW User Shell) [nice: 19]
└── java -jar app.jar (NEW Java Application) [nice: 19]
OH NO! Because the child-processes inherited the nice value it means that even though we technically stopped and re-started both the server and bash, due to the ssh session spanning longer than 120 time units, all processes forked from it also take on the penalized nice value!
So as you can see, simply stopping and restarting like that isn’t a viable solution
The Solution = Re-SSH
The solution to this problem is simple, we just need to close our SSH session every time we gain the penalty. For me that’s 2 hours, so every 2 hours I need to stop and re-start the server. Doing this manually would be quite laborious so this is a good job for some random old laptop or even a Raspberry Pi to trigger.
while true; do
echo "[$(date)] Starting SSH session..."
ssh "$USER@$HOST" << 'EOF'
echo "SSH session started. Will auto-exit in 2 hours."
<run server stuff here> &
sleep 7200 # 2 hours
echo "Time's up. Goodbye."
exit
EOF
echo "[$(date)] Session ended. Reconnecting in 5 seconds..."
sleep 5
done
This will ensure that a new sshd
process is being forked from the sshd master daemon
(which almost certainly has no issues with being nice-d since you want people to be able to ssh in),
avoiding the time constrain issue.
If you have access to 2 machines, you could theoretically bounce them back and forth to maintain infinite uptime, but as you’ll soon see even the script above doesn’t satisfy the 4th condition.
Being nice, let’s not waste CPU
There’s no point in keeping a server up if there’s no players. So what if we were able to have some way to on-demand start and stop when someone wants to play.
The solution I’ve come up with is to run a small flask app which manages checking for players, as well as spawning the new sshd process as discussed in part 3.
To start, the server. Users can navigate to the website and trigger the flask app to run a bash script that handles SSH-ing into the relevant machine.
# Example start.sh
#!/bin/bash
ssh -o StrictHostKeyChecking=no [email protected] << 'EOF'
cd /home/yukais6/java-neural-net
tmux new-session -d -s server
tmux send-keys -t server'./tunnel &' C-m
tmux send-keys -t server 'jdk-21.0.5/bin/java -Xmx1024M -Xms1024M -jar server.jar nogui' C-m
exit
EOF
# Example stop.sh
ssh -o StrictHostKeyChecking=no [email protected] << 'EOF'
tmux kill-session -t supervised-cluster
exit
EOF
We can run a second thread within the flask-app to check when the server should restart. (aka calling stop.sh then start.sh)
Using Minecraft’s built in RCON API (you need to port forward the RCON port found in server.properties
file), we can run commands from within Minecraft and parse
various information.
def get_player_count(host, password, port):
with MCRcon(host, password, port=port) as mcr:
response = mcr.command("list")
print(response)
match = re.search(r"There are (\d+) of a max of \d+ players online", response)
if match:
return int(match.group(1))
else:
print("Error: Could not parse player count from response.")
return None
Similarly, we can also use this API to make announcements which is helpful for announcing when the server is about to restart:
def announce_to_server(message: str, host: str, password:str,port: str):
with MCRcon(host, password, port) as mcr:
resp = mcr.command("say " + message)
return resp
With this we have solved the problem of “being nice”. We only run the server on demand, while also managing the nice-ness value of the server accordingly.
So if you have access to some form of computing and are paying for Realms or managed hosting. Why not consider self-hosting.