Pterodactyl on Unraid: Panel + Wings, start to finish
Every Pterodactyl guide shows you the happy path. This one was written after running the thing in anger — a panel plus a handful of game servers spread across three nodes — so it includes the parts that actually cost me evenings: the passthrough mounts nobody mentions, the reverse-proxy config that looks like a certificate problem but isn’t, and the Cloudflare setting that silently breaks backups months after you set it up.
Substitute your own values for anything in <angle brackets> and for example.com. Nothing here
contains real credentials — generate your own.
What you’re building
Pterodactyl is two separate pieces, and understanding the split makes everything else easier:
| Piece | What it is | Where it runs |
|---|---|---|
| Panel | The web UI and REST API. Stores users, servers, eggs and allocations in MySQL/MariaDB. Runs no game code. | One machine only |
| Wings | The daemon that actually starts game servers as Docker containers, streams consoles, handles SFTP. | Every machine that hosts games (a “node”) |
The Panel talks to Wings over HTTPS, and Wings talks back to the Panel. Both directions must work, which is where most setup pain comes from.
The reference deployment this guide is based on:
Unraid box (10.0.0.10)
├── MariaDB panel database
├── Redis sessions/cache
├── Panel web UI → panel.example.com
└── Wings node 1 → wings.example.com
Linux box #2 (10.0.0.20) Wings node 2 (native, not Docker) → node2.example.com
Linux box #3 (10.0.0.30) Wings node 3 (native) → node3.example.com
Design note: Wings runs fine in a container on Unraid, but on dedicated game boxes install it natively via systemd. It’s simpler, and you skip the passthrough-mount gymnastics below.
Prerequisites
- Unraid with the Community Applications plugin
- A domain on Cloudflare (free tier is fine)
- A reverse proxy — this guide uses Nginx Proxy Manager (NPM)
- The ability to port-forward on your router
- ~2 GB RAM for the panel stack, plus whatever your games need
⚠️ PostgreSQL is not supported. Pterodactyl requires MySQL or MariaDB. Don’t lose an afternoon to this like I did — if you already run Postgres for other apps, you still need a separate MariaDB just for Pterodactyl.
1. Database (MariaDB)
Install MariaDB from Community Applications.
Image: mariadb
Network: db-net (see note below)
Ports: none published (see note below)
Paths: /mnt/user/appdata/mariadb/data → /var/lib/mysql
/mnt/user/appdata/mariadb/config → /etc/mysql/conf.d
Variables:
MARIADB_DATABASE panel
MARIADB_USER pterodactyl
MARIADB_PASSWORD <strong-password>
MARIADB_ROOT_PASSWORD <different-strong-password>
TZ <Your/Timezone>
Networking — worth doing properly. Create a user-defined bridge network and put the database on it with no published ports at all:
docker network create db-net
Containers on db-net reach the database by container name (<mariadb-container>:3306).
Nothing else on your LAN can reach it, because there is no host port to reach. That is strictly
better than binding 0.0.0.0:3306 and trusting your firewall to be right forever.
⚠️ Gotcha: MariaDB data directory ownership
Unraid’s template runs containers as 99:100 (nobody:users), but the official MariaDB image
expects 999:999. If the data directory has the wrong owner, the container dies at startup with:
Can't create/write to file './ddl_recovery.log' (Errcode: 13)
The fix:
chown -R 99:100 /mnt/user/appdata/mariadb/data
This bites specifically when a container is recreated from its template after being hand-built — which is a good general warning for Unraid: template re-apply discards hand edits.
2. Redis
Install Redis from Community Applications. The defaults are fine; the panel uses it for sessions and cache.
Image: redis
Port: 6379
⚠️ Set a password (requirepass) if you publish it on 0.0.0.0. An open Redis on a flat LAN
is a genuine risk, not a theoretical one, and the panel supports REDIS_PASSWORD natively.
3. Panel
Install the Pterodactyl Panel container (ich777’s Unraid template, or the official image directly).
Image: ghcr.io/pterodactyl/panel
Network: db-net ← same network as MariaDB
Ports: 8084 → 80
8484 → 443 (optional; the proxy terminates TLS anyway)
Paths: /mnt/user/appdata/pteropanel/var → /app/var
/mnt/user/appdata/pteropanel/nginx → /app/nginx/http.d/
/mnt/user/appdata/pteropanel/logs → /app/storage/logs
Variables:
APP_URL https://panel.example.com ← must be the PUBLIC url, https
DB_HOST <mariadb-container> ← container name, not an IP
DB_PORT 3306
DB_DATABASE panel
DB_USERNAME pterodactyl
DB_PASSWORD <same as MARIADB_PASSWORD>
REDIS_HOST <host-ip or container name>
REDIS_PASSWORD <redis password>
APP_TIMEZONE <Your/Timezone>
TZ <Your/Timezone>
PTERODACTYL_TELEMETRY_ENABLED false
⚠️ APP_URL must be the final public HTTPS URL from the very start. It gets baked into
generated links, the Wings handshake and password-reset emails. Changing it later means clearing
caches and re-issuing node configs.
First run
docker exec -it <panel-container> php artisan migrate --seed --force
docker exec -it <panel-container> php artisan p:user:make # create the admin
Then browse to http://<unraid-ip>:8084 and confirm it loads before you add the proxy. Debugging
a broken panel through a broken proxy is twice the work.
4. Public access: reverse proxy, Cloudflare, router
Nginx Proxy Manager
Add a proxy host:
| Field | Value |
|---|---|
| Domain | panel.example.com |
| Scheme | http |
| Forward host | <unraid-ip> |
| Forward port | 8084 |
| Websockets | ON ← required; the console won’t work without it |
| SSL | Cloudflare Origin cert (a wildcard *.example.com is easiest) |
Websockets support is not optional. Without it the panel loads normally but every server console stays blank — which looks exactly like a Wings problem, and isn’t.
⚠️ If your router holds ports 80 and 443
Some all-in-one routers (UniFi Dream Machines are the common example) intercept WAN 80/443 for their own management console, so you simply cannot forward 443 to your proxy. The workaround:
- Forward an unusual WAN port to your proxy: WAN
<wan-port>→<proxy-ip>:<proxy-https-port> - In Cloudflare, add an Origin Rule rewriting the origin port to
<wan-port>for those hostnames
Visitors still reach you on ordinary 443 — Cloudflare handles that side. Only the connection from Cloudflare back to your origin uses the unusual port. Pick your own port numbers; there’s nothing special about any particular choice.
Cloudflare DNS
panel.example.com→ Proxied (orange cloud). Fine; panel traffic is small.wings.example.com→ read the warning below first.
⚠️ Do NOT proxy your Wings hostnames through Cloudflare
Cloudflare’s free tier caps request bodies at 100 MB. Wings uses HTTP for backups, uploads and node-to-node server transfers, so anything larger than 100 MB fails — and it fails in a way that looks like a Wings bug, months after you set the DNS up and forgot about it.
Set Wings hostnames to DNS-only (grey cloud) and get certificates directly over DNS-01:
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials <path-to-cloudflare-credentials.ini> \
-d node2.example.com
DNS-01 means the node never needs inbound port 80, and renewal is automatic.
5. Wings on Unraid (node 1)
Install the Pterodactyl Wings container.
Image: ghcr.io/pterodactyl/wings:latest
Network: bridge
Privileged: ON ← required; it manages Docker
Ports: 8092 → 8080 (API)
2022 → 2022 (SFTP)
Paths:
/mnt/user/appdata/pterowings/etc → /etc/pterodactyl/
/mnt/user/appdata/pterowings/logs → /var/log/pterodactyl/
/var/run/docker.sock → /var/run/docker.sock
/tmp/pterodactyl → /tmp/pterodactyl/
/run/wings → /run/wings ← host == container
/mnt/user/gameservers → /mnt/user/gameservers ← host == container
⚠️ The three Unraid-specific gotchas
These cost the most time, and none of them are in the official docs.
1. /run/wings must be passthrough — the host path and container path must be identical.
Wings bind-mounts a per-container machine-id from this directory into each game container. Docker
resolves that bind on the host, so if the two paths differ, the install fails with:
invalid mount: bind source path does not exist
2. The game data directory must be passthrough too. Same reason: Wings tells Docker to bind
/mnt/user/gameservers/<uuid> into the game container, and the daemon has to see it at the
identical path the host does. Set system.data in config.yml to match the mount exactly.
3. Wings’ default Docker subnet may collide with your existing networks. Wings creates
pterodactyl_nw on 172.18.0.0/16 by default. If that overlaps a network you already have, game
containers come up with no working networking at all. Change it in config.yml:
docker:
network:
interfaces:
v4:
subnet: 172.21.0.0/16
gateway: 172.21.0.1
Add all of these mounts to the Unraid template XML, not just to the running container. Otherwise a GUI “Apply” silently drops them and Wings breaks the next time you edit anything unrelated. This applies to any hand-built container config on Unraid — it’s the same reason a GPU pinned to the wrong identifier survives right up until the day someone re-applies a template.
Storage: give game data its own share
Share: gameservers
Use cache: Only ← never moved to the parity array
Pool: <fast pool>
Two reasons. First, it keeps the appdata backup plugin away from it — worlds grow to tens of gigabytes and change constantly, and sweeping them into nightly appdata backups is miserable. Second, performance: game servers are latency-sensitive and parity-protected array writes are not. Back this share up separately instead (see below).
The same principle pays off elsewhere on an Unraid box: binding a large, read-heavy directory
straight to its pool instead of going through a user share
cut one container’s load time from 92 s to 31 s,
because the FUSE layer behind /mnt/user is expensive for big sequential reads.
6. Registering the node
In the panel: Admin → Nodes → Create New.
| Field | Value | Note |
|---|---|---|
| FQDN | wings.example.com | Must resolve and have a valid certificate |
| Communicate over SSL | Yes | |
| Behind proxy | Yes | if a reverse proxy or Cloudflare is in front |
| Daemon port | 443 | what the panel dials publicly |
| Daemon SFTP port | 2022 | |
| Disk / Memory | your limits |
Then open the Configuration tab, copy the generated YAML, and paste it into
/mnt/user/appdata/pterowings/etc/config.yml.
⚠️ Now hand-edit that file — the generated config is wrong for a proxied container
api:
host: 0.0.0.0
port: 8080 # ← the container's REAL listen port, not 443
ssl:
enabled: false # ← the proxy terminates TLS, not Wings
system:
data: /mnt/user/gameservers # ← must match the passthrough mount
remote: https://panel.example.com
allowed_origins: ['*']
The panel wrote “port 443” because that’s the port it dials publicly. Wings itself must listen on
8080 inside the container, and must not attempt TLS, because your proxy already did it.
Leaving ssl.enabled: true produces a handshake error that reads convincingly like a certificate
problem and isn’t.
Restart Wings and check the panel — the node heartbeat should go green within about 30 seconds.
Verifying it actually works
curl -I https://wings.example.com # expect 401
A 401 is success here. It proves the request traversed Cloudflare → router → proxy → Wings and got a real Pterodactyl response back. A 502 or 522 means the chain is broken somewhere; a redirect to a login page means something in front (an access-control layer, say) is intercepting it.
7. Adding more nodes (native install)
On dedicated game boxes, install Wings natively — no container, no passthrough mounts:
curl -L -o /usr/local/bin/wings \
https://github.com/pterodactyl/wings/releases/latest/download/wings_linux_amd64
chmod u+x /usr/local/bin/wings
mkdir -p /etc/pterodactyl
# paste config.yml from the panel, then:
systemctl enable --now wings
Notes from doing this twice:
- Match the Wings version across nodes (
wings --version). Mixed versions cause odd, hard-to-attribute transfer failures. - Check that the
pterodactyluser’s uid/gid don’t collide. On one box the gid I wanted was alreadydocker; on another the uid was alreadypolkitd. Create the user in its own group and setsystem.user.uid/gidinconfig.ymlto match, or file ownership inside volumes goes wrong in confusing ways. - Set
allowed_origins: ['*']from day one, otherwise the console websocket is rejected. - Wings binds allocations to the node’s IP, not localhost. An RCON or query tool has to dial
the node’s LAN address, not
127.0.0.1— the loopback connection is refused.
Moving a server between nodes
The panel’s built-in transfer works, but it pushes the whole archive over HTTP, which runs straight into Cloudflare’s 100 MB cap if that hostname is proxied. For anything large, move it manually:
# on the source node
tar -cf - -C /var/lib/pterodactyl/volumes <uuid> | \
ssh <new-node> 'tar -xf - -C /var/lib/pterodactyl/volumes'
chown -R pterodactyl:pterodactyl /var/lib/pterodactyl/volumes/<uuid>
Then reassign the server to the new node in the panel with a new allocation, and restart Wings on both ends. Verify the file counts match on each side before starting the server.
8. Backups
Wings’ built-in backups are per-server and live on the same box — fine for “I blew up my base”, useless if the machine dies. Add a real one:
# nightly: stop-save-copy-start, keep 7 dated snapshots, then push off-box
rsync -a --delete /var/lib/pterodactyl/volumes/ /backups/pterodactyl/current/
cp -al /backups/pterodactyl/current /backups/pterodactyl/snapshot-$(date +%F)
rsync -a /backups/pterodactyl/current/ <remote>:/path/to/node-worlds/current/
Use cp -al (hardlinks) for the snapshots — you get dated restore points at almost no disk cost,
because unchanged files are shared rather than copied.
Restrict the off-box SSH key to the source host in authorized_keys:
from="<source-host-ip>" ssh-ed25519 AAAA...
For Minecraft specifically, issue save-off and save-all before copying and save-on
afterwards, or you will eventually archive a half-written region file and not find out until you
need the backup.
9. Troubleshooting
| Symptom | Cause |
|---|---|
| Node red, “could not connect” | api.port still set to 443 in config.yml; it must be the container’s real listen port |
| TLS handshake errors | api.ssl.enabled: true while a proxy is already terminating TLS |
| Console blank but the server runs | Websockets not enabled on the proxy host, or allowed_origins unset |
invalid mount: bind source path does not exist | /run/wings (or the data directory) isn’t a passthrough host == container mount |
| Game containers have no network | Wings’ default 172.18.0.0/16 collides with an existing Docker network |
| Uploads or transfers fail above 100 MB | Cloudflare proxy enabled on a Wings hostname — set it DNS-only |
Database won’t start, Errcode: 13 | Data directory ownership; chown -R 99:100 |
| Server shows “offline” on public status sites | Router-level geo filtering can block their probe nodes — check from an ordinary client before believing it |
| Server won’t boot after a Minecraft update | The egg’s Java image is too old; set a newer yolks:java_XX image on the server |
Useful commands
docker logs -f <wings-container> # wings log
docker exec -it <panel-container> php artisan p:user:make # new admin
docker exec -it <panel-container> tail -f /app/storage/logs/laravel-*.log
curl -I https://wings.example.com # 401 = healthy
10. Things I’d do differently
- Decide the public URL before installing anything.
APP_URLis baked into too much to change casually. - Never proxy Wings hostnames through Cloudflare. The 100 MB cap only surfaces later, during a transfer or a large backup, and the failure is deeply unobvious when it does.
- Put game data on its own cache-only share from day one. Retro-fitting it means moving hundreds of gigabytes with the servers down.
- Run Wings natively anywhere that isn’t Unraid. The container works, but the passthrough mounts are a recurring source of breakage every time a template is re-applied.
- Write down each node’s uuid and token when you create it. If you rebuild a node’s machine,
reusing the same
config.ymlkeeps every server defined in the panel — otherwise you re-add them all by hand.
Built and debugged on Unraid 7.3.x with Pterodactyl Panel (latest) and Wings v1.13.1.