2AM Homelab

Pterodactyl on Unraid: Panel + Wings, start to finish

Aug 10, 2026 · unraid, pterodactyl, game-servers, docker

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:

PieceWhat it isWhere it runs
PanelThe web UI and REST API. Stores users, servers, eggs and allocations in MySQL/MariaDB. Runs no game code.One machine only
WingsThe 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

⚠️ 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:

FieldValue
Domainpanel.example.com
Schemehttp
Forward host<unraid-ip>
Forward port8084
WebsocketsON ← required; the console won’t work without it
SSLCloudflare 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:

  1. Forward an unusual WAN port to your proxy: WAN <wan-port> → <proxy-ip>:<proxy-https-port>
  2. 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

⚠️ 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.

FieldValueNote
FQDNwings.example.comMust resolve and have a valid certificate
Communicate over SSLYes
Behind proxyYesif a reverse proxy or Cloudflare is in front
Daemon port443what the panel dials publicly
Daemon SFTP port2022
Disk / Memoryyour 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:

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

SymptomCause
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 errorsapi.ssl.enabled: true while a proxy is already terminating TLS
Console blank but the server runsWebsockets 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 networkWings’ default 172.18.0.0/16 collides with an existing Docker network
Uploads or transfers fail above 100 MBCloudflare proxy enabled on a Wings hostname — set it DNS-only
Database won’t start, Errcode: 13Data directory ownership; chown -R 99:100
Server shows “offline” on public status sitesRouter-level geo filtering can block their probe nodes — check from an ordinary client before believing it
Server won’t boot after a Minecraft updateThe 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

  1. Decide the public URL before installing anything. APP_URL is baked into too much to change casually.
  2. 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.
  3. Put game data on its own cache-only share from day one. Retro-fitting it means moving hundreds of gigabytes with the servers down.
  4. 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.
  5. Write down each node’s uuid and token when you create it. If you rebuild a node’s machine, reusing the same config.yml keeps 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.