Game servers
The game-01 container on green-server, from August to September 2026. Pelican Panel and Wings ran three Minecraft workloads in turn, a modpack, Better Realism and then vanilla, until I retired the guest and its worlds on 2026-09-12.
For five weeks I hosted my own game servers on game-01, an unprivileged LXC on green-server. Pelican Panel managed them from a web interface, and its Wings daemon ran each server in its own Docker container with the memory, CPU and disk limits the panel gave it. Every workload was Minecraft Java Edition. Friends could join from outside the LAN, while the panel, Wings and file access stayed internal.
I retired it on 2026-09-12. The container was already stopped when I checked it, and the retirement record doesn’t give any other reason.
| Item | Value |
|---|---|
| Guest | game-01, CT 123 on green-server, Debian 13 from the 13.6-1 template |
| Size | 6 vCPU, 12 GiB memory, 2 GiB swap, an 80 GiB root volume on local-lvm |
| Features | Unprivileged, with nesting=1,keyctl=1 for Docker |
| Network | SERVERS-A, VLAN 80 |
| Panel | Pelican Panel v1.0.0-beta38 in Docker Compose, with SQLite |
| Daemon | Wings v1.0.0-beta27 as a native systemd service |
| Docker | 29.7.2 |
| Deployed | 2026-08-07 |
| Retired | 2026-09-12; the guest and both worlds deleted the same day |
Why Green, and why an LXC
green-server had joined the cluster on 2026-07-31 and was still empty. I checked the other nodes first:
| Node | Why not |
|---|---|
grey-server | 19 GiB free and already using swap. A Minecraft heap commits its memory at start, so adding 10 GiB to a swapping node put Coolify, Wazuh and Splunk at risk |
purple-server | The Kasm VM held 12 of its 15 GiB |
blue-server | Under 6 GiB in total after a memory module moved to Green |
red-server | Workable, but media-01 spikes during transcodes and Green was genuinely idle |
Green’s cost is single-thread speed. Its i5-8500T boosts to 3.5 GHz against Grey’s Ryzen 7 3700X, and Minecraft ticks each server on one thread. I took the isolation over the clock speed. High availability stayed off, because the root volume was on node-local storage.
Pelican’s own documentation warns that Wings will most likely not run under LXC virtualization. That warning is aimed at VPS customers who can’t turn on nesting. On Proxmox I control the container’s features, and five other guests already ran Docker this way, so I went ahead with a VM on Green as the fallback. It wasn’t needed. The part that mattered was that the container delegated the cpu, memory and pids cgroup controllers: without them Wings can’t enforce a per-server limit, and one runaway server takes the host down with it.
The panel and Wings
The panel ran from a Compose project with SQLite and no separate database container. The stock upstream Compose file needed three changes:
- A
subpathmount fails on a fresh volume. Docker’ssubpathoption doesn’t create the directory it points at, so the container refused to start until I created thepluginsdirectory once with a throwaway container. DB_CONNECTIONhas to be set, even for SQLite. Laravel’s own config defaults to SQLite, but the entrypoint is a shell script that doesn’t read it. Once the app was marked installed, an unset variable sent it down the network-database branch, and it looped forever waiting for a database while the proxy served 502s.- An HTTPS
APP_URLneedsBEHIND_PROXY. Without it the entrypoint demanded a Let’s Encrypt email address. Behind the internal reverse proxy,BEHIND_PROXY: "true"was exactly right.
I installed from the command line instead of the web installer, so the whole build was scriptable and left a readable trail, and staged the admin password in a mode-0600 file that I shredded afterwards. Wings ran as a native binary under systemd, because running it in Docker inside an LXC adds a nesting layer for nothing. I wrote its generated node configuration straight to a mode-0600 file so the token never printed.
The node’s create command got two settings wrong for a host behind a reverse proxy. It registered Wings at the port Wings listens on, when the panel and the browser actually reach it through the proxy’s HTTPS port, and it advertised file transfer at the node’s proxied name, which the proxy doesn’t carry. I pointed the first at the proxy and gave the second a direct alias. Then Wings refused to start, because both the panel and Wings call each other through the proxy as well as receiving from it, and the firewall had only the inbound direction. One more rule, scoped to this host and HTTPS, fixed the return path.
The panel enforced the node’s capacity: 10,240 MiB of memory, 51,200 MiB of disk and 600 percent CPU, with no overallocation, and eleven game allocations. Those limits are what stopped one server from taking the host down.
Three Minecraft workloads
| Dates | Workload | Runtime | What happened |
|---|---|---|---|
| 2026-08-07 to 2026-08-09 | Best Vanilla World 2 modpack | NeoForge 26.1.2.78 for Minecraft 26.1.2, Java 25 | Deleted with its world on 2026-08-09 and replaced by Better Realism |
| 2026-08-09 | Better Realism 7.2.0 | Minecraft 1.21.1, Fabric Loader 0.19.3, Java 21 | Stopped the same day and kept, with its world, until the retirement |
| 2026-08-09 to 2026-09-12 | Vanilla Minecraft 26.2 | Minecraft Java Edition 26.2, Java 25 | The public server until the guest was retired |
The first modpack
The first server was a NeoForge modpack, and its published versions were wrong in three ways. CurseForge labelled the server pack 26.2, but every mod inside was built for 26.1.2. The pack’s variables.txt named a loader that belongs to Minecraft 1.21.2. And one client-only rendering mod shipped enabled, so the server died trying to start graphics before any world loaded. I derived the right versions from the mod filenames, disabled that mod and got a clean boot.
The server then refused every connection from the first player. Its log named the reason: the client needed a different NeoForge build. CurseForge publishes this pack in parallel release lines, and client and server files only pair within a line. I had matched the Minecraft version and picked a loader myself, and any loader in that line runs the mod set, so the server booted and looked correct. Only a client proves the loader is right. Moving to the matching server pack and its loader fixed it, and since nobody had joined yet there was no player data to migrate.
The player reported the failure as a missing sync config in one mod. That mod was identical in both packs. A loader mismatch fails the handshake a few milliseconds after login, and whichever mod owns the first sync channel is the one the client names, so the mod in a client-side error is a symptom of the failed handshake, not its cause.
Better Realism
On 2026-08-09 I deleted the modpack server and its world and built a clean Fabric server for Better Realism 7.2.0. CurseForge filed it under game version 26.1.2, but the server archive’s own variables.txt said Minecraft 1.21.1 with Fabric Loader 0.19.3 and Java 21, and I trusted the archive. It reached Done with 163 mods loaded, and friends could reach it from outside the LAN.
Later the same day I stopped it and kept it. I stopped it through Wings rather than docker stop, because Wings’ crash detection restarts a container that stops outside the panel. Pelican counts a stopped server’s limits against the node, so I parked it at 1 GiB of memory and one core to make room for vanilla, which also meant it could no longer start: its startup command asks for a 4 GiB initial heap. The record doesn’t say why I replaced it.
Vanilla 26.2
The last workload was an unmodified Minecraft Java Edition server at 26.2, the release Mojang marked current on 2026-08-09. It used Pelican’s official vanilla egg and the Java 25 image, and I pinned the installer variable to 26.2 rather than latest, so a reinstall could never quietly move it to a newer release. The server JAR matched Mojang’s published SHA-1. Online authentication stayed on, with 20 player slots and no whitelist. It had 8 GiB of memory, four cores and 20 GiB of disk, took over the public allocation, and answered as 26.2, protocol 776.
The client version mismatch
On 2026-08-09 a player reported a version mismatch while trying to join Better Realism, even though they had selected 7.2.0.
Everything on the server side checked out. The status probe returned Minecraft 1.21.1, protocol 767. I downloaded the official server archive again, verified its SHA-256, and compared a sorted SHA-256 manifest of its 134 mod files with the live server’s: they were byte for byte the same. The server log held no join attempt from the player at all.
The player’s launcher log supplied the answer. It had started Minecraft 26.1.2 with Fabric Loader but none of the pack’s mods, not the 1.21.1 client with the pack’s 141 files. Fabric Loader matched by coincidence; the Minecraft version and the mod set didn’t. The log also showed an expired Minecraft session, a second blocker on a server with online authentication. CurseForge’s wrong game-version tag makes this mistake easy.
The fix was on the player’s side: install the official 7.2.0 client file as a fresh instance, import that rather than reusing the old launcher profile, check the launcher reports 1.21.1 before starting, and sign out and back in for a new session. I closed the investigation without the player confirming it, because I stopped Better Realism later that day. No server setting changed.
Keep inventory and the sudo policy
On 2026-08-11 I made two changes to the vanilla server and its host.
Keep inventory. Minecraft 26.2 rejected the old keepInventory rule name; the current identifier is keep_inventory. Attaching to the Docker console didn’t give me a safe, bounded session, so I sent the commands through Wings’ own authenticated command endpoint on the host instead, read the rule back as true and flushed a save. Players kept their items when they died from then on, and nobody was disconnected.
Sudo. The same day I gave my own account passwordless sudo on game-01, for convenience, through a validated drop-in. The SSH policy stayed keys only. That put the host outside the Linux host baseline, where only unattended accounts carry NOPASSWD, and I recorded it as a known deviation waiting for a decision rather than leaving it as an oversight.
The decision on 2026-09-07 went against the exception. I removed the drop-in while holding a root session open as a way back, checked the policy with visudo -c and sudo -l, and confirmed that a wrong password now failed and the normal elevated path still worked. My account answered a sudo prompt with root’s password, under Defaults rootpw, like the other password-gated guests. That wrong-password test had been impossible to run while NOPASSWD authenticated nothing.
The minecraft-status probe
Tests/minecraft-status.py was the check I ran after every change. It performs a Minecraft Java status handshake using only the Python standard library and prints the version, protocol, players online and maximum, the server’s message and the latency as JSON.
python3 minecraft-status.py <host> --port <port>
python3 minecraft-status.py <name> --srv
--port checks one endpoint directly. --srv resolves the name through DNS the way a Minecraft client does, using dig, and leaves the target it resolves out of its output, so the result is safe to paste into a record. The two options are mutually exclusive. The acceptance lines in the change records, such as “26.2, protocol 776, 0 of 20 players”, are its output.
Retirement, 2026-09-12
CT 123 was stopped on Green when I came to retire it. I disabled its autostart and removed it from every layer around it:
| Area | Change |
|---|---|
| Prometheus | Removed its node and container exporter targets and the panel probe, and dropped the container’s Proxmox metrics |
| Grafana | Removed its per-host dashboard, which the archive keeps |
| Wazuh | Removed the disconnected agent 018 |
| DNS | Deleted the public game records and the internal panel and Wings names |
| Proxy | Removed the two proxy hosts and checked nginx -t |
| UniFi | Deleted three dedicated firewall policies and took the host out of a shared monitoring destination list |
| Ansible | Removed it from five inventories and three identity allowlists, locally and on ansible-01 |
At first I kept the stopped container and its 80 GiB root volume, because I hadn’t decided whether to destroy the worlds. Once I had, I confirmed CT 123 was still game-01, stopped, with exactly that one root volume and nothing else attached, and destroyed it. That deleted Pelican, its configuration and both Minecraft worlds, and Green’s local-lvm went back to 0.00 percent used.
Two cached Grafana alert entries for the guest stayed behind, both in a resolved state. Grafana 13 keeps alert state outside the old tables, so I verified through its API that nothing for the guest was firing or pending, and the deleted targets meant nothing new would be evaluated.