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 nogui
After 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:
Why am I installing PHP, MariaDB, Redis, Nginx, Composer, Wings, and whatever else directly onto my workstation just to run some Minecraft test servers?
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 Wings
The 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 SFTP
The 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 space
Wings 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/16
So 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 servers
Then .localhost betrayed me
For local access I initially used:
panel.pterodactyl.localhost
wings.pterodactyl.localhost
The 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.localhost
But 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 container
That 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 logs
So 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.firefox
The 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/MariaDB
If 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 file
Nothing 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 69
Because 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:
Why expose a separate router port for every Minecraft server when I could put Velocity in front of them?
So that happened too.
The basic setup became:
Internet :25565
↓
Velocity
↓
Lobby
│
├── Paper servers
├── DeceasedCraft
└── Star Technology
Players 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 connected
Star 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=512
Then ATM10 immediately found another limit:
PluginMessagePacket was too big
The 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=8388608
That 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 10
Only 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:
I should probably organize my Minecraft test servers.
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 start
Start whichever development or modded server I need from the Panel, do whatever I’m doing, and eventually:
pterodactyl stop
Everything 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 nogui
Progress, 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
#!/usr/bin/env bash
# Small helper for an on-demand, Docker Compose based Pterodactyl installation.
#
# Features:
# - Starts/stops the Pterodactyl Compose stack.
# - Waits for Panel and Wings to become reachable.
# - Starts selected "core" servers through the Pterodactyl Client API.
# - Gracefully stops every running Pterodactyl server before shutting down.
# - Opens the Panel in Firefox/Flatpak Firefox/xdg-open.
# - Shows Compose status, running game servers, and logs.
#
# This is intentionally a personal-workstation wrapper, not a generic installer.
set -uo pipefail
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Docker Compose project.
PTERO_DIR="${PTERO_DIR:-$HOME/.local/share/pterodactyl}"
COMPOSE_FILE="${COMPOSE_FILE:-$PTERO_DIR/compose.yml}"
# Local endpoints used by this workstation setup.
PANEL_URL="${PANEL_URL:-http://panel.pterodactyl.localhost:8085}"
WINGS_URL="${WINGS_URL:-http://wings.pterodactyl.test:8080}"
# Client API key. The key should be an administrator key because the script
# requests type=admin-all when resolving and stopping servers.
API_KEY_FILE="${API_KEY_FILE:-$HOME/.config/pterodactyl/client-api-key}"
# Wings-managed Docker network containing game-server containers.
GAME_NETWORK="${GAME_NETWORK:-pterodactyl_nw}"
# Timeouts, in seconds.
PANEL_START_TIMEOUT="${PANEL_START_TIMEOUT:-60}"
WINGS_START_TIMEOUT="${WINGS_START_TIMEOUT:-30}"
CORE_START_TIMEOUT="${CORE_START_TIMEOUT:-120}"
SHUTDOWN_TIMEOUT="${SHUTDOWN_TIMEOUT:-300}"
# Open the Panel after a successful start. Set OPEN_BROWSER=0 to disable.
OPEN_BROWSER="${OPEN_BROWSER:-1}"
# Servers that should always be running whenever Pterodactyl is started.
# Names must match the names shown in the Panel exactly.
CORE_SERVERS=(
"Lobby"
"Velocity"
)
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
die() {
echo "pterodactyl: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 ||
die "required command not found: $1"
}
compose() {
docker compose \
--project-directory "$PTERO_DIR" \
-f "$COMPOSE_FILE" \
"$@"
}
api_request() {
local method="$1"
local endpoint="$2"
local body="${3:-}"
[[ -r "$API_KEY_FILE" ]] ||
die "API key not found or unreadable: $API_KEY_FILE"
local key
key="$(<"$API_KEY_FILE")"
local args=(
--silent
--show-error
--fail-with-body
--request "$method"
--header "Authorization: Bearer $key"
--header "Accept: application/json"
)
if [[ -n "$body" ]]; then
args+=(
--header "Content-Type: application/json"
--data "$body"
)
fi
curl "${args[@]}" "${PANEL_URL}${endpoint}"
}
fetch_servers() {
api_request GET '/api/client?type=admin-all&per_page=100'
}
running_servers() {
docker ps \
--filter "network=$GAME_NETWORK" \
--format '{{.ID}}\t{{.Names}}'
}
running_server_uuids() {
docker ps \
--filter "network=$GAME_NETWORK" \
--format '{{.Names}}'
}
# -----------------------------------------------------------------------------
# Health checks
# -----------------------------------------------------------------------------
wait_for_panel() {
local deadline=$((SECONDS + PANEL_START_TIMEOUT))
printf "Waiting for Panel"
while (( SECONDS < deadline )); do
if curl -fsS "$PANEL_URL" >/dev/null 2>&1; then
echo
return 0
fi
printf "."
sleep 1
done
echo
return 1
}
wait_for_wings() {
local deadline=$((SECONDS + WINGS_START_TIMEOUT))
local status
printf "Waiting for Wings"
while (( SECONDS < deadline )); do
status="$(
curl \
-sS \
-o /dev/null \
-w '%{http_code}' \
"$WINGS_URL" 2>/dev/null || true
)"
# An unauthenticated request to a healthy Wings instance returns 401.
if [[ "$status" == "401" ]]; then
echo
return 0
fi
printf "."
sleep 1
done
echo
return 1
}
# -----------------------------------------------------------------------------
# Server lookup / control
# -----------------------------------------------------------------------------
resolve_server_identifier() {
local name="$1"
local servers="$2"
mapfile -t matches < <(
jq -r \
--arg name "$name" \
'.data[]
| select(.attributes.name == $name)
| .attributes.identifier' \
<<< "$servers"
)
if (( ${#matches[@]} == 0 )); then
echo "Could not find Pterodactyl server: $name" >&2
return 1
fi
if (( ${#matches[@]} > 1 )); then
echo "More than one Pterodactyl server is named: $name" >&2
return 1
fi
printf '%s\n' "${matches[0]}"
}
server_state() {
local identifier="$1"
api_request \
GET \
"/api/client/servers/$identifier/resources" \
| jq -r '.attributes.current_state'
}
wait_for_server_running() {
local name="$1"
local identifier="$2"
local deadline=$((SECONDS + CORE_START_TIMEOUT))
local state="unknown"
printf "Waiting for %s" "$name"
while (( SECONDS < deadline )); do
state="$(
server_state "$identifier" 2>/dev/null ||
printf 'unknown'
)"
if [[ "$state" == "running" ]]; then
echo
return 0
fi
printf "."
sleep 2
done
echo
echo "$name did not reach the running state within ${CORE_START_TIMEOUT}s."
echo "Last reported state: $state"
return 1
}
start_server() {
local name="$1"
local identifier="$2"
local state
state="$(
server_state "$identifier" 2>/dev/null ||
printf 'unknown'
)"
case "$state" in
running)
echo "$name is already running."
return 0
;;
starting)
echo "$name is already starting."
;;
stopping)
local deadline=$((SECONDS + CORE_START_TIMEOUT))
printf "Waiting for %s to finish stopping" "$name"
while (( SECONDS < deadline )); do
state="$(
server_state "$identifier" 2>/dev/null ||
printf 'unknown'
)"
if [[ "$state" == "offline" ]]; then
echo
break
fi
printf "."
sleep 2
done
if [[ "$state" != "offline" ]]; then
echo
echo "$name did not finish stopping."
return 1
fi
echo "Starting $name..."
api_request \
POST \
"/api/client/servers/$identifier/power" \
'{"signal":"start"}' \
>/dev/null ||
return 1
;;
*)
echo "Starting $name..."
api_request \
POST \
"/api/client/servers/$identifier/power" \
'{"signal":"start"}' \
>/dev/null ||
return 1
;;
esac
wait_for_server_running "$name" "$identifier"
}
start_core_servers() {
(( ${#CORE_SERVERS[@]} > 0 )) || return 0
require_command jq
echo "Fetching Pterodactyl servers..."
local servers
servers="$(fetch_servers)" || {
echo "Failed to query the Pterodactyl API."
return 1
}
local name
local identifier
for name in "${CORE_SERVERS[@]}"; do
identifier="$(
resolve_server_identifier "$name" "$servers"
)" || return 1
start_server "$name" "$identifier" ||
return 1
done
echo "Core servers are ready."
}
# -----------------------------------------------------------------------------
# Graceful shutdown
# -----------------------------------------------------------------------------
gracefully_stop_servers() {
mapfile -t running < <(running_server_uuids)
if (( ${#running[@]} == 0 )); then
echo "No game servers are running."
return 0
fi
require_command jq
echo "Fetching server information..."
local servers
servers="$(fetch_servers)" || {
echo "Failed to query the Pterodactyl API."
return 1
}
local failures=0
local uuid
local identifier
local name
for uuid in "${running[@]}"; do
identifier="$(
jq -r \
--arg uuid "$uuid" \
'.data[]
| select(.attributes.uuid == $uuid)
| .attributes.identifier' \
<<< "$servers" \
| head -n1
)"
name="$(
jq -r \
--arg uuid "$uuid" \
'.data[]
| select(.attributes.uuid == $uuid)
| .attributes.name' \
<<< "$servers" \
| head -n1
)"
if [[ -z "$identifier" || "$identifier" == "null" ]]; then
echo "Could not map running container $uuid to a Panel server."
failures=1
continue
fi
echo "Stopping: ${name:-$uuid}"
if ! api_request \
POST \
"/api/client/servers/$identifier/power" \
'{"signal":"stop"}' \
>/dev/null
then
echo "Failed to send stop signal to: ${name:-$uuid}"
failures=1
fi
done
if (( failures != 0 )); then
echo
echo "One or more servers could not be stopped."
return 1
fi
echo
echo "Waiting for game servers to shut down gracefully..."
local deadline=$((SECONDS + SHUTDOWN_TIMEOUT))
while (( SECONDS < deadline )); do
mapfile -t remaining < <(running_server_uuids)
if (( ${#remaining[@]} == 0 )); then
printf '\r%-60s\r' ""
echo "All game servers are offline."
return 0
fi
printf '\rWaiting... %d server(s) still running ' \
"${#remaining[@]}"
sleep 2
done
echo
echo
echo "Timed out waiting for graceful shutdown."
echo "The control plane will NOT be stopped."
echo
echo "Still running:"
printf ' %s\n' "${remaining[@]}"
return 1
}
# -----------------------------------------------------------------------------
# Browser
# -----------------------------------------------------------------------------
open_panel() {
[[ "$OPEN_BROWSER" != "0" ]] || return 0
if command -v firefox >/dev/null 2>&1; then
firefox --new-tab "$PANEL_URL" >/dev/null 2>&1 &
disown
return 0
fi
if command -v flatpak >/dev/null 2>&1 &&
flatpak info org.mozilla.firefox >/dev/null 2>&1; then
flatpak run org.mozilla.firefox \
--new-tab "$PANEL_URL" \
>/dev/null 2>&1 &
disown
return 0
fi
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "$PANEL_URL" >/dev/null 2>&1 &
disown
return 0
fi
echo "Could not find a browser."
echo "$PANEL_URL"
}
# -----------------------------------------------------------------------------
# Commands
# -----------------------------------------------------------------------------
start() {
require_command docker
require_command curl
[[ -f "$COMPOSE_FILE" ]] ||
die "Compose file not found: $COMPOSE_FILE"
echo "Starting Pterodactyl..."
compose up -d ||
die "failed to start Compose stack"
if ! wait_for_panel; then
echo "Panel did not become reachable."
echo
compose ps
echo
echo "Try:"
echo " pterodactyl logs"
exit 1
fi
if ! wait_for_wings; then
echo "Wings did not become reachable."
echo
compose ps
echo
echo "Try:"
echo " pterodactyl logs wings"
exit 1
fi
if ! start_core_servers; then
echo
echo "Failed to start one or more core servers."
echo "The Pterodactyl control plane has been left running."
echo "Check the Panel for details."
exit 1
fi
echo
echo "Pterodactyl is ready."
open_panel
}
stop() {
require_command docker
require_command curl
if ! compose ps --status running --quiet | grep -q .; then
echo "Pterodactyl is already stopped."
return 0
fi
if ! gracefully_stop_servers; then
echo
echo "Refusing to stop the Pterodactyl control plane."
return 2
fi
echo
echo "Stopping Pterodactyl..."
compose down ||
die "failed to stop Compose stack"
echo "Pterodactyl stopped."
}
status() {
require_command docker
compose ps
mapfile -t rows < <(running_servers)
(( ${#rows[@]} > 0 )) || return 0
echo
echo "Running game servers:"
# Resolve UUIDs to friendly names when the Panel/API is reachable.
local servers=""
if command -v curl >/dev/null 2>&1 &&
command -v jq >/dev/null 2>&1 &&
[[ -r "$API_KEY_FILE" ]] &&
curl -fsS "$PANEL_URL" >/dev/null 2>&1; then
servers="$(fetch_servers 2>/dev/null || true)"
fi
printf '%-14s %s\n' "CONTAINER" "SERVER"
local row
local container_id
local uuid
local name
for row in "${rows[@]}"; do
IFS=$'\t' read -r container_id uuid <<< "$row"
name=""
if [[ -n "$servers" ]]; then
name="$(
jq -r \
--arg uuid "$uuid" \
'.data[]
| select(.attributes.uuid == $uuid)
| .attributes.name' \
<<< "$servers" \
| head -n1
)"
fi
[[ -n "$name" && "$name" != "null" ]] ||
name="$uuid"
printf '%-14s %s\n' "$container_id" "$name"
done
}
logs() {
require_command docker
if [[ $# -gt 0 ]]; then
compose logs -f --tail=100 "$@"
else
compose logs -f --tail=100
fi
}
usage() {
cat <<'EOF'
Usage:
pterodactyl start
pterodactyl stop
pterodactyl restart
pterodactyl status
pterodactyl logs [service]
pterodactyl help
Examples:
pterodactyl logs
pterodactyl logs wings
pterodactyl logs panel
Configuration is kept near the top of the script. Most scalar settings can
also be overridden with environment variables, for example:
OPEN_BROWSER=0 pterodactyl start
SHUTDOWN_TIMEOUT=600 pterodactyl stop
EOF
}
case "${1:-}" in
start)
start
;;
stop)
stop
;;
restart)
stop && start
;;
status)
status
;;
logs)
shift
logs "$@"
;;
help|-h|--help)
usage
;;
*)
usage
exit 1
;;
esac