Why leave the Pi at all
Palworld's dedicated server binary is x86_64 only. On a Pi 5 that means running it through
box64, and Palworld's game tick is heavily single-core bound -- so emulation overhead
concentrates on one core no matter how many the Pi has. That's fine for a small, young world. It stops
being fine once the world save grows.
The actual trigger World save size grew roughly 7x in one week (168KB → 1.2MB
Level.sav), which alone was enough to push CPU from comfortable to saturated with only 1-2
concurrent players. This was never really a "how many players" problem -- it was an accumulated-world-size
problem hitting a fixed emulation ceiling.
Tuning BOX64_DYNAREC_SAFEFLAGS=0 to try to claw back performance made it worse
(288% CPU vs 145% at the default SAFEFLAGS=1), which confirmed the ceiling was structural,
not a tuning problem. A $140 Dell Optiplex 5070 off the used market removes the emulation layer entirely.
- Native, not emulatedNo box64 -- the game binary runs as intended, all cores available.
- Pterodactyl, not raw ComposeDeliberately re-adopted for this move -- official install path, not a community docker-compose image.
- Custom egg requiredEvery existing community egg for Palworld was missing settings this setup needs. See below.
Install Pterodactyl Panel and Wings natively
Both installed via Pterodactyl's official, tested install path -- PHP/nginx/MariaDB/Redis for the Panel, Wings as the native node daemon. Not documented step-by-step here since Pterodactyl's own docs cover this well; the parts worth calling out are the two gotchas below that their docs don't mention.
Passwordless sudo The deploy user needs broad sudo access for the automation this
setup eventually grows into (backups, restart orchestration, the settings panel below). Configuring
NOPASSWD:ALL up front in /etc/sudoers.d/ saves fighting this piecemeal later.
Wings' crash-detection will fight you
Wings has its own crash-detection/auto-restart logic that's independent of whatever you think
a container's desired state is. A raw docker stop or kill that bypasses Wings'
own API gets treated as an unexpected crash and silently auto-restarted -- including mid-file-swap, which
can generate a spurious fresh world if you're in the middle of replacing save data.
sudo systemctl stop wings
# ...do the file work...
sudo systemctl start wings
Build a custom egg (every existing one is missing this)
Confirmed, widely-documented ecosystem-wide gap: parkervcp/pelican-eggs, the official eggs.pterodactyl.io listing, and the Proton variant all lack support for the three variables below -- meaning a server built from any of them silently never shows up in the in-game community browser.
| Variable | Value | Why it's non-negotiable |
|---|---|---|
IS_MULTIPLAY | true | Undocumented outside community trial-and-error. Required for community-list visibility at all. |
QUERY_PORT | 27015 | Separate port used specifically for community-list discovery, independent of the game port. |
REST_API_ENABLED | true | Needed by the monitoring dashboard and the custom settings panel below -- neither works without it. |
CROSSPLAY_PLATFORMS | Steam,Xbox,PS5,Mac | Controls which platforms can see/join. Also missing from stock eggs. |
The egg's startup script templates a fresh PalWorldSettings.ini on first boot, then
sed-patches a specific set of fields from Panel-stored server variables on every
subsequent start -- server name/description, both passwords, public IP/port, RCON, REST API, and
crossplay platforms. That last part matters a lot later; see the callout in the settings-panel section.
Migrating the world save (the real debugging saga)
Symptom: logging in on the new server dropped straight into character creation instead of loading the existing character. Three wrong turns before finding the real cause.
- Suspected a "hot copy" (copying save files while the old server was still live) causing file-level inconsistency. Ruled out with a from-stopped, guaranteed-consistent re-copy -- same symptom.
- Tried pinning the new server down to the old server's game version by copying its entire install over. Backwards: the player's own Steam client had already auto-updated past that version, so the client flat-out rejected the older server as incompatible.
The actual fix A game-version mismatch, not save corruption. The old server was on
an older build than the player's client had already auto-updated to; the older-format character data
silently failed to deserialize under the client's newer engine expectations -- no error, just a
character that looked freshly created. Fix: update the old server forward to match the client
(flip AUTO_UPDATE_ENABLED on temporarily, let it patch, revert), have the player actually
log in and out once there to let the engine properly migrate the save, then snapshot that known-good
result and push it to the new server.
Lesson for next time: if a save fails to load correctly after moving Palworld between servers or
updating either side, check game version match first (docker logs <container> | grep
"Game version" on both sides) before suspecting file corruption.
PalWorldSettings.ini quirks that will bite you
It gets silently overwritten unless you stop first
Palworld's own process writes its in-memory settings back into the ini file during shutdown. Edit the
ini while the container is still running -- including via docker restart, which is
stop-then-start as one motion -- and the edit gets silently clobbered: it briefly lands on disk, then
the old process's shutdown phase overwrites it with the stale in-memory value a moment later. No error,
no warning, the file just quietly reverts.
sudo systemctl stop wings
sudo docker stop <container> # wait for it to fully stop
# now edit the ini
sudo docker start <container>
sudo systemctl start wings
Difficulty=Hard silently overrides your rate multipliers
Setting a named Difficulty preset (Casual/Normal/Hard) overrides the individual rate
settings with that preset's own bundled values at runtime -- even though the individual
multiplier fields in the ini keep showing whatever you last set them to. The file looks like your
custom values took effect; they didn't. Set Difficulty=None to actually get independent
control over each rate.
Two fields are misspelled -- in the game itself
PlayerStomachDecreaceRate, PalStomachDecreaceRate,
PlayerStaminaDecreaceRate, and PalStaminaDecreaceRate all spell "Decrease" as
"Decreace" in Palworld's own ini schema. Not a typo to fix -- the parser only recognizes the misspelled
key, so correcting the spelling silently makes the setting a no-op.
13 fields will never stick, on this setup specifically
This custom egg's startup script (see above) re-applies ServerName,
ServerDescription, both passwords, PublicIP, PublicPort,
RCONEnabled/Port, RESTAPIEnabled/Port,
CrossplayPlatforms, ServerPlayerMaxNum, and bIsMultiplay from
Panel-stored variables on every container start. A direct ini edit to any of these appears to
succeed -- the write lands, the server boots clean -- then reverts the moment the container restarts,
because that's exactly when the startup script re-applies the Panel values. Change these in the
Pterodactyl Panel's server configuration instead.
Discord notifications
Two independent pieces, both custom (the egg has no Discord support built in):
- Join/leave pingsA systemd service tailing
docker logs -f, greps for join/leave log lines, posts an embed per event. - Hourly + on-demand statusPlayer count, uptime, load average, container CPU/memory, color-coded by severity, posted via cron and 5 minutes after any join.
Cron runs as root Root has its own separate SSH key store and doesn't use a regular
user's default key -- pass an explicit -i key path in any cron-invoked script that SSHes
elsewhere, and separately establish root's own host-key trust before the cron version will work.
Unprivileged shell globs against root-owned directories silently expand to nothing
A pattern like ls /root-owned-dir/*.bak-* run as a non-root user expands the glob
before sudo ever runs, using the calling shell's own (insufficient) permissions --
so it silently matches nothing rather than erroring. Use sudo find /dir -name "pattern"
instead, which does its own traversal as root.
Monitoring dashboard
Deployed ghcr.io/rnz01/palworld-server-dashboard (open source) for live FPS/uptime/player
monitoring, chat, and announcements -- reaches the game's REST API over the internal Docker network, no
need to expose port 8212 to the host at all.
Missing data volume caused a real bug later The original deployment never mounted
the dashboard's /app/data directory to a persistent volume, so its own login password
store was ephemeral to the container's writable layer. A restart silently reset the panel password back
to its seed value while the browser kept sending the old one -- and because the dashboard's rate limiter
defaults to one shared bucket for all clients (unless it's told it's behind a trusted reverse
proxy), that single mismatch locked out every visitor with "Too many attempts." Fixed by adding the
volume and enabling RATE_LIMIT_TRUST_PROXY once a reverse proxy correctly overwrites the
client-IP headers (see below).
The dashboard's own built-in restart button doesn't work in this deployment and that's intentional -- it needs Docker-socket access to cycle the container, and the tradeoff of handing a third-party image full control over every container on the host wasn't worth it for a restart button when reliable restart paths already exist elsewhere.
A from-scratch settings panel, with real accounts
The dashboard covers monitoring; it doesn't cover editing all 118 PalWorldSettings.ini
fields, and its restart button is confirmed broken. Built a separate Next.js app specifically for this.
No Docker-socket access here either
Same reasoning as the dashboard. Instead, the app SSHes back into the host's own sshd using two
dedicated keypairs, each locked via authorized_keys command= to exactly one
forced command -- a read-only key that can only cat the ini, and an apply key forced to a
dispatch script accepting only prepare/commit/abort. Even a fully
compromised container can't get a shell out of either key.
# prepare: stop wings -> stop container -> backup -> return the flushed file
# commit: write new content via stdin -> start container -> start wings ->
# verify the three known-good boot log lines before reporting success
# abort: recovery path if commit never follows a prepare (closed tab, dropped
# connection) -- a cron watchdog self-heals this so the server is never
# left down because of a UI hiccup
118 fields, with real descriptions and guardrails
Every field ships a plain-English description of what it actually does, plus a red warning for anything genuinely risky (Hardcore mode, permanent Pal loss, server-performance knobs). The riskiest settings -- randomizers, egg timing, hardcore/death rules -- render grayed out behind an explicit Unlock button and sit at the bottom of their category, so they're never one accidental click away.
Real accounts, not a shared password
Scrypt-hashed per-user accounts (Node's built-in crypto, no extra dependency), signed
session cookies, and a forced password-change screen on first login. Two roles:
- AdminFull field editor, save changes, generate one-time passwords for other accounts.
- ViewerSees every field and slider (useful for brainstorming settings together) but Save is disabled and blocked server-side. IPs/ports/passwords come back redacted from the API, not just hidden in the UI. Can trigger a restart.
The "restart" action for a viewer reuses the exact same apply pipeline as a real settings save, just with an empty change-set -- the diff comes back empty, which routes to the same stop-backup-start recovery path a normal apply uses, so no separate restart code exists at all.
Going public: Cloudflare Tunnel + one shared login
The goal: a friend gets his own account, reachable from anywhere, monitoring plus a restart button, no IPs or passwords visible -- and exactly one login screen for the whole panel, not a separate password for the dashboard and another for the settings editor.
nginx as the single front door
A small nginx container takes over the host's port 3000 and fronts both apps: /settings
routes to the settings editor, everything else to the dashboard. A tiny static wrapper page (no
framework, nothing to hydrate) owns the bare / and shows two tabs swapping an iframe's
src between them.
Injecting a nav link directly into the dashboard's HTML doesn't survive Tried
sub_filter-injecting a floating link into the dashboard's own served page first. Next.js
App Router hydrates the entire document as one React root, and on hydration it reconciles
<body>'s children against what it actually rendered -- anything extra, script tags
included, gets silently wiped. The iframe-wrapper approach above sidesteps this entirely since neither
app's own DOM is ever touched.
One login governs both apps
Both apps happen to share an origin (same host:port, different paths), which means they share
localStorage. On a successful login through the settings editor's own auth, the browser
also seeds the dashboard's expected localStorage keys with whichever of its two passwords
matches the signed-in role -- so the dashboard tab never shows its own separate login. Signing out
clears both.
That alone isn't enough Someone could still reach the dashboard directly via its own
URL and hit its own separate (much weaker, shared) password, bypassing the unified login entirely. Closed
with nginx's auth_request module: every request to the dashboard's paths triggers a
sub-request to a purpose-built /api/auth/check endpoint (returns a real 401, unlike the
JSON-boolean endpoint the browser polls) before nginx will proxy it through at all.
Two real deployment gotchas, in case this pattern recurs
- Docker's embedded DNS returns both an A and AAAA record for every container on a
network, and a plain
upstream { server name; }block resolves via the OS resolver at config-load time, which can hand back the IPv6 address first -- but these containers bind0.0.0.0(IPv4 only), so that connection gets refused. Fix: route through an nginx variable inproxy_pass(forces nginx's own resolver, not libc, at request time) withresolver 127.0.0.11 ipv6=off;. - The exact same class of bug hit the Cloudflare Tunnel connector too -- it resolved
localhostto::1and got a connection reset against an IPv4-only origin. Fixed by pointing the tunnel's origin URL at127.0.0.1explicitly instead oflocalhost.
A stale DNS negative-cache entry will look like the tunnel is broken when it isn't
If a local resolver (Pi-hole, in this setup) queried the new hostname before its DNS record existed,
it can cache that NXDOMAIN and keep serving it even after the record is live everywhere else. Confirm
against a public resolver first (nslookup host 1.1.1.1) before assuming the tunnel itself
is the problem, then flush the local cache (pihole reloaddns, needs sudo).
Security, once this is actually public
What's already handled server-side, plus what's worth doing on the account/network side once a panel like this stops being LAN-only.
- Every privileged host action goes through SSH forced-command scoping, never a Docker-socket mount
- Direct access to the dashboard's own weaker login is closed off (nginx
auth_request) - Login endpoint is rate-limited at the edge (nginx
limit_req), before a guess ever reaches the app - Passwords are scrypt-hashed per account, never stored or logged in plaintext
- Sensitive fields (IPs, ports, passwords) come back redacted from the API for restricted accounts -- not just hidden in the UI
- Consider enabling Bot Fight Mode / a WAF ruleset at the Cloudflare edge, ahead of the origin entirely
- Make sure any account holder picks a real password on first login, not something trivial