Minecraft Java Edition 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 java | One replica of itzg/minecraft-server (pin an image digest from the java25 index for Minecraft 26.x, and set VERSION to the Java release such as 26.3) |
PVC bedrock-world-lh | Live /data on longhorn-ssd (RWO). Name is historical from the Bedrock era; do not rename while Bound |
PV/PVC bedrock-backup | Nightly tar archives on NFS (private static PV → NAS share). Historical name retained |
Service java | In-cluster ClusterIP (TCP 25565) |
Service java-headless | StatefulSet serviceName |
Service java-lb | MetalLB LoadBalancer (TCP 25565), private overlay |
ConfigMap java-site | Site keys such as WHITELIST_USERS and LEVEL |
CronJob java-backup | Nightly archive of /data with retention (java-world-*.tar.gz) |
Job java-restore | Apply-on-demand wipe-and-extract from a Java backup archive. Not in the Kustomization |
The public StatefulSet defaults to survival, easy difficulty, whitelist on, and online mode. Private Argo patches may force creative mode. World folder name comes from LEVEL (for example Family World).
Deploy#
Merge public homelabs (branch + PR) so path minecraft/ on main is the Java stack. Commit the private 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 on TCP 25565.
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. Use a placeholder such as <metallb-minecraft-ip> when you write runbooks elsewhere.
If you previously ran Bedrock objects (bedrock, bedrock-lb, CronJob bedrock-backup) and Application sync has prune: false, delete those leftovers after Java is Ready so only the Java stack remains.
Connect from Java Edition#
On a device on the same LAN, add a multiplayer server using the LoadBalancer IP and port 25565 (TCP). Use a Java Edition client whose version matches the server VERSION pin. Bedrock Edition clients will not join this server (Geyser is out of scope).
Allow-list (whitelist)#
Whitelist stays enabled in GitOps so random LAN clients cannot join. Seed players before inviting anyone.
Prefer private ConfigMap java-site key WHITELIST_USERS as comma-separated Java usernames. The StatefulSet maps that key into the itzg WHITELIST env and sets EXISTING_WHITELIST_FILE=SYNCHRONIZE, so on every start the container rewrites live whitelist.json from that env. After changing the ConfigMap, recreate the pod so the env reloads. Keep real usernames out of the public homelabs tree.
You can also add players at runtime with RCON (whitelist add <name>), but those adds are ephemeral unless you copy the same names into WHITELIST_USERS. Because of SYNCHRONIZE, a restart or rollout that still has a stale ConfigMap will wipe RCON-only entries. Treat Git as the source of truth: update the ConfigMap whenever the live allow-list changes.
Bedrock gamertag:XUID pairs do not apply to Java. Re-seed after a Bedrock→Java migration.
Verify a non-listed account is rejected before you treat the server as live.
Migrating a Bedrock world (Chunker)#
Java cannot mount a Bedrock LevelDB world as-is. Convert offline with Chunker (GUI or CLI), targeting a Java format that matches your VERSION pin (pick the closest Chunker JAVA_* output to that release).
Chunker does not convert player inventories across editions. Before cutover, have every player empty hotbar, armor, and valuables into chests or other containers. Container contents generally convert; loose entities often do not.
Operator outline (cluster writes are yours; agents do not run them):
- Trigger a final Bedrock-era backup (
bedrock-world-*.tar.gz) and confirm it on the backup PVC. Keep it for rollback. - Players stash inventories in chests; stop play.
- Pause Application autosync, scale the Bedrock StatefulSet to zero, wait until the pod is gone.
- Copy
/data/worlds/<LEVEL_NAME>/off the volume (or convert from that path with Chunker CLI). - Run Chunker Bedrock → Java. Enable item/container conversion in advanced settings when available.
- Wipe live
/data(exceptlost+found). Place the converted world soitzg/minecraft-serversees/data/<LEVEL>/level.dat(folder name must matchLEVEL). - Sync the Java GitOps stack. Seed
WHITELIST_USERS. Join and confirm builds and chest contents. - Delete leftover Bedrock Services/StatefulSet/CronJob if prune left them behind.
Rollback: restore a bedrock-world-*.tar.gz only after reverting GitOps to Bedrock manifests. Do not feed a Bedrock archive into Job java-restore.
Backups and restore#
Nightly protection is CronJob java-backup (schedule 0 4 * * *). The Job mounts the world PVC read-only, writes dated java-world-*.tar.gz files to the backup PVC, and keeps the newest three archives (keep=3). The live world is RWO on Longhorn; schedule the Job where the volume can attach (same node as java-0 is the simple default).
Backup storage is a private NFS PersistentVolume: 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.
Older bedrock-world-*.tar.gz files may remain on the same PVC for Bedrock rollback; Java restore uses java-world-*.tar.gz only. PVC names bedrock-world-lh and bedrock-backup stay for the Bound volumes; renaming them would orphan storage.
Trigger a one-off Job to verify:
kubectl -n minecraft create job java-backup-manual --from=cronjob/java-backup
kubectl -n minecraft logs job/java-backup-manualRestore wipes live /data (except lost+found) and extracts one archive. The Job lives at public homelabs/minecraft/job-restore.yaml. Leave that file out of the Kustomization; if Argo created the Job on sync it would destroy the world.
Pause automated sync before you scale. Application minecraft uses selfHeal: true, so Git will put replicas: 1 back unless autosync is off. The world PVC is RWO, so wait until java-0 is gone before the restore pod attaches. Nightly backup requires that pod on the same node: do not leave the StatefulSet at zero across the 0 4 * * * schedule.
Pause autosync, scale the server down, wait for the pod to disappear, then apply the Job with the stamp substituted. After the Job Completes, scale back to one replica and restore the Application automated policy (prune: false, selfHeal: true).
kubectl -n argocd patch application minecraft --type merge \
-p '{"spec":{"syncPolicy":{"automated":null}}}'
kubectl -n minecraft scale statefulset java --replicas=0
kubectl -n minecraft wait --for=delete pod/java-0 --timeout=180s
kubectl -n minecraft delete job java-restore --ignore-not-found
stamp=YYYYMMDDTHHMMSSZ
sed "s/YYYYMMDDTHHMMSSZ/${stamp}/" homelabs/minecraft/job-restore.yaml | kubectl apply -f -
kubectl -n minecraft wait --for=condition=complete job/java-restore --timeout=180s
kubectl -n minecraft logs job/java-restore
kubectl -n minecraft scale statefulset java --replicas=1
kubectl -n minecraft rollout status statefulset/java --timeout=180s
kubectl -n argocd patch application minecraft --type merge \
-p '{"spec":{"syncPolicy":{"automated":{"prune":false,"selfHeal":true}}}}'Join from the LAN on TCP 25565 and confirm the world still matches LEVEL.
Updates#
Two pins move together. The StatefulSet image is the itzg wrapper (explicit digest or dated tag, never floating latest). VERSION is the Minecraft Java release the wrapper downloads at start. Minecraft 26.x needs a Java 25-capable wrapper image (java25 index); older JVM tags will not run that release. Keep the private ConfigMap IMAGE_PIN note in sync for operators. Merge public homelabs first, then the private note, and let Argo roll the pod.
The current public pin is wrapper digest sha256:2a7f76eb43b4b4f45f73d01a8dd95a90ca7b7ea375187fb8407b74ac8b0b56d4 (itzg java25 index) and VERSION=26.3. After sync, confirm the new pod is Ready and the server log shows that version.
kubectl -n minecraft rollout status statefulset/java
kubectl -n minecraft logs statefulset/java | grep -E 'Starting minecraft server version|Image info'Rollback#
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 a java-world-*.tar.gz for Java, or revert GitOps to Bedrock and restore a bedrock-world-*.tar.gz if you must go back. Removing the LoadBalancer Service frees the MetalLB address.
Security notes#
Access control is LAN reachability plus the whitelist. There is no Cloudflare Tunnel or Ingress for Minecraft in this design. Confirm your router does not forward TCP 25565 from the WAN. Prefer no Vault secrets unless you later introduce a real credential.