← back

I Put a Minecraft Server Behind My Own Domains — Then Had to Smuggle Cloudflare Tunnel Through a JAR

I wanted a Minecraft server.

Not Kubernetes. Not a distributed systems project. Not a networking experiment.

Just Minecraft.

The plan sounded simple:

mc.pragal.fun   → Minecraft server
map.pragal.fun  → live 3D map

I already use Cloudflare for most of my personal infrastructure, so I assumed this would be another DNS record, maybe a reverse proxy, and done.

It wasn't.

By the end, I had:

  • a Paper Minecraft server running on managed hosting
  • Minecraft reachable through mc.pragal.fun
  • BlueMap rendering the world in 3D
  • map.pragal.fun serving BlueMap through Cloudflare
  • Cloudflare Tunnel running inside the Minecraft server environment
  • a custom Paper plugin whose main job is spawning and supervising cloudflared
  • an SRV record hiding the ugly non-standard Minecraft port
  • Simple Voice Chat sharing the Minecraft port over UDP

The most interesting part was getting Cloudflare Tunnel running on a host where I didn't have a normal Linux server to install it on.

That required a slightly cursed .jar hack.


The Starting Point

The server runs Paper on managed Minecraft hosting.

Unlike my own VPS, I don't get unrestricted control over the machine.

I essentially get:

Minecraft files
plugins/
server.properties
Java process
hosting panel

rather than:

root
systemd
nginx
ufw
docker
arbitrary services

The Minecraft server itself was assigned:

server-ip=0.0.0.0
server-port=40567

So players could connect using the host's address and port:

<server-address>:40567

That works.

It also looks terrible.

I wanted:

mc.pragal.fun

Giving Minecraft a Proper Domain

A Minecraft Java connection is not HTTP.

That distinction becomes important very quickly.

When a browser opens:

https://example.com

Cloudflare's normal reverse proxy understands that traffic.

Minecraft instead establishes its own TCP connection to the Minecraft server.

So simply creating an orange-cloud Cloudflare DNS record isn't equivalent to putting a website behind Cloudflare.

For the game endpoint, I needed DNS to ultimately lead Minecraft to the actual Minecraft server.

But there was another problem.

My server wasn't listening on the conventional Minecraft port:

25565

It was:

40567

I didn't want everyone typing:

mc.pragal.fun:40567

Luckily, Minecraft supports DNS SRV discovery.

Conceptually:

Player enters:

mc.pragal.fun
       │
       ▼
DNS SRV lookup

_minecraft._tcp.mc.pragal.fun
       │
       ▼
target: actual-server-host
port:   40567
       │
       ▼
Minecraft connects

An SRV record stores both a target hostname and a port. Cloudflare supports standard SRV DNS records.

So the domain can remain clean while Minecraft discovers the weird hosting-provider port underneath.

The important distinction is:

DNS tells Minecraft WHERE to connect.

DNS does not proxy the Minecraft connection.

That distinction saved me from a lot of confusion later.


Adding a 3D World Map

Then I installed BlueMap.

BlueMap reads Minecraft world data and generates a browser-viewable 3D representation of the world. It can run directly as a Paper/Spigot plugin, and its renderer runs asynchronously rather than doing all rendering on Minecraft's main server thread.

The basic architecture becomes:

                   Paper
                     │
             ┌───────┴────────┐
             │                │
        Minecraft          BlueMap
          world             plugin
             │                │
             └──────┬─────────┘
                    │
                    ▼
              world renderer
                    │
                    ▼
             BlueMap web UI

BlueMap can also run its own small HTTP server.

Its default web root contains the frontend and generated map data, and the plugin can update the map as the Minecraft world changes.

Perfect.

Except there was one problem.

How Do I Expose That HTTP Server?

BlueMap's own installation documentation normally assumes you have another port available for its web server.

On a normal VPS, this is trivial.

I'd run BlueMap on something like:

localhost:8100

and put Nginx in front:

server {
    server_name map.pragal.fun;

    location / {
        proxy_pass http://127.0.0.1:8100;
    }
}

But this wasn't my VPS.

I didn't have an ordinary public web-server port that I could expose however I wanted.

What I did have was outbound Internet access.

And that's exactly what Cloudflare Tunnel needs.


Cloudflare Tunnel Was Perfect... Except I Couldn't Run It

Cloudflare Tunnel reverses the usual networking model.

Instead of:

Internet
   │
   ▼
open firewall port
   │
   ▼
server

the origin establishes an outbound connection:

server
   │
   │ outbound connection
   ▼
Cloudflare
   ▲
   │
Internet

Cloudflare describes Tunnel as an outbound-only connection from cloudflared to its network. The origin therefore doesn't need a publicly reachable HTTP port.

That was exactly what I needed.

I could configure:

map.pragal.fun
        │
        ▼
Cloudflare
        │
   existing tunnel
        │
        ▼
localhost:<BlueMap port>

Cloudflare supports publishing a hostname to a local HTTP service such as:

http://localhost:8000

through a tunnel.

There was just one annoying detail.

I needed to run:

cloudflared tunnel run --token ...

on a Minecraft host that wasn't designed to let me run arbitrary daemons.


The .jar Hack

This was where things became fun.

The hosting platform wouldn't give me a nice:

sudo systemctl enable cloudflared
sudo systemctl start cloudflared

But it would run Paper plugins.

And a Paper plugin is Java.

And Java has:

ProcessBuilder

So instead of asking the host to run Cloudflare Tunnel...

I made Minecraft run Cloudflare Tunnel.

The resulting architecture is gloriously backwards:

Hosting platform
      │
      ▼
    Java
      │
      ▼
    Paper
      │
      ▼
BlueMapTunnel.jar
      │
      │ ProcessBuilder.start()
      ▼
 ./cloudflared
      │
      │ outbound HTTP/2
      ▼
Cloudflare Tunnel
      │
      ▼
map.pragal.fun

The Minecraft server became the process supervisor for my reverse tunnel.


BlueMapTunnel

I called the plugin:

BlueMapTunnel

The JAR declares:

name: BlueMapTunnel
version: 1.1.0
main: fun.pragal.bluemaptunnel.BlueMapTunnelPlugin
api-version: '1.21'
author: PSP
description: Starts a Cloudflare Tunnel connector alongside Paper for BlueMap, forcing HTTP/2 transport.
load: POSTWORLD

There are two particularly important details here.

First:

load: POSTWORLD

I wanted the tunnel tied to the Paper lifecycle rather than behaving like some unrelated external process.

Second, the plugin deliberately forces Cloudflare's HTTP/2 tunnel transport.

More on that shortly.


The Configuration

The JAR ships with:

# Cloudflare remotely-managed tunnel token.
# Treat this like a password: anyone with it can run a connector for your tunnel.
tunnel-token: "PASTE_YOUR_TUNNEL_TOKEN_HERE"

# cloudflared binary path, relative to the Minecraft server root.
cloudflared-path: "./cloudflared"

# Recommended when cloudflared is managed by this plugin.
no-autoupdate: true

enabled: true

The real tunnel token obviously should never be committed to Git or included in a blog post.

It's effectively a credential.

The directory therefore looks approximately like:

server/
├── cloudflared
├── server.jar
├── server.properties
├── world/
└── plugins/
    ├── BlueMap/
    ├── BlueMap.jar
    ├── BlueMapTunnel.jar
    └── BlueMapTunnel/
        └── config.yml

What the Plugin Actually Does

The heart of the plugin is surprisingly small.

This is the source code for the JAR:

package fun.pragal.bluemaptunnel;

import org.bukkit.plugin.java.JavaPlugin;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

public final class BlueMapTunnel extends JavaPlugin {

    private Process cloudflaredProcess;
    private Thread outputThread;

    @Override
    public void onEnable() {
        saveDefaultConfig();

        if (!getConfig().getBoolean("enabled", true)) {
            getLogger().info("BlueMapTunnel is disabled in config.yml.");
            return;
        }

        String token = getConfig().getString("tunnel-token", "");
        String cloudflaredPath =
                getConfig().getString("cloudflared-path", "./cloudflared");

        boolean noAutoupdate =
                getConfig().getBoolean("no-autoupdate", true);

        if (token == null || token.isBlank()
                || token.equals("YOUR_TUNNEL_TOKEN")) {

            getLogger().severe(
                    "No Cloudflare Tunnel token configured!"
            );

            getLogger().severe(
                    "Set tunnel-token in plugins/BlueMapTunnel/config.yml"
            );

            return;
        }

        try {
            startCloudflared(
                    cloudflaredPath,
                    token,
                    noAutoupdate
            );
        } catch (Exception e) {
            getLogger().severe(
                    "Failed to start cloudflared: "
                            + e.getMessage()
            );

            e.printStackTrace();
        }
    }

    private void startCloudflared(
            String executable,
            String token,
            boolean noAutoupdate
    ) throws Exception {

        List<String> command = new ArrayList<>();

        command.add(executable);
        command.add("tunnel");

        if (noAutoupdate) {
            command.add("--no-autoupdate");
        }

        // Giganode had unreliable QUIC connectivity,
        // so force Cloudflare Tunnel to HTTP/2.
        command.add("--protocol");
        command.add("http2");

        command.add("run");
        command.add("--token");
        command.add(token);

        ProcessBuilder builder =
                new ProcessBuilder(command);

        /*
         * Pterodactyl normally starts Paper with the
         * server directory as the working directory.
         *
         * Explicitly setting it makes ./cloudflared
         * resolve relative to the Minecraft server root.
         */
        builder.directory(
                new File(System.getProperty("user.dir"))
        );

        /*
         * Merge stderr into stdout so both normal
         * cloudflared logs and errors appear in the
         * Minecraft console.
         */
        builder.redirectErrorStream(true);

        cloudflaredProcess = builder.start();

        getLogger().info(
                "Started cloudflared using HTTP/2 (PID "
                        + cloudflaredProcess.pid()
                        + ")"
        );

        outputThread = new Thread(() -> {
            try (
                BufferedReader reader =
                    new BufferedReader(
                        new InputStreamReader(
                            cloudflaredProcess.getInputStream()
                        )
                    )
            ) {
                String line;

                while ((line = reader.readLine()) != null) {
                    // Avoid leaking the tunnel credential if it ever
                    // appears in cloudflared output.
                    String safeLine = line.replace(
                            token,
                            "[REDACTED]"
                    );

                    getLogger().info(
                            "[cloudflared] " + safeLine
                    );
                }

            } catch (Exception e) {

                if (cloudflaredProcess != null
                        && cloudflaredProcess.isAlive()) {

                    getLogger().warning(
                            "Error reading cloudflared output: "
                                    + e.getMessage()
                    );
                }
            }
        });

        outputThread.setName(
                "BlueMapTunnel-cloudflared-output"
        );

        outputThread.setDaemon(true);
        outputThread.start();
    }

    @Override
    public void onDisable() {

        if (cloudflaredProcess == null) {
            return;
        }

        if (!cloudflaredProcess.isAlive()) {
            return;
        }

        getLogger().info(
                "Stopping cloudflared..."
        );

        cloudflaredProcess.destroy();

        try {
            boolean stopped =
                    cloudflaredProcess.waitFor(
                            5,
                            java.util.concurrent.TimeUnit.SECONDS
                    );

            if (!stopped) {
                getLogger().warning(
                        "cloudflared did not stop gracefully. "
                                + "Forcing termination."
                );

                cloudflaredProcess.destroyForcibly();
            }

        } catch (InterruptedException e) {

            Thread.currentThread().interrupt();

            cloudflaredProcess.destroyForcibly();
        }
    }
}

ProcessBuilder Is the Entire Trick

The important line is:

cloudflared = builder.start();

ProcessBuilder asks the operating system to create a child process.

Paper therefore ends up with a process tree conceptually like:

container
   │
   └── java
        │
        └── Paper
             │
             └── cloudflared

The hosting panel thinks it started Minecraft.

Minecraft thinks it loaded a plugin.

The plugin starts another executable.

And that executable establishes my Cloudflare Tunnel.

This only works because the hosting sandbox allows the Java process to execute the uploaded cloudflared binary. A provider that blocks execve/child-process creation would stop this immediately.

So this isn't a Cloudflare vulnerability or sandbox escape.

It's just creatively using capabilities the environment already exposes.


Why redirectErrorStream(true) Matters

cloudflared writes operational information to its process streams.

Rather than separately handling stdout and stderr, the plugin does:

builder.redirectErrorStream(true);

Now both streams arrive through:

process.getInputStream()

The plugin continuously reads them:

while ((line = reader.readLine()) != null) {
    ...
}

and pipes them into Paper's logger.

So my Minecraft console effectively becomes the service log for Cloudflare Tunnel:

[BlueMapTunnel] [cloudflared] ...

That made debugging much easier because I didn't have SSH access to tail a separate system log.


Don't Accidentally Print Your Tunnel Token

One small detail I particularly wanted in the plugin was token redaction.

The tunnel starts with:

--token <SECRET>

If cloudflared ever emitted the token into output, blindly forwarding its logs into the Minecraft console could expose it.

So every line goes through:

String safeLine = line.replace(
    token,
    "[REDACTED]"
);

before logging.

It's not sophisticated secret-management infrastructure, but it's an important defensive measure.


Paper Also Owns the Tunnel Lifecycle

Starting the tunnel was only half the problem.

Suppose Paper restarts.

Without lifecycle management I could end up with:

old cloudflared
new cloudflared
another cloudflared
another cloudflared
...

So the plugin keeps the returned Process:

private Process cloudflared;

and when Paper disables the plugin:

cloudflared.destroy();

Then it gives the process five seconds:

cloudflared.waitFor(
    5,
    TimeUnit.SECONDS
);

If the child refuses to die:

cloudflared.destroyForcibly();

Effectively:

Paper starts
   ↓
cloudflared starts


Paper stops
   ↓
SIGTERM-ish graceful termination
   ↓
wait 5 seconds
   ↓
still alive?
   ↓
force kill

It also registers:

cloudflared.onExit()

so an unexpected tunnel failure appears in the Minecraft logs.

At this point the Paper plugin isn't merely launching a command.

It's acting as a tiny process supervisor.


Why I Forced HTTP/2

The command produced by the plugin is essentially:

./cloudflared tunnel \
  --no-autoupdate \
  --protocol http2 \
  run \
  --token <TOKEN>

The interesting part is:

--protocol http2

Cloudflare Tunnel supports:

auto
quic
http2

for the connection between cloudflared and Cloudflare. Cloudflare currently documents auto as preferring QUIC and falling back to HTTP/2 when UDP connectivity isn't available.

A managed game-server environment is exactly the sort of place where outbound UDP behavior can become awkward.

Rather than relying on automatic negotiation, I forced:

HTTP/2

That means the connector path is:

BlueMap
   │
   │ local HTTP
   ▼
cloudflared
   │
   │ Cloudflare Tunnel over HTTP/2
   ▼
Cloudflare edge
   │
   │ HTTPS
   ▼
browser

There is an important nuance here: this http2 flag controls cloudflared → Cloudflare transport, not necessarily BlueMap → cloudflared. Cloudflare documents origin-side HTTP/2 as a separate setting.

Those are two completely different network legs.


Why Disable cloudflared Auto-Update?

The plugin also supplies:

--no-autoupdate

when configured.

Normally self-updating software can be useful.

Inside this environment, I wanted the opposite.

cloudflared is effectively an application binary bundled alongside my Minecraft deployment.

I don't want it unexpectedly replacing itself while Paper is responsible for supervising it.

The deployment unit becomes:

Paper version
plugins
cloudflared binary
BlueMapTunnel
configuration

and I can upgrade that intentionally.


The Final BlueMap Request Path

Once the tunnel connected, the public request flow looked roughly like this:

Browser
   │
   │ https://map.pragal.fun
   ▼
Cloudflare Edge
   │
   │ existing Cloudflare Tunnel
   ▼
cloudflared
   │
   │ http://localhost:<BlueMap port>
   ▼
BlueMap Web Server
   │
   ▼
generated map assets

No inbound BlueMap port needed to be exposed publicly.

That's the entire reason this hack was useful.

Cloudflare Tunnel is explicitly designed around an outbound connector model, so the origin doesn't need a public IP or inbound firewall opening for the HTTP service.


One Domain, Two Very Different Networking Problems

This project also reinforced something that's easy to miss:

The Minecraft server and the BlueMap website have almost nothing in common at the network layer.

Minecraft:

mc.pragal.fun
       │
       ▼
DNS / SRV
       │
       ▼
TCP 40567
       │
       ▼
Paper

BlueMap:

map.pragal.fun
       │
       ▼
HTTPS / Cloudflare
       │
       ▼
Cloudflare Tunnel
       │
       ▼
local HTTP
       │
       ▼
BlueMap

Trying to solve both with the same kind of Cloudflare proxying was the wrong mental model.

Cloudflare's public Tunnel routing documentation also points out that non-HTTP services such as raw TCP generally require cloudflared on the client side when exposed through that mechanism.

Obviously I'm not asking every friend joining Minecraft to install cloudflared.

So the game traffic stays game traffic.

The web traffic goes through Tunnel.


Then Voice Chat Broke Because UDP Exists

Naturally, after solving HTTP and TCP, the next problem was UDP.

I installed Simple Voice Chat.

Minecraft itself was:

TCP 40567

Simple Voice Chat normally wants a separate UDP port, with the default being:

UDP 24454

The official configuration explicitly states that voice audio is sent over UDP independently of the Minecraft server's normal networking.

That's easy on a VPS.

Not so easy when the hosting panel doesn't give me another convenient allocation.

Then I noticed Simple Voice Chat supports:

port=-1

That tells it to use the same numeric port as Minecraft.

At first that sounds impossible:

Minecraft     :40567
Voice Chat    :40567

But the missing information is the transport protocol:

Minecraft     TCP :40567
Voice Chat    UDP :40567

TCP port 40567 and UDP port 40567 are separate sockets.

So they can coexist.

My server.properties already had:

server-port=40567
enable-query=false

The second line mattered.

Minecraft's server query feature also uses UDP, and Simple Voice Chat specifically warns about conflicts when sharing the Minecraft port.

With query disabled, I configured:

# plugins/voicechat/voicechat-server.properties

port=-1
bind_address=
voice_host=

Now the network stack effectively became:

                  :40567
                 /      \
                /        \
             TCP          UDP
              │            │
              ▼            ▼
           Minecraft   Voice Chat

Same number.

Different protocols.

That worked around another limitation without needing another public-facing service port.


The Final Architecture

After all of this, my "simple Minecraft server" looked something like:

                             INTERNET
                                │
             ┌──────────────────┴──────────────────┐
             │                                     │
             │                                     │
      Minecraft client                         Web browser
             │                                     │
             │ mc.pragal.fun                       │ map.pragal.fun
             │                                     │
             ▼                                     ▼
        Cloudflare DNS                       Cloudflare Edge
             │                                     │
             │ SRV                                 │ HTTPS
             ▼                                     │
     server-address:40567                          │
             │                                     │
       ┌─────┴──────┐                              │
       │            │                              │
   TCP 40567    UDP 40567                          │
       │            │                              │
       ▼            ▼                              │
    Paper      Simple Voice Chat                   │
       │                                           │
       ├── BlueMap                                 │
       │      │                                    │
       │      ▼                                    │
       │   local HTTP ◄──── cloudflared ◄──────────┘
       │                       ▲
       │                       │
       ├── BlueMapTunnel.jar ──┘
       │      ProcessBuilder

And the process hierarchy is:

GigaNodes/container
        │
        ▼
      Java
        │
        ▼
      Paper
        │
        ├── BlueMap
        ├── Chunky
        ├── Simple Voice Chat
        ├── LuckPerms
        │
        └── BlueMapTunnel
                 │
                 ▼
             ProcessBuilder
                 │
                 ▼
             cloudflared
                 │
                 ▼
           Cloudflare Edge

Why Not Just Use a VPS?

A reasonable person might look at all of this and ask:

Why didn't you just put Minecraft on a VPS?

Fair.

With a VPS I could have:

systemd
nginx
cloudflared.service
iptables
whatever ports I want

and avoided several of these problems.

But that's also why this became interesting.

Constraints forced me to understand what each component actually required.

BlueMap didn't require an inbound public port.

It required a path from the Internet to an HTTP service.

Cloudflare Tunnel provided that path outbound.

Cloudflare Tunnel didn't require systemd.

It required a running cloudflared process.

Paper could create that process.

Minecraft didn't require users to type 40567.

It required DNS to tell the client what port to use.

SRV could do that.

Voice chat didn't necessarily require a second number.

It required a UDP socket.

TCP and UDP can share the same numeric port.

Once I stopped thinking in terms of products and started thinking in terms of primitives, most of the restrictions became solvable.


The Actual Lesson

This project started as:

host Minecraft

and slowly turned into an exercise in:

DNS
SRV records
TCP vs UDP
HTTP reverse proxies
outbound tunnels
process supervision
Java child processes
plugin lifecycles
credential handling
resource contention

The .jar hack is probably my favourite part.

Not because it's particularly complicated.

It's about 100 lines of Java.

But because it changes the question.

Instead of:

How do I install Cloudflare Tunnel on a Minecraft host?

the question became:

What capability do I actually need?

The answer was:

I need this binary to remain running
for as long as Paper remains running.

Paper already had a lifecycle.

Java already had process creation.

So I connected the two.

onEnable()
    ↓
start cloudflared

onDisable()
    ↓
stop cloudflared

That's it.

Sometimes the fun part of engineering isn't building a sophisticated system.

It's noticing that the box you're supposedly trapped inside has a door nobody told you to use.


Current Stack

For anyone interested, the final setup is approximately:

Minecraft Server
└── Paper 1.21.x
    ├── BlueMap
    ├── BlueMapTunnel
    ├── Chunky
    ├── Simple Voice Chat
    └── LuckPerms

Networking
├── Cloudflare DNS
├── Minecraft SRV
└── Cloudflare Tunnel
    └── forced HTTP/2 transport

Domains
├── mc.pragal.fun
└── map.pragal.fun

Game networking
├── TCP 40567 → Minecraft
└── UDP 40567 → Simple Voice Chat

Web networking
└── HTTPS
    → Cloudflare
    → Tunnel
    → cloudflared
    → BlueMap HTTP server

What started as a Minecraft server ended up being one of those tiny projects where every additional feature exposes another layer of the network stack.

Which, frankly, is much more fun than just opening port 25565.


PS: If you want to punch some trees or build something questionable with me, feel free to join:

👉 mc.pragal.fun