pinapelz's blog

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.

  1. Once again, no root access, no access to router, no access to the package manager
  2. Other players other than yourself must be able to also join and play simultaneously
  3. 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)
  4. 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.

image.png

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:

image.png

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.

Processes don’t just spawn from thin air, UNIX/Linux uses 2 syscalls fork() and exec() to create new processes.

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.

image.png

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.