SnubTech Solutions — Wiki
Home Lab · Game Servers

Migrating Palworld off a Pi 5 onto real x86 hardware, and building a proper admin panel for it

What started as "the Pi is running out of headroom" turned into a full native migration, a custom Pterodactyl egg nobody else has built, a from-scratch web panel with per-person logins and a forced first-login password change, and a public Cloudflare Tunnel in front of all of it. This is the whole trail, including the wrong turns.

Hardware  Dell Optiplex 5070 (i5-9500, 16GB, native x86_64) OS  Debian 13 (trixie) Why  box64 emulation on the Pi hit a hard single-core ceiling as the world grew
OVERVIEW

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.
STEP 1

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.

i

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.

Before any raw filesystem surgery on a server's volume
sudo systemctl stop wings
# ...do the file work...
sudo systemctl start wings
STEP 2

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.

VariableValueWhy it's non-negotiable
IS_MULTIPLAYtrueUndocumented outside community trial-and-error. Required for community-list visibility at all.
QUERY_PORT27015Separate port used specifically for community-list discovery, independent of the game port.
REST_API_ENABLEDtrueNeeded by the monitoring dashboard and the custom settings panel below -- neither works without it.
CROSSPLAY_PLATFORMSSteam,Xbox,PS5,MacControls 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.

STEP 3

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.

  1. 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.
  2. 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.

STEP 4

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.

The only safe order
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.

STEP 5

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.
i

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.

STEP 6

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.

STEP 7

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.

The dispatch script's prepare/commit protocol
# 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.

STEP 8

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 bind 0.0.0.0 (IPv4 only), so that connection gets refused. Fix: route through an nginx variable in proxy_pass (forces nginx's own resolver, not libc, at request time) with resolver 127.0.0.11 ipv6=off;.
  • The exact same class of bug hit the Cloudflare Tunnel connector too -- it resolved localhost to ::1 and got a connection reset against an IPv4-only origin. Fixed by pointing the tunnel's origin URL at 127.0.0.1 explicitly instead of localhost.
i

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).

CHECKLIST

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