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 mapI 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.funserving 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 panelrather than:
root
systemd
nginx
ufw
docker
arbitrary servicesThe Minecraft server itself was assigned:
server-ip=0.0.0.0
server-port=40567So players could connect using the host's address and port:
<server-address>:40567That works.
It also looks terrible.
I wanted:
mc.pragal.funGiving Minecraft a Proper Domain
A Minecraft Java connection is not HTTP.
That distinction becomes important very quickly.
When a browser opens:
https://example.comCloudflare'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:
25565It was:
40567I didn't want everyone typing:
mc.pragal.fun:40567Luckily, 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 connectsAn 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 UIBlueMap 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:8100and 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
│
▼
serverthe origin establishes an outbound connection:
server
│
│ outbound connection
▼
Cloudflare
▲
│
InternetCloudflare 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:8000through 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 cloudflaredBut it would run Paper plugins.
And a Paper plugin is Java.
And Java has:
ProcessBuilderSo 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.funThe Minecraft server became the process supervisor for my reverse tunnel.
BlueMapTunnel
I called the plugin:
BlueMapTunnelThe 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: POSTWORLDThere are two particularly important details here.
First:
load: POSTWORLDI 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: trueThe 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.ymlWhat 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
│
└── cloudflaredThe 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 killIt 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 http2Cloudflare Tunnel supports:
auto
quic
http2for 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/2That means the connector path is:
BlueMap
│
│ local HTTP
▼
cloudflared
│
│ Cloudflare Tunnel over HTTP/2
▼
Cloudflare edge
│
│ HTTPS
▼
browserThere 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-autoupdatewhen 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
configurationand 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 assetsNo 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
│
▼
PaperBlueMap:
map.pragal.fun
│
▼
HTTPS / Cloudflare
│
▼
Cloudflare Tunnel
│
▼
local HTTP
│
▼
BlueMapTrying 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 40567Simple Voice Chat normally wants a separate UDP port, with the default being:
UDP 24454The 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=-1That tells it to use the same numeric port as Minecraft.
At first that sounds impossible:
Minecraft :40567
Voice Chat :40567But the missing information is the transport protocol:
Minecraft TCP :40567
Voice Chat UDP :40567TCP port 40567 and UDP port 40567 are separate sockets.
So they can coexist.
My server.properties already had:
server-port=40567
enable-query=falseThe 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 ChatSame 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 ──┘
│ ProcessBuilderAnd the process hierarchy is:
GigaNodes/container
│
▼
Java
│
▼
Paper
│
├── BlueMap
├── Chunky
├── Simple Voice Chat
├── LuckPerms
│
└── BlueMapTunnel
│
▼
ProcessBuilder
│
▼
cloudflared
│
▼
Cloudflare EdgeWhy 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 wantand 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 Minecraftand 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 contentionThe .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 cloudflaredThat'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 serverWhat 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: