Deploy Pterodactyl Panel and Wings with Docker, Traefik, and Headscale
Run Pterodactyl Panel publicly while keeping Wings, SFTP, and game allocations private over Headscale.
Pterodactyl is a self-hosted game server control panel. The Panel gives users a web UI for creating and controlling servers. Wings is the daemon that talks to Docker and runs each game server in an isolated container.
This guide documents a single-server Pterodactyl deployment where the Panel is public but the actual game infrastructure stays private over Headscale/Tailscale.
The pattern is useful when you want a clean admin UI on the internet, but you do not want to open game ports, SFTP, or Wings directly to the public network.
All domains, IP addresses, usernames, passwords, API keys, tokens, and IDs in this post are placeholders. Replace them in your own environment and never publish real secrets or private infrastructure details.
What this service does
Route or access pattern:
1
2
3
4
pterodactyl.example.com # public Panel UI
wings.example.com # Wings API, DNS-only/private tailnet target
<server-tailnet-ip>:2022 # SFTP over Headscale/Tailscale only
<server-tailnet-ip>:25565 # Minecraft/game allocation over Headscale/Tailscale only
Main components:
1
Pterodactyl Panel, Wings, Docker, MariaDB, Redis, Traefik, Cloudflare DNS, Headscale/Tailscale, game server containers.
Network and port model:
1
2
3
4
5
6
7
8
9
10
11
Public HTTPS:
Browser -> Traefik :443 -> Pterodactyl Panel :80
Private daemon path:
Browser/Panel -> wings.example.com :443 -> Traefik -> Wings :8080
Private game path:
Game client on tailnet -> <server-tailnet-ip>:25565 -> Minecraft container
Private SFTP path:
SFTP client on tailnet -> <server-tailnet-ip>:2022 -> Wings SFTP
In this design, only the Panel is public. Wings and game allocations require a tailnet connection.
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Public HTTPS
Admin browser
↓
https://pterodactyl.example.com
↓
Traefik
↓
Pterodactyl Panel
├── MariaDB
├── Redis
└── talks to Wings through wings.example.com
Private tailnet
Headscale/Tailscale client
↓
wings.example.com -> <server-tailnet-ip>
↓
Traefik allowlist
↓
Wings daemon
├── Docker socket
├── /var/lib/pterodactyl volumes
├── /run/wings runtime state
└── game server containers
└── Minecraft, Steam games, voice servers, etc.
Panel and Wings can run on the same physical server. Treat them as separate logical services:
- Panel owns the web UI, users, eggs, allocations, and database state.
- Wings owns runtime execution, Docker containers, logs, SFTP, installs, and power actions.
Folder layout
Use one top-level service folder:
1
2
3
4
5
6
7
8
9
10
11
12
/home/ubuntu/pterodactyl/
├── panel/
│ ├── docker-compose.yml
│ ├── .env
│ └── mysql-client-disable-ssl.cnf
├── wings/
│ ├── docker-compose.yml
│ └── config.yml
├── panel-data/
├── database/
├── logs/
└── backups/
Host paths used by Wings:
1
2
3
4
5
/etc/pterodactyl/config.yml
/var/lib/pterodactyl/volumes
/var/log/pterodactyl
/tmp/pterodactyl
/run/wings
The /run/wings mount matters when Wings itself runs inside Docker. Game containers may need host-visible runtime files such as machine ID bind mounts.
DNS model
Use two DNS styles:
1
2
pterodactyl.example.com -> public reverse-proxy target
wings.example.com -> DNS-only A record to <server-tailnet-ip>
Rules:
- keep
pterodactyl.example.compublic if you want to manage the Panel from normal browsers; - keep
wings.example.comDNS-only, not Cloudflare-proxied; - point
wings.example.comat the server tailnet IP; - ensure Headscale/Tailscale clients can resolve that name;
- never proxy Wings through Cloudflare orange-cloud mode because it is a private daemon endpoint, not a public website.
Example private DNS entry:
1
wings.example.com A <server-tailnet-ip>
If you use a DNS automation container that creates records from Traefik labels, exclude wings from automatic public CNAME creation.
Panel environment file
Create a private Panel .env file.
APP_URL=https://pterodactyl.example.com
APP_TIMEZONE=UTC
[email protected]
TRUSTED_PROXIES=*
APP_ENV=production
APP_ENVIRONMENT_ONLY=false
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_DRIVER=redis
REDIS_HOST=cache
DB_HOST=database
DB_PORT=3306
DB_DATABASE=panel
DB_USERNAME=pterodactyl
DB_PASSWORD=<database-password>
MYSQL_DATABASE=panel
MYSQL_USER=pterodactyl
MYSQL_PASSWORD=<database-password>
MYSQL_ROOT_PASSWORD=<root-database-password>
MAIL_DRIVER=smtp
MAIL_HOST=<smtp-host>
MAIL_PORT=587
MAIL_USERNAME=<smtp-username>
MAIL_PASSWORD=<smtp-password>
MAIL_ENCRYPTION=tls
[email protected]
Protect it:
1
chmod 600 /home/ubuntu/pterodactyl/panel/.env
Panel Docker Compose
This Compose file runs the Panel, MariaDB, and Redis. Traefik publishes only the Panel route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
services:
database:
image: mariadb:10.11
container_name: pterodactyl-database
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password
env_file: .env
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- /home/ubuntu/pterodactyl/database:/var/lib/mysql
networks:
- pterodactyl_internal
cache:
image: redis:7-alpine
container_name: pterodactyl-redis
restart: unless-stopped
networks:
- pterodactyl_internal
panel:
image: ghcr.io/pterodactyl/panel:latest
container_name: pterodactyl-panel
restart: unless-stopped
env_file: .env
depends_on:
- database
- cache
volumes:
- /home/ubuntu/pterodactyl/panel-data:/app/var
- /home/ubuntu/pterodactyl/nginx:/etc/nginx/http.d
- /home/ubuntu/pterodactyl/logs:/app/storage/logs
- /home/ubuntu/pterodactyl/panel/mysql-client-disable-ssl.cnf:/etc/my.cnf.d/zz-disable-client-ssl.cnf:ro
networks:
- proxy
- pterodactyl_internal
extra_hosts:
- "wings.example.com:<traefik-proxy-network-ip>"
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.pterodactyl.entrypoints=https"
- "traefik.http.routers.pterodactyl.rule=Host(`pterodactyl.example.com`)"
- "traefik.http.routers.pterodactyl.tls=true"
- "traefik.http.routers.pterodactyl.tls.certresolver=cloudflare"
- "traefik.http.services.pterodactyl.loadbalancer.server.port=80"
networks:
proxy:
external: true
pterodactyl_internal:
name: pterodactyl_internal
driver: bridge
The extra_hosts entry is useful when the Panel container needs to reach wings.example.com through the local Traefik container rather than public DNS.
The nginx mount lets you tune Panel upload limits without rebuilding the image. That matters for plugin packs, modpacks, worlds, backups, and other large files uploaded through the browser.
Panel upload limits
If browser uploads fail partway through with Network Error, 413 Request Entity Too Large, or 502 Bad Gateway, raise the Panel nginx and PHP upload limits.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
server {
listen 80;
server_name _;
root /app/public;
index index.html index.htm index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
access_log off;
error_log /var/log/nginx/pterodactyl.app-error.log error;
client_max_body_size 1024m;
client_body_timeout 600s;
sendfile off;
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param PHP_VALUE "upload_max_filesize = 1024M \n post_max_size=1024M \n max_execution_time=600 \n max_input_time=600";
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 600;
fastcgi_read_timeout 600;
}
}
Restart the Panel after changing this file:
1
2
cd /home/ubuntu/pterodactyl/panel
docker compose up -d --force-recreate panel
MariaDB client SSL workaround
Some Panel images include a MariaDB client that defaults to SSL. If your local MariaDB container does not support SSL, migrations can fail with:
1
TLS/SSL error: SSL is required, but the server does not support it
Add a small client config:
1
2
3
[client]
skip-ssl
ssl-verify-server-cert=0
Mount it read-only into the Panel container as shown above.
Start the Panel
1
2
cd /home/ubuntu/pterodactyl/panel
docker compose up -d
Create an admin user:
1
docker exec -it pterodactyl-panel php artisan p:user:make
Create a location and a node:
1
2
3
docker exec -it pterodactyl-panel php artisan p:location:make
docker exec -it pterodactyl-panel php artisan p:node:make
Recommended node values for this pattern:
1
2
3
4
5
6
7
FQDN: wings.example.com
Scheme: https
Behind proxy: yes
Public: no
Daemon port: 443
SFTP port: 2022
Base directory: /var/lib/pterodactyl/volumes
The Panel shows https://wings.example.com:443 because Traefik terminates HTTPS and forwards to Wings internally.
Generate Wings config
After creating the node, export the config from the Panel:
1
2
docker exec pterodactyl-panel php artisan p:node:configuration 1 --format=yaml \
> /home/ubuntu/pterodactyl/wings/config.yml
Copy it to the host config path:
1
2
3
sudo mkdir -p /etc/pterodactyl
sudo cp /home/ubuntu/pterodactyl/wings/config.yml /etc/pterodactyl/config.yml
sudo chmod 600 /etc/pterodactyl/config.yml
When Panel and Wings are on the same Docker host, you may want Wings to reach the Panel internally:
1
remote: 'http://pterodactyl-panel'
If you do that, also add explicit allowed CORS origins so the browser-side node heartbeat still works:
1
2
3
allowed_origins:
- https://pterodactyl.example.com
- http://pterodactyl-panel
Without this, the node details may work but the node-list heart can stay red because the browser rejects Wings’ CORS response.
Wings Docker Compose
Wings needs access to Docker, server volumes, logs, temp files, and /run/wings.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
services:
wings:
image: ghcr.io/pterodactyl/wings:latest
container_name: pterodactyl-wings
restart: unless-stopped
tty: true
environment:
TZ: UTC
WINGS_UID: 988
WINGS_GID: 988
WINGS_USERNAME: pterodactyl
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/containers:/var/lib/docker/containers
- /etc/pterodactyl:/etc/pterodactyl
- /var/lib/pterodactyl:/var/lib/pterodactyl
- /var/log/pterodactyl:/var/log/pterodactyl
- /tmp/pterodactyl:/tmp/pterodactyl
- /run/wings:/run/wings
- /etc/ssl/certs:/etc/ssl/certs:ro
ports:
- "<server-tailnet-ip>:2022:2022/tcp"
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.middlewares.wings-headscale-only.ipallowlist.sourcerange=<tailnet-cidr>,<docker-cidr>,127.0.0.1/32"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolalloworiginlist=https://pterodactyl.example.com"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolallowcredentials=true"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolmaxage=7200"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolexposeheaders=Location,Tus-Resumable,Tus-Version,Tus-Extension,Tus-Max-Size,Upload-Length,Upload-Metadata,Upload-Offset,Upload-Defer-Length,Upload-Concat"
- "traefik.http.middlewares.wings-upload-cors.headers.addvaryheader=true"
- "traefik.http.middlewares.wings-upload-cors.headers.accesscontrolallowheaders=Accept,Accept-Encoding,Authorization,Cache-Control,Content-Type,Content-Length,Origin,X-Real-IP,X-CSRF-Token,X-Requested-With,Tus-Resumable,Upload-Length,Upload-Metadata,Upload-Offset,Upload-Defer-Length,Upload-Concat"
- "traefik.http.routers.wings.entrypoints=https"
- "traefik.http.routers.wings.rule=Host(`wings.example.com`)"
- "traefik.http.routers.wings.middlewares=wings-headscale-only,wings-upload-cors"
- "traefik.http.routers.wings.tls=true"
- "traefik.http.routers.wings.tls.certresolver=cloudflare"
- "traefik.http.services.wings.loadbalancer.server.port=8080"
networks:
proxy:
external: true
The wings-upload-cors middleware is important for browser file uploads. Pterodactyl uses resumable Tus-style upload requests, so Wings must allow and expose headers such as Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset, and Location. Without those headers, normal node checks can pass while plugin, modpack, or world uploads fail with a generic browser network error.
Create required directories:
1
2
3
4
5
sudo mkdir -p /etc/pterodactyl \
/var/lib/pterodactyl/volumes \
/var/log/pterodactyl \
/tmp/pterodactyl \
/run/wings/machine-id
Start Wings:
1
2
cd /home/ubuntu/pterodactyl/wings
docker compose up -d
Wings Docker network
Wings creates and manages the game-server Docker network automatically. Avoid creating a separate Compose network with the same subnet.
A safe explicit network block in config.yml looks like this:
1
2
3
4
5
6
7
8
9
10
docker:
network:
name: pterodactyl_nw
driver: bridge
network_mode: pterodactyl_nw
interface: 172.27.0.1
interfaces:
v4:
subnet: 172.27.0.0/16
gateway: 172.27.0.1
Before choosing a subnet, check existing Docker networks:
1
2
3
4
docker network inspect $(docker network ls -q) \
--format '{{.Name}} {{range .IPAM.Config}}{{.Subnet}} {{end}}'
If Wings logs say the pool overlaps with another address space, choose an unused subnet and restart Wings.
Add private game allocations
In the Panel, add allocations on the node using the server tailnet IP.
Example:
1
2
IP: <server-tailnet-ip>
Ports: 25565-25600
This keeps game servers private. Clients must be connected to Headscale/Tailscale before joining.
If you want public players to join without the tailnet, you must expose the selected game ports publicly or use a public relay/proxy. Headscale-only access means private clients only.
Minecraft server notes
For Minecraft, avoid blindly choosing latest unless your Java image supports it.
Useful Pterodactyl Java images:
1
2
3
ghcr.io/pterodactyl/yolks:java_17
ghcr.io/pterodactyl/yolks:java_21
ghcr.io/pterodactyl/yolks:java_25
If the game exits with:
1
2
UnsupportedClassVersionError
class file version 69.0
then the server jar requires a newer Java runtime. Switch the server image to a newer Java yolk, then start again.
Minecraft also requires EULA acceptance. The first clean start may exit with:
1
You need to agree to the EULA in order to run the server.
Only set eula=true if you accept Mojang’s/Microsoft’s Minecraft EULA.
Steam game server examples
Pterodactyl is not limited to Minecraft. The same private-allocation pattern works for SteamCMD games, as long as the egg exposes the correct ports and the runtime image can actually start the dedicated server on your CPU architecture.
The important rule is the same as above:
1
2
3
Allocation IP: <server-tailnet-ip>
Client access: Headscale/Tailscale only
Public internet: no direct game-port exposure
For Steam games, keep a small internal checklist for each server:
1
2
3
4
5
6
7
8
9
10
Egg / nest
Docker image
Steam install app ID
Game / GSLT app ID, when required
Default map
Query port
Game port
RCON password
Architecture notes
Any mounted base game files
Never publish real RCON passwords, Steam login credentials, game tokens, or internal allocation IDs in public documentation.
Counter-Strike: Source private server
Counter-Strike: Source is a good example of a Source Dedicated Server running behind Pterodactyl with a tailnet-only allocation.
Typical settings:
1
2
3
4
5
6
7
8
9
Nest: Custom
Egg: Counter-Strike: Source
Image: ghcr.io/ptero-eggs/steamcmd:debian
Install app ID: 232330
Game / GSLT app ID: 240
Default map: de_dust2
Default port: 27015/tcp + 27015/udp
Query: A2S on the game port
Access: Headscale/Tailscale clients only
Example startup shape:
1
2
3
4
5
6
7
8
9
./srcds_run \
-game cstrike \
-console \
-port <SERVER_PORT> \
+ip 0.0.0.0 \
+map <SRCDS_MAP> \
+maxplayers <MAX_PLAYERS> \
+hostname "<SERVER_NAME>" \
+rcon_password "<RCON_PASSWORD>"
For a private test server, a Steam Game Server Login Token can stay empty. For public listing, create a GSLT using the game’s app ID and store it as a Pterodactyl variable, not in a public post.
ARM64 host workaround
On ARM64 hosts, SteamCMD may fail under amd64/i386 emulation. A practical workaround is:
- install game files with a native ARM64 tool such as DepotDownloader;
- place the installed files in the Pterodactyl server volume;
- start SRCDS through Wings using the normal amd64 game image under emulation;
- verify with an A2S query before handing the address to players.
Example volume path pattern:
1
/var/lib/pterodactyl/volumes/<server-uuid>/
Useful verification target:
1
2
3
4
5
6
Name: <server-name>
Map: de_dust2
Folder: cstrike
Game: Counter-Strike: Source
App ID: 240
Max players: <configured-player-limit>
Also set a CPU frequency hint when old Source tooling misdetects the host CPU under emulation:
1
CPU_MHZ=2000
That variable is not a performance booster. It is a compatibility hint for older dedicated-server tooling.
Call of Duty 4X private server
CoD4X can be managed in the same Pterodactyl Custom nest as other non-standard games. Keep the egg clean and avoid mixing base game assets, custom images, and install scripts in the same place.
Typical layout:
1
2
3
4
5
6
Nest: Custom
Egg: Call of Duty 4X - clean/latest
Image: <custom-cod4x-pterodactyl-image>
Base game mount: /mnt/gamefiles/cod4
Server volume: /var/lib/pterodactyl/volumes/<server-uuid>/
Access: Headscale/Tailscale clients only unless you intentionally publish the game port
Recommended pattern:
- keep original game files in a read-only host mount;
- keep server-specific configs, mods, and generated files in the Pterodactyl volume;
- keep RCON and private server passwords in Pterodactyl variables;
- avoid baking licensed game assets into a public Docker image;
- document only placeholders in public guides.
When an egg depends on mounted base files, label the mount clearly in the Panel so future operators know whether reinstalling the server is safe.
Minecraft plugin upload fallback
Browser uploads are convenient, but they are not the only way to place files in a server. If a plugin upload fails because of CORS, upload limits, or a browser timeout, you can place the file directly into the server volume as an operator fallback.
Example pattern:
1
2
sudo install -m 0644 ./ExamplePlugin.jar \
/var/lib/pterodactyl/volumes/<server-uuid>/plugins/ExamplePlugin.jar
Then restart the Minecraft server from the Panel and check the server log for plugin load errors.
This is useful for emergency fixes, but the normal user-facing path should still be the Panel file manager or SFTP over the tailnet.
Verification
Check Panel:
1
curl -I https://pterodactyl.example.com
Check Wings from the Panel container:
1
docker exec pterodactyl-panel curl -k https://wings.example.com/api/system
Unauthenticated Wings should return 401. Authenticated Panel/Wings checks should return 200.
Check CORS:
1
2
3
4
curl -k -i -X OPTIONS https://wings.example.com/api/system \
-H "Origin: https://pterodactyl.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization"
Expected header:
1
Access-Control-Allow-Origin: https://pterodactyl.example.com
Check upload preflight headers:
1
2
3
4
curl -k -i -X OPTIONS https://wings.example.com/api/servers/<server-uuid>/files/write?file=%2Fplugins%2Fexample.jar \
-H "Origin: https://pterodactyl.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization,Tus-Resumable,Upload-Length,Upload-Metadata,Upload-Offset,Content-Type"
Expected headers include:
1
2
3
Access-Control-Allow-Origin: https://pterodactyl.example.com
Access-Control-Allow-Headers: ... Tus-Resumable ... Upload-Length ... Upload-Metadata ... Upload-Offset ...
Access-Control-Expose-Headers: Location ... Tus-Resumable ... Upload-Offset ...
Check private SFTP listener:
1
ss -lntup | grep 2022
It should bind to the tailnet IP, not 0.0.0.0.
Check game port after starting a server:
1
ss -lntup | grep 25565
Operations runbook
Restart Panel stack:
1
2
cd /home/ubuntu/pterodactyl/panel
docker compose up -d
Restart Wings:
1
2
cd /home/ubuntu/pterodactyl/wings
docker compose up -d --force-recreate wings
Watch Wings logs:
1
docker logs -f pterodactyl-wings
List game containers:
1
docker ps -a --filter network=pterodactyl_nw
Inspect server files:
1
sudo find /var/lib/pterodactyl/volumes -maxdepth 2 -type f
Back up important state:
1
2
3
4
/home/ubuntu/pterodactyl/panel-data
/home/ubuntu/pterodactyl/database
/etc/pterodactyl/config.yml
/var/lib/pterodactyl/volumes
Troubleshooting
Node details work but the heart is red
The node index page runs a browser-side AJAX call to Wings. If CORS is wrong, the daemon can be healthy but the heart remains red.
Fix Wings CORS:
1
2
3
allowed_origins:
- https://pterodactyl.example.com
- http://pterodactyl-panel
Then recreate Wings.
File upload fails with Network Error or 502
Browser uploads touch both the public Panel and the private Wings route:
- the Panel nginx/PHP config must allow the file size and long enough request timeouts;
- Traefik in front of Wings must allow Tus/upload request headers;
- Traefik must expose Tus/upload response headers such as
Location,Tus-Resumable, andUpload-Offset; - the client browser must be connected to the tailnet if
wings.example.comresolves to a private tailnet IP.
If normal Wings reachability works but uploads fail, add the wings-upload-cors middleware shown in the Wings Compose section, mount the custom Panel nginx config shown in the Panel upload limits section, then recreate both containers.
1
2
3
4
5
cd /home/ubuntu/pterodactyl/panel
docker compose up -d --force-recreate panel
cd /home/ubuntu/pterodactyl/wings
docker compose up -d --force-recreate wings
Panel cannot resolve Wings
If the Panel container cannot resolve wings.example.com, add an internal override:
1
2
extra_hosts:
- "wings.example.com:<traefik-proxy-network-ip>"
Then verify:
1
docker exec pterodactyl-panel curl -k https://wings.example.com/api/system
Install fails with missing /run/wings/machine-id
If Wings is containerized, mount /run/wings into the Wings container and create the host runtime directory:
1
2
volumes:
- /run/wings:/run/wings
1
sudo mkdir -p /run/wings/machine-id
Then reinstall the server from the Panel.
Docker network pool overlaps
Wings creates its own game network. Do not pre-create a Compose network on the same subnet. Check existing networks and choose a free range.
Minecraft installs but crashes immediately
Check logs:
1
docker logs <server-container-id>
Common causes:
- Java runtime too old for the selected Minecraft version;
- EULA not accepted;
- not enough memory;
- wrong server jar name;
- unsupported architecture for a custom image.
Game clients cannot connect
Check:
- client is connected to Headscale/Tailscale;
- DNS resolves
wings.example.comor the game host to the tailnet IP; - allocation uses
<server-tailnet-ip>, not a public IP; - host firewall allows the port on the tailnet interface;
- the game container is actually running and listening.
Security notes
- Keep the Panel public only if it has strong admin credentials and ideally 2FA.
- Keep Wings private. It controls Docker and game runtime actions.
- Do not expose the Docker socket directly to the internet.
- Do not expose MariaDB or Redis publicly.
- Keep
wings.example.comDNS-only/private; do not Cloudflare-proxy it. - Bind SFTP and game allocations to the tailnet IP when using private-only access.
- Treat
/etc/pterodactyl/config.ymlas sensitive because it contains node tokens. - Keep public documentation generic and use placeholders for real domains, IPs, and tokens.
Service documentation map
This post connects to the rest of the homelab stack:
- Traefik reverse proxy for public Panel HTTPS and private Wings routing.
- Headscale control server for the private tailnet used by Wings, SFTP, and game allocations.
- Private Pi-hole DNS over Headscale for resolving private service names to tailnet IPs.
- Cloudflare Companion for public DNS automation and exclusions for private names.
- Portainer Docker management for inspecting Panel, Wings, and game containers.
- Jekyll documentation site for publishing service guides like this one.
- WatchParty service for another example of keeping media/game-like runtime ports private over Headscale.