I have accumulated a lot of Minecraft servers over time.
Some are development servers for my plugins, some are old projects, and some are modded servers I occasionally boot up to play with friends. They were all sitting in a directory on my Arch Linux machine and were generally started with extremely sophisticated infrastructure such as:
#!/bin/bash
clear
java -Xmx4G -jar server.jar noguiAfter staring at that mess for long enough, I decided I wanted an actual server panel.
Pterodactyl seemed like the obvious choice.
Then I looked at the installation instructions and realized Arch isn’t officially supported.
Great.
The original plan
I briefly found an Arch-specific installation guide that involved installing several packages from the AUR and configuring Pterodactyl directly on the system.
Then I had a better thought:
I already use Docker for development, so the obvious answer was to containerize the entire thing.
The final layout ended up looking roughly like this:
Arch
└── Docker
├── Pterodactyl Panel
├── MariaDB
├── Redis
├── Wings
│
└── Minecraft servers managed by WingsThe host system only needs Docker.
No PHP installation on Arch. No MariaDB service. No Redis service. No Pterodactyl packages from the AUR.
Much better.
Containerizing Pterodactyl
The Panel itself was straightforward.
I used the official Pterodactyl images and built a Compose stack containing:
- Pterodactyl Panel
- MariaDB
- Redis
- Wings
The Panel and Wings management interfaces are only exposed locally:
127.0.0.1:8085 → Panel
127.0.0.1:8080 → Wings API
127.0.0.1:2022 → Wings SFTPThe game servers themselves use my machine’s LAN address so they can still be reached by other devices and forwarded through my router when necessary.
Persistent data lives outside the containers, meaning the entire Pterodactyl control plane can disappear without deleting any configuration or servers.
That becomes important later.
Docker networking immediately decided to be annoying
The first Wings startup failed with:
invalid pool request: Pool overlaps with other one on this address spaceWings wanted to create its game-server network on 172.18.0.0/16. Unfortunately, another Docker development project on my machine was already using exactly that subnet.
My networks looked like this:
bridge → 172.17.0.0/16
taams_default → 172.18.0.0/16
pterodactyl_default → 172.19.0.0/16So I changed Wings’ game-server network to 172.20.0.0/16. After that, Wings started normally.
The final Docker network layout is satisfyingly sequential:
172.17.0.0/16 → Docker default bridge
172.18.0.0/16 → another development project
172.19.0.0/16 → Pterodactyl control plane
172.20.0.0/16 → Pterodactyl game serversThen .localhost betrayed me
For local access I initially used:
panel.pterodactyl.localhost
wings.pterodactyl.localhostThe Panel worked perfectly in Firefox. The Panel container talking to Wings did not.
A test from inside the Panel container produced something extremely suspicious:
getent hosts wings.pterodactyl.localhost
172.17.0.1 wings.pterodactyl.localhostBut curl tried 127.0.0.1:8080.
It turns out .localhost is special. Software is allowed to treat anything beneath it as loopback regardless of normal DNS resolution.
That is very convenient until “localhost” means “this container” instead of “the Arch machine hosting Docker.”
I moved Wings to wings.pterodactyl.test and gave the Wings container that hostname as a Docker network alias.
Now the same name resolves differently depending on where it is used:
Arch:
wings.pterodactyl.test → 127.0.0.1
Panel container:
wings.pterodactyl.test → Wings containerThat finally allowed the Panel to talk to Wings normally.
A proper pterodactyl command
One of the main things I wanted from the beginning was for Pterodactyl to behave like an application rather than permanent server infrastructure.
pterodactyl start
pterodactyl stop
pterodactyl restart
pterodactyl status
pterodactyl logsSo I wrote a small Bash wrapper.
pterodactyl start:
- Starts the Compose stack.
- Waits for the Panel.
- Waits for Wings.
- Starts the permanent lobby server.
- Starts Velocity.
- Opens the Panel in Firefox.
My Firefox installation came from Flatpak, because of course it did, so the script checks native Firefox first and then falls back to:
flatpak run org.mozilla.firefoxThe more interesting part is shutdown.
Originally I made the script refuse to stop Pterodactyl if any game server was still running. That worked, but since I already had a Pterodactyl Client API key configured, I eventually made it smarter.
Now pterodactyl stop does this:
find all running Pterodactyl servers
↓
send proper Pterodactyl "stop" commands
↓
wait for every server container to exit
↓
only then shut down Panel/Wings/Redis/MariaDBIf a server takes too long to stop, the script aborts rather than killing the control plane underneath it.
For example:
Fetching server information...
Stopping: Lobby
Stopping: Velocity
Waiting for game servers to shut down gracefully...
All game servers are offline.
Stopping Pterodactyl...
...
Pterodactyl stopped.restart behaves similarly, except after rebuilding the stack it only brings back the permanent infrastructure servers: Lobby and Velocity.
Any actual gameplay or development servers stay offline until I explicitly start them.
Migrating the servers
I started with roughly sixteen server directories.
After asking myself whether I actually needed all sixteen, that became ten.
Much better.
Since Pterodactyl was running on the same machine, migration didn’t require uploading anything. I could just use rsync directly into Wings’ server volumes:
sudo rsync -aH --info=progress2 \
/old/server/ \
/var/lib/pterodactyl/volumes/<uuid>/
sudo chown -R 988:988 /var/lib/pterodactyl/volumes/<uuid>Paper servers were relatively painless.
The only particularly funny mistake happened when a 1.21.11 server accidentally got started using Paper 26.2.
Paper 26.x uses a newer world-storage layout and immediately tried to migrate the Nether and End. It then discovered that fresh 26.2 world data was already in the destination and very responsibly refused to overwrite it:
Refusing to overwrite existing migrated fileNothing was lost because the original server directory was untouched. I wiped the new Pterodactyl volume, installed the correct Paper build, copied the server again, and everything worked.
Modded servers: Java roulette
The modded servers were where things got more entertaining.
The remaining collection included:
- DeceasedCraft — Forge 1.20.1
- Star Technology Theta — Forge 1.20.1
- All the Mods 10 — NeoForge 1.21.1
- Divine Journey 2 — Forge 1.12.2
Each one needed its appropriate Java version.
DeceasedCraft, for example, should run on Java 17. I configured the Forge egg for Java 17. Pterodactyl somehow started the actual server using Java 25 anyway.
Forge responded with:
Unsupported class file major version 69Because apparently Java version selection was now a negotiation.
Changing the Docker image directly in that server’s Startup settings to Java 17 fixed it immediately.
Star Technology had a different surprise. I normally ran it with a 6 GB Java heap, so I initially gave its Pterodactyl container 6 GB. It ran out of memory.
I gave it 8 GB. It ran out of memory again.
At 10 GB it finally survived startup and settled at roughly 7.5 GB.
The reason is simple in hindsight: -Xmx6G only limits Java’s heap. Pterodactyl’s memory setting limits the entire container, including heap, metaspace, native allocations, thread stacks, direct buffers, Forge overhead, and everything else.
Running directly on the host had allowed Java to quietly exceed that 6 GB heap limit in total process memory.
One public port with Velocity
At some point during all of this I had what initially sounded like a stupid idea:
So that happened too.
The basic setup became:
Internet :25565
↓
Velocity
↓
Lobby
│
├── Paper servers
├── DeceasedCraft
└── Star TechnologyPlayers connect to one address, land in a small lobby, and then use /server <server> to move somewhere else.
Eventually I’ll replace that with a custom GUI.
The lobby itself is intentionally tiny: a void world with a bedrock platform. It idles around 750–800 MB, so I gave its container roughly 1 GB rather than wasting 2 GB on what is essentially a server selector.
Because some family members play using offline Minecraft accounts, Velocity itself runs in offline mode while still using modern player forwarding to compatible backends.
SkinRestorer handles skins at the proxy level.
Proxying Forge
Modern Paper servers work with Velocity forwarding almost trivially.
Forge 1.20.1 required a little more work.
Velocity uses Ambassador for Forge compatibility, while the Forge servers use Proxy Compatible Forge for player forwarding.
DeceasedCraft worked:
[connected player] Officer_Ray has connected
[server connection] Officer_Ray -> deceased_craft has connectedStar Technology worked too, although switching from the Paper lobby sometimes requires the client to reconnect before Velocity sends it to the proper modded backend.
Not elegant, but perfectly usable for an on-demand private setup.
ATM10 decided to be ATM10
All the Mods 10 was by far the most annoying server in the entire migration.
Direct connections worked. Velocity connections did not.
The first failure was caused by ATM10 advertising too many known packs for Velocity’s default limit.
So Velocity got:
-Dvelocity.max-known-packs=512Then ATM10 immediately found another limit:
PluginMessagePacket was too bigThe first offending message was around 1.9 MB. I increased the plugin-message limit.
ATM10 responded with a packet above 5 MB.
At that point I settled on an 8 MiB maximum:
-Dvelocity.max-plugin-message-payload-size=8388608That finally worked.
Then I created the proper lobby. ATM10 joined Velocity, tried to enter the Paper lobby, and its client complained:
You are trying to connect to a server that is not running NeoForge,
but you have mods that require it.I decided that was enough.
ATM10 had officially earned the same treatment as Divine Journey 2.
The final network layout
So the final public-facing setup is intentionally simple:
:25565 → Velocity
├── Lobby
├── Paper servers
├── DeceasedCraft
└── Star Technology
:25566 → Divine Journey 2
:25567 → All the Mods 10Only three router ports are needed regardless of how many compatible servers I create behind Velocity.
The rest of the Pterodactyl allocations are never exposed to the Internet.
Was this worth it?
Yes.
What started as:
Ended with:
- Dockerized Pterodactyl on Arch Linux
- No Pterodactyl dependency stack installed on the host
- Persistent server storage
- Graceful API-based startup and shutdown
- Automatic Lobby and Velocity startup
- One-command lifecycle management
- A Velocity proxy for most servers
- Cross-version lobby access
- Migrated Paper, Forge, and NeoForge servers
- A much cleaner way to spin development servers up and down
Now my normal workflow is basically:
pterodactyl startStart whichever development or modded server I need from the Panel, do whatever I’m doing, and eventually:
pterodactyl stopEverything shuts down properly and the entire Pterodactyl control plane disappears until I need it again.
Which is significantly better than having ten terminals open running variations of:
java -Xmx4G -jar server.jar noguiProgress, I suppose.
The wrapper script
This is the helper script I use to manage the whole stack. It is intentionally tailored to my setup rather than being a general-purpose Pterodactyl management tool.
It can:
- Start the Docker Compose stack
- Wait for Panel and Wings
- Start Lobby and Velocity through the Client API
- Open the Panel in Firefox
- Gracefully stop every running game server
- Wait for them to fully exit
- Shut down the Pterodactyl control plane
- Show status and logs
View the complete wrapper script
Loading…