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.
- Port
- default 25565
- Whitelist
- off: any account could join
- Joining
- VPS address and port, typed by hand
- Scan noise
- frequent on the common port
- 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.net0 5 28565 mc-edge.example.net. 203.0.113.24The SRV answer carries the port and a target name; the target name resolves to the edge address.
Example output
whitelist on whitelist listWhitelist is now turned on There are 5 whitelisted players: friend-a, friend-b, friend-c, friend-d, friend-eThe 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.propertiesserver-port=28565 white-list=true enforce-whitelist=trueenforce-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 28565nc: 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 gameThat 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.
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.propertiesShow output
server-port=28565
white-list=true
enforce-whitelist=truewhite-list=trueonly accounts on the list may joinenforce-whitelist=truealso 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:
- A friend types
play.example.net. The client asks DNS for_minecraft._tcp.play.example.net. - Cloudflare DNS answers with the SRV record: a target hostname,
mc-edge.example.net, and the new port. - 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.
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.netShow output
0 5 28565 mc-edge.example.net.
203.0.113.240 5 28565priority, weight, then the port the client will usemc-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.
digreturns the SRV target and port, and a friend joins with just the name.
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 28565Show 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.
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 +answerShow 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.300the public resolver has the new record and its 5-minute TTL2961friend-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.
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 +%sShow output
kQ7…example…= 1792690822
17927016221792690822the last handshake with the lab peer1792701622now: 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.
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 3Show 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!/198.51.100.7no account name: a status check or a dropped handshake, typical of a scannerstranger-1an 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
Tools in this entry
Minecraft Java Edition server In my lab
Official documentation for Minecraft Java Edition server- Crafty Controller In my lab
Official documentation for Crafty Controller WireGuard In my lab
Official documentation for WireGuardCloudflare DNS In my lab
Official documentation for Cloudflare DNSProxmox VE In my lab
Official documentation for Proxmox VE