How the lab began · Part 2 of 2

Hosting Minecraft behind CGNAT for five friends

I opened my Minecraft server to five friends with the default port and no whitelist, then watched port scans arrive. Here is what moving the port changed, what the whitelist actually controlled, and why an SRV record kept joining simple.

Five friends, five different places, one server in a container on my laptop. The question I cared about was simple to say and fiddly to get right: how do I make joining easy for them without making it easy for everyone else?

The server is vanilla Minecraft Java Edition 26.3, running in Proxmox as LXC 207. I manage it with Crafty Controller, which puts the console, the config files and start and stop in one panel. My home connection sits behind carrier-grade NAT, so nothing on the internet can open a connection to the laptop. Instead, a WireGuard tunnel from the container reaches out to a public VPS, and friends connect to the VPS. The VPS carries their connection back down the tunnel to LXC 207.

I wanted joining to stay simple because five people in five places means five different home networks, and every extra step (a port number, a VPN client, a message to me first) is a step someone gets wrong on an evening I am not around to help.

In this entry the VPS address, join name, SRV record, port and player names are examples, substituted for privacy. The real ones stay private, and so does the route to the Crafty panel.

Minecraft for five friendsLXC 207 · Java Edition 26.3
Cloudflare DNSSRV + A, DNS onlyInternet scannerscommon portsFriend's clientjoins by nameVPS edge203.0.113.24WireGuardtunnel to the labLXC 207Crafty + serverWhitelist + sign-in:25565 answered:25565 closedCloudflare DNSSRV + A recordsScannerscommon portsFriend's clientjoins by nameVPS edge203.0.113.24WireGuardtunnel to the labLXC 207Crafty + serverWhitelist + sign-in:25565 answered:25565 closed
Port
28565 (example)
Whitelist
on: five named friends
Joining
one name, found by an SRV record
Scan noise
quieter; the port is not a control

Examples for privacy: the VPS IP, player names, hostname, SRV record and port are substituted

Cloudflare only answers the DNS lookup here; game traffic goes to the VPS. The journal’s own Cloudflare Tunnel is a separate path.

  • dig +short SRV _minecraft._tcp.play.example.net
    dig +short mc-edge.example.net
    0 5 28565 mc-edge.example.net.
    203.0.113.24

    The SRV answer carries the port and a target name; the target name resolves to the edge address.

    Example output

  • whitelist on
    whitelist list
    Whitelist is now turned on
    There are 5 whitelisted players: friend-a, friend-b, friend-c, friend-d, friend-e

    The whitelist is the control. An account not on this list is refused even if it finds the port.

    Example output

  • grep -E '^(server-port|white-list|enforce-whitelist)=' server.properties
    server-port=28565
    white-list=true
    enforce-whitelist=true

    enforce-whitelist also removes anyone already online who is not on the list when it is reloaded. The list matches accounts, so account sign-in checks are part of the same control.

    Example output

  • nc -vz -w 3 mc-edge.example.net 25565
    nc -vz -w 3 mc-edge.example.net 28565
    nc: connect to mc-edge.example.net port 25565 (tcp) timed out
    Connection to mc-edge.example.net 28565 port [tcp/*] succeeded!

    Run from a laptop off the home network, so the test takes the same path a friend does.

    Example output

Where I started

To make the first evening painless, I kept the game’s common port, 25565, and left the whitelist off. Anyone with the address could join, and that was the point: no one had to send me their account name first.

It worked. It also meant the server answered anyone who knocked on that port.

What I noticed

Repeated port scans. Addresses none of us used kept connecting to the default port, asking the server for its status and leaving. That is normal background noise on the internet: scanners sweep well-known ports looking for anything that answers. A Minecraft server on 25565 is one of the things they look for.

The figure below shows what that looks like in the server log, using made-up times and addresses.

Worked example · invented times, addresses and counts

08:02:11 [Server thread/INFO]: /198.51.100.7:51022 lost connection: Disconnected
08:02:40 [Server thread/INFO]: /198.51.100.7:51188 lost connection: Disconnected
08:15:03 [Server thread/INFO]: /192.0.2.66:40112 lost connection: Disconnected
09:41:27 [Server thread/INFO]: friend-c joined the game
In this example day, 38 connections come from addresses that never join and 9 from friends who do. The lines without a player name are status checks. Nobody got in, but with the whitelist off the only thing stopping a stranger was not knowing the server was there.

That last point is what bothered me. With the whitelist off, a scanner that found the port could tell any player where to find an open server.

What I changed

Three changes, each doing a different job.

I moved the service port. The game now listens on a non-default port (28565 in these examples). Most routine scanners only try the well-known port, so a different number cuts down the noise in the log. It is not a lock. A scanner that sweeps every port will still find it, so I count the quieter log as a convenience, not a control.

I turned on the whitelist. This is the control that matters. With white-list=true and enforce-whitelist=true, the server refuses any account that is not on the list, even one that finds the port. A whitelist matches account names, so it is only as strong as the server’s check that each player really owns the account they name. That check is online-mode=true, and my server runs with it on: at every login the server asks Mojang’s session service to confirm the account, so nobody gets in by typing one of my friends’ names.

I tightened the server and connection rules. Players need exactly one thing from the outside: the game port. The Crafty panel is a management route, so it stays off the public path completely.

bashLXC 207 · read-onlyExample output · substituted names and addresses

Purpose: confirm the settings that control access

Crafty edits this file for me. Reading it back confirms what the panel actually saved.

grep -E '^(server-port|white-list|enforce-whitelist)=' server.properties
Show output
server-port=28565
white-list=true
enforce-whitelist=true
  1. white-list=true only accounts on the list may join
  2. enforce-whitelist=true also removes anyone already connected who is not on the list when the whitelist reloads

The port line is the only one a scanner cares about. The other two decide who actually plays.

Keeping joining simple: the SRV record

A new port means a new thing to remember, and five friends typing name:28565 by hand is five chances for a typo. An SRV record fixes that. Minecraft Java Edition looks up an SRV record before it connects, so friends type one name and the client finds the port by itself.

The DNS flow has three parts:

  1. A friend types play.example.net. The client asks DNS for _minecraft._tcp.play.example.net.
  2. Cloudflare DNS answers with the SRV record: a target hostname, mc-edge.example.net, and the new port.
  3. The client looks up the target hostname, gets the address of the VPS edge, and connects to that address on that port. WireGuard carries the connection from the VPS to LXC 207.
bashAny laptopExample output · substituted names and addresses

Purpose: see what the client sees

If a friend cannot join, this comes before touching the server: does DNS give out the right target and port?

dig +short SRV _minecraft._tcp.play.example.net
dig +short mc-edge.example.net
Show output
0 5 28565 mc-edge.example.net.
203.0.113.24
  1. 0 5 28565 priority, weight, then the port the client will use
  2. mc-edge.example.net. the target name, which needs its own address record

Both records are DNS only. Cloudflare’s standard proxy carries web traffic, not Minecraft’s protocol, so these names resolve to the edge address directly.

Not the same path as this website

Both use Cloudflare, so it is easy to blur them together. They work in completely different ways:

Minecraft This portfolio
What Cloudflare does Answers DNS: an SRV record and an address record Proxies HTTPS through its WAF
Where the connection goes Straight to the VPS edge address To Cloudflare, then down a named tunnel
What carries it into the lab WireGuard from the VPS to LXC 207 cloudflared, outbound only, to the journal origin
Who is allowed in Whitelisted accounts that pass sign-in Anyone can read; nothing can be changed

Keeping them on separate lines in my head and on the lab map stops me crediting the WAF for protecting a Minecraft server for my friends it never sees.

The checks that prove each change

A control that has not been tested is only a setting. Each change has a check that runs from a laptop off the home network, so the test takes the same path a friend’s connection does:

  • The old port is shut. A connection to 25565 on the edge times out; the new port connects.
  • The whitelist refuses strangers. An account that is not on the list gets “You are not white-listed on this server!”; a friend’s account joins.
  • The name still works. dig returns the SRV target and port, and a friend joins with just the name.
bashLaptop off the home networkExample output · substituted names and addresses

Purpose: test both ports from outside

This proves the old port no longer answers, instead of trusting my memory of changing it.

nc -vz -w 3 mc-edge.example.net 25565
nc -vz -w 3 mc-edge.example.net 28565
Show output
nc: connect to mc-edge.example.net port 25565 (tcp) timed out
Connection to mc-edge.example.net 28565 port [tcp/*] succeeded!

A port that answers is not a problem on its own; the whitelist test is what shows who gets past it.

Three faults, worked through

These are worked examples set in October and November 2026, with invented times, names and addresses. Each one is a fault this setup can produce, the trail of evidence that separates it from its look-alike, and how it ends. Times are UTC.

A friend still gets the old port: a stale DNS cache

14 Oct 2026, 19:05. Right after the port change, four friends join and friend-b gets Can’t connect to server. Same name, same server, so the difference has to be in what each of them was told.

bashLaptop off the home networkExample output · substituted names and addresses

Purpose: ask two resolvers the same question

If two resolvers disagree, the record is fine and one of them is remembering an older answer.

dig SRV _minecraft._tcp.play.example.net @1.1.1.1 +noall +answer
dig SRV _minecraft._tcp.play.example.net @192.0.2.53 +noall +answer
Show output
_minecraft._tcp.play.example.net. 300  IN SRV 0 5 28565 mc-edge.example.net.
_minecraft._tcp.play.example.net. 2961 IN SRV 0 5 25565 mc-edge.example.net.
  1. 300 the public resolver has the new record and its 5-minute TTL
  2. 2961 friend-b's router cached the old record and will keep it for another 49 minutes

The second resolver stands in for friend-b’s home router.

Evidence trail: the authoritative record is right, the public resolver agrees, and only friend-b’s resolver serves the old port, with most of its old one-hour TTL still to run. Outcome: friend-b joins once the cached answer expires. Since then I lower the TTL to 300 a day before changing a record, so the old answer can only outlive the change by five minutes.

The VPS answers, the game does not: a tunnel that went quiet

22 Oct 2026, 20:40. Nobody can join. The VPS replies to ping and SSH, so the internet path is fine; the game port times out.

bashVPS · read-onlyExample output · substituted names and addresses

Purpose: check when the tunnel last heard from the lab

A reachable VPS only proves the first half of the path. The handshake time shows whether the second half exists.

wg show wg0 latest-handshakes
date -u +%s
Show output
kQ7…example…=	1792690822
1792701622
  1. 1792690822 the last handshake with the lab peer
  2. 1792701622 now: 10,800 seconds later, so the tunnel has been silent for three hours

The peer key is shortened and invented.

Evidence trail: public path up, handshake three hours old, so the break is the tunnel. The lab end sits behind CGNAT, which drops an idle UDP mapping after a while; the VPS cannot reopen it from outside, because only the lab side can start the conversation. Outcome: PersistentKeepalive = 25 on the lab side sends a small packet every 25 seconds, the mapping never goes idle, and the handshake stays within the two-minute rekey interval.

Scan noise or a real attempt: telling them apart

3 Nov 2026, 21:02. The log has two kinds of unfamiliar lines, and only one of them is worth my attention.

bashLXC 207 · read-onlyExample output · substituted names and addresses

Purpose: separate lines with an account from lines without one

A scanner never gets as far as naming an account. A rejected login does.

grep -E 'lost connection' logs/latest.log | tail -n 3
Show output
[21:01:12] [Server thread/INFO]: /198.51.100.7:51022 lost connection: Disconnected
[21:01:40] [Server thread/INFO]: /198.51.100.7:51188 lost connection: Disconnected
[21:02:51] [Server thread/INFO]: stranger-1 (/203.0.113.90:52344) lost connection: You are not white-listed on this server!
  1. /198.51.100.7 no account name: a status check or a dropped handshake, typical of a scanner
  2. stranger-1 an account name that is not on the list, refused by the whitelist

Example lines; the names and addresses are invented.

Evidence trail: the first two lines carry only an address and no account, so they are background noise. The third names an account and ends with the whitelist message, so someone who knew the name and the port tried to join with an account that is not on the list, and was refused. Outcome: the whitelist did its job, and that line is the one I would follow up, because it means the join name has spread beyond the five of us.

What I learned

Moving the port changed what showed up in my log, not who could get in. The whitelist changed who could get in, and the SRV record meant none of that cost my friends anything. The three faults taught me to read the path in halves: DNS, then the VPS, then the tunnel, then the server, each with its own piece of evidence. I now sort every change into one of two piles: noise reduction or access control. Then I only test the second pile as a security claim.

Takeaway: if a change makes your logs quieter, write it down as noise reduction. The control is the thing that refuses a stranger who knows exactly where to look, so test that from outside.

Further reading: Minecraft Java Edition 26.3 · Minecraft server.properties · Whitelist · Cloudflare SRV records · Proxied and DNS-only records · Crafty Controller docs

Skills used

  • Routing a service through a VPS and WireGuard
  • Using DNS SRV records
  • Telling scan noise apart from access control
  • Writing down a fault, its check and its fix

All skills, grouped by what job postings ask for

Tools in this entry

Every tool and its documentation