My daughter and her friends wanted to play Minecraft together. Not some public server with 500 strangers and a chat log I'd rather they didn't see, not a "family-friendly" server run by a stranger. A private one, ours, invite-only.
I work in security, so "buy a Realms subscription and move on" was never going to be the end of it. It turned into a mini-PC, provisioned with Ansible, hardened Docker containers, locked down over Tailscale - and, somewhere along the way, a reckoning with whether I actually trust the Microsoft account the whole thing depends on.
This is the story of how it went, in the order that actually makes sense - not the order I stumbled through it in. Everything below - the Ansible playbook, the Docker Compose templates, every doc I link to - is public: tomhanoldt/little-family-minecraft-server, MIT licensed. Clone it, steal from it, tell me what I got wrong.
Where it started: a USB stick and a keyboard I only needed once
The first version had nothing to do with Ansible - just personal notes in German: download the Ubuntu Server ISO, flash it with balenaEtcher, plug in a monitor and keyboard exactly once to click through the installer (tick "Install OpenSSH server," this matters), find the new IP in the router, give it a static lease, then never touch a keyboard on that box again:
ssh [email protected]
sudo apt update && sudo apt upgrade -yThat was the whole plan, and it worked - the first login went through. I already knew what needed to happen next to automate the setup properly, so Ansible was the first choice, no debate. Building it became the promise I made myself that same night: a secure server for my daughter and her friends, not just a server.
Turning it into infrastructure, not a pet
The next real step wasn't a Minecraft feature - it was throwing out the manual setup for an Ansible playbook that rebuilds the whole box from a fresh Ubuntu install, reproducibly, checked into git. Docker Compose runs the Minecraft server and its backup sidecar, so "what's running" is a file, not a memory. Even the Ansible tooling is containerized - nothing touches my Mac directly.
It's split into two roles - common (Docker, Tailscale, the firewall, SSH/sudo hardening) and minecraft (the server itself, plugins, whitelist) - with tags on the riskier tasks, so I can re-run just one piece (--tags firewall, say) without touching anything else. Every bug I found (several, more below) went into the playbook, not a one-off SSH session I'd forget by the next deploy.
Locking down the network before anything else
The single most important decision: the server is never reachable from the public internet, period. No port forwarding, no dynamic DNS, no exposed IP. Everyone connects over Tailscale, a WireGuard mesh VPN - UFW only accepts Minecraft traffic from the tailnet's own address range.
Friends join via Tailscale's Device Sharing rather than full tailnet membership: their device sees only the Minecraft box, nothing else on my network - and it's "quarantined," so it can respond but never initiate a connection.
I also scope access per person via Tailscale ACL tags, not just "any tailnet member gets in":
"grants": [
// Kid's device - tagged, Minecraft ports only, nothing else.
{
"src": ["tag:kid-restricted"],
"dst": ["tag:minecraft"],
"ip": ["tcp:25565", "udp:19132"]
},
// My own devices need an EXPLICIT grant to reach the same server -
// "autogroup:member" no longer covers them once they're tagged.
{
"src": ["tag:tom-personal"],
"dst": ["tag:minecraft"],
"ip": ["tcp:25565", "udp:19132"]
}
]Locking down the server itself
Both containers (itzg/minecraft-server and its backup sidecar, itzg/mc-backup) run with cap_drop: ALL, with only the specific capabilities their entrypoints actually need added back - verified by running them, not guessed. Both are pinned to an exact image digest, not a floating tag. ONLINE_MODE plus a whitelist mean only specifically invited, authenticated accounts can connect at all.
The whitelist is also where I hit this project's ugliest bug: the image resolves usernames against Mojang's PlayerDB at startup, and if one name fails (routine for a brand-new Bedrock player who's never connected before) - Could not resolve user from Playerdb: .newkidhere - that's a fatal error that crash-loops the entire container, kicking everyone off, repeatedly, not just rejecting that one player. A known, long-unresolved limitation in the image, open since 2023. The fix: stop relying on its live name resolution entirely - an Ansible task now resolves Java usernames against Mojang's API at deploy time, so a typo fails the deploy cleanly instead of crash-looping something already running:
# roles/minecraft/tasks/resolve_players.yml
- name: Look up Mojang UUID for each Java whitelist/op name
ansible.builtin.uri:
url: "https://api.mojang.com/users/profiles/minecraft/{{ item }}"
return_content: true
status_code: [200, 204, 404]
register: mojang_lookup
loop: "{{ (mc_whitelist_java + mc_ops_java) | unique }}"
- name: Fail clearly if a Java name doesn't resolve to a real Mojang account
ansible.builtin.assert:
that: item.status == 200 and item.json.id is defined
fail_msg: >-
Could not resolve Java username '{{ item.item }}' via Mojang's API
(HTTP {{ item.status }}) - check for typos in group_vars/all.yml.
loop: "{{ mojang_lookup.results }}"The resulting whitelist.json/ops.json then gets handed to the container via WHITELIST_FILE/OPS_FILE, copied byte-for-byte - no validation, no live PlayerDB call, no way for a bad entry to crash anything ever again.
Bedrock added its own chaos. Xbox Gamertags get sanitized before reaching the server - a name#1234 suffix has its # stripped, while an older two-word name 1234 gets its space replaced with an underscore. Two different rules, neither guessable in advance - the only reliable way to know a player's real in-game identity is to watch them connect and read it from the log (full writeup, incident included, in docs/plugins.md).
Actually making it safe for the kids on it
The lockdown above stops a stranger from reaching the server. A separate set of plugins keeps things reasonable between the kids who are already on it:
- GriefPrevention lets each kid claim their own build area with a golden shovel - nobody else touches it, by accident or on purpose.
- CoreProtect logs every world action, so if something goes wrong, it can be rolled back and attributed rather than just vanishing.
- ChatFilter blocks links, IPs, and known-bad content in chat - actually configured to do this, since its default only registers filter categories without switching any on.
- Creative mode is the default (
force-gamemode=true), so nobody's fighting mobs or losing a build to fall damage - and non-admins can toggle their own gamemode without needing operator access.
One disclosed tradeoff: Minecraft's chat-signing/reporting feature is off, since Bedrock players (via Geyser/Floodgate) have no Mojang signing key, and leaving it on just silently broke their chat. That costs Mojang's own "report a player" pipeline; ChatFilter and CoreProtect stand in instead. Worth admitting, not hiding.
"Trust me, it's fine" isn't good enough for the other parents, so there's a plain-language doc spelling out exactly what's logged, how long, and who can read it - in practice, just me, via one SSH key.
The Microsoft problem
Both editions need a Microsoft account now - Mojang's old accountless login is gone. Minors need a Family group and a managed child account before anyone's touched the game, and there's no way to restrict where they can then play - just broad on/off toggles and a house rule, not a per-server allowlist (checked directly, not assumed). Paying twice doesn't help either: Bedrock's usually already owned, but Java's bundled with a PC Bedrock copy for ~$30, same account, sold again. Whether the other parents actually follow me through all of it is a separate, open question.
And the company behind it? I don't trust them at all. In 2023, a leaked Microsoft signing key let a Chinese state actor forge tokens and read State Department email - the US Cyber Safety Review Board called it "a cascade of security failures," an "inadequate" security culture Microsoft didn't even catch itself. Months later, a Russian actor walked straight into Microsoft's own corporate email through a legacy account with no MFA. None of that means panic about a kid's Xbox login - but it's why my server never leans on Microsoft for isolation: whitelist-only, over a VPN, not on the internet. Even a popped account has nothing to reach.
The open-source path I considered, and didn't take
If avoiding Microsoft entirely matters to you, Luanti (formerly Minetest) is the real alternative - open-source, and account-free at the platform level: each server runs its own local login, no centralized identity provider at all. Self-hosting and whitelisting work the same way.
I didn't switch, for two reasons: no official iOS app (only an ad-supported fork on an outdated engine - not something I'd hand a kid), and a mod ecosystem nowhere near Minecraft's scale, despite being real and active. For an all-Android family that doesn't need the wider plugin selection, it's the better privacy story. For mine, mixed iOS and Android, it wasn't yet a workable swap.
So what age is this actually for?
Officially: PEGI 7 / ESRB E10+ for fantasy violence, Common Sense Media's editorial recommendation around age 8 - a reasonable floor for solo or Creative-mode play with a parent nearby.
My actual recommendation: age 10, and only with a private, parent-managed server like this one - whitelist, Microsoft Family group, and Tailscale invite all still firmly in a parent's hands. That's the setup this whole post is about; what happens later is a separate conversation. Worth pairing with device-side screen time limits too - a secure server doesn't cap how long they're on it.
Wrapping up
If you're setting up something similar, the repo is public - steal from it.
Most of the actual implementation - the crash-loop, the Gamertag rules, the ACL gotcha - happened in pairing sessions with Claude Code, in the same spirit as how this blog itself got built. Fast, and it catches real bugs by running the thing rather than reading docs and hoping. But anything security-relevant - firewall rules, ACLs, capabilities, whitelisting - still gets reviewed line by line before it touches the real box. A force multiplier, not a substitute for understanding what you're deploying.