Minecraft Bedrock runs as Argo CD Application minecraft. Public reusable manifests live under homelabs/minecraft/. The MetalLB LoadBalancer Service, site ConfigMap, and NFS backup PersistentVolume live under homelabs-private and are composed as a multi-source Application (public path plus private overlay).
| Piece | Role |
|---|---|
StatefulSet bedrock | One replica of itzg/minecraft-bedrock-server (pin an explicit tag such as 2026.7.3) |
PVC bedrock-world-lh | Live /data on longhorn-ssd (RWO, replicated) |
PV/PVC bedrock-backup | Nightly tar archives on NFS (private static PV → NAS share) |
Service bedrock | In-cluster ClusterIP (UDP 19132) |
Service bedrock-headless | StatefulSet serviceName |
Service bedrock-lb | MetalLB LoadBalancer (UDP 19132), private overlay |
ConfigMap bedrock-site | Site keys such as ALLOW_LIST_USERS and LEVEL_NAME |
CronJob bedrock-backup | Nightly archive of /data with retention |
The public StatefulSet defaults to survival, easy difficulty, cheats off, and allow-list on. World display name comes from LEVEL_NAME (for example Family World). Keep-inventory is a world gamerule, not an itzg environment variable; set it once after the pod is Ready.
Deploy#
Merge public homelabs (branch + PR) so path minecraft/ exists on main. Commit the private AppProject destination if needed, the Application CR, LoadBalancer Service, site ConfigMap, and NFS backup PV/PVC. Point the Application’s public targetRevision at main only after that path exists, then let Argo sync.
Confirm the Application is Synced/Healthy, the StatefulSet pod is Ready, bedrock-world-lh is Bound on longhorn-ssd, and the LoadBalancer has an EXTERNAL-IP.
Reserve a free address from your MetalLB pool in the private Service annotation only. Do not put real LAN IPs in the public repo or in this documentation.
Connect from Bedrock#
On a device on the same LAN, add a server using the LoadBalancer IP and port 19132 (UDP). The in-game server name can be anything; the world display name follows LEVEL_NAME from your pin or site ConfigMap.
Allow-list#
Allow-list stays enabled in GitOps so random LAN clients cannot join. Seed players before inviting anyone.
Prefer private ConfigMap bedrock-site key ALLOW_LIST_USERS as gamertag:xuid pairs (comma- or newline-separated). You can also maintain allowlist.json on the world volume under /data. After changing the ConfigMap, recreate the pod so the env reloads. XUIDs appear in server logs when a player attempts to join. Keep real gamertags out of the public homelabs tree.
Verify a non-listed account is rejected before you treat the server as live.
Importing a local world#
You can seed /data from a local .mcworld export (a zip of the world folder) before or instead of generating a fresh world. Stop or scale the server so nothing writes /data, extract the archive so the world directory name matches LEVEL_NAME, then start the pod again.
Local client exports often disable multiplayer flags that Bedrock Dedicated Server needs. On BDS 1.26.30+, a world whose level.dat has LANBroadcast=0 or MultiplayerGame=0 can produce a truncated RakNet pong: the LoadBalancer answers, but clients never finish joining. Before go-live on an imported world, open level.dat with an NBT editor and set both LANBroadcast and MultiplayerGame to 1, then restart the server. Keep gamertags and LAN addresses out of any notes you publish.
Backups and restore#
The itzg Bedrock image does not ship a native scheduled backup for this layout. Nightly protection is a Kubernetes CronJob (bedrock-backup, schedule 0 4 * * *) plus whatever snapshots you already take on the NAS. The Job mounts the world PVC (bedrock-world-lh) read-only, writes dated bedrock-world-*.tar.gz files to the backup PVC, and keeps the newest three archives (keep=3), deleting older matches. The live world is RWO on Longhorn; schedule the Job where the volume can attach (same node as bedrock-0 is the simple default).
Backup storage is a private NFS PersistentVolume (same pattern as Immich): set server and share in homelabs-private under clusters/<cluster>/overlays/minecraft/. Do not put real NAS hosts or share paths in the public repo. Create the share on the NAS before the first sync, with NFSv3 access for the cluster nodes (same Synology-style recipe as Immich).
If you previously applied a dynamic bedrock-backup claim that stayed Pending, delete that PVC (and any unbound PV) before syncing the bound NFS PV+PVC pair.
Trigger a one-off Job to verify:
kubectl -n minecraft create job bedrock-backup-manual --from=cronjob/bedrock-backup
kubectl -n minecraft logs job/bedrock-backup-manualRestore outline (site-specific paths stay in your local runbook): scale the StatefulSet to 0 (or disable the Application briefly) so nothing writes /data, extract the chosen archive into the world volume, then scale back to 1 and confirm the world loads.
v1 backups are crash-consistent while the server runs. A later improvement is to pause saves with Bedrock Dedicated Server console commands (save hold / save query / save resume) before the tar step if you adopt send-command automation.
Updates#
Bump the image tag on the StatefulSet (public pin; keep any private ConfigMap IMAGE_PIN note in sync for operators), merge in commit order, and let Argo roll the pod. Prefer an explicit dated tag or digest, not floating latest.
kubectl -n minecraft rollout status statefulset/bedrockRollback#
Disable or delete the Argo Application, or scale the StatefulSet to 0. World data remains on bedrock-world-lh until you delete that PVC. Restore from the backup PVC if needed. Removing the LoadBalancer Service frees the MetalLB address.
Security notes#
Access control for v1 is LAN reachability plus the allow-list. There is no Cloudflare Tunnel or Ingress for Bedrock in this design. Confirm your router does not forward UDP 19132 from the WAN. Prefer no Vault secrets unless you later introduce a real credential.