Buy Me a Coffee

Buy Me a Coffee!

Friday, July 31, 2026

Setting Up a Palworld Dedicated Server (Or: Everything I Got Wrong Along the Way)

I run a homelab, I've got a Proxmox cluster with more hardware than any reasonable person needs, and my kid wanted a Palworld server. This felt like an easy weekend project. It was not an easy weekend project. But it was a genuinely useful one, because almost every mistake I made along the way was the kind of mistake that teaches you something about how your own infrastructure actually behaves - not just how you assume it behaves.

This post is the honest version of that journey, bugs and bad assumptions included.

Step one: SteamCMD doesn't just install on Debian 13

First stumble, and it happened before I'd even touched Palworld. apt install steamcmd on a fresh Debian 13 (trixie) box just fails - no candidate. SteamCMD lives in the non-free component, and it's also a 32-bit binary, so you need i386 architecture support enabled on what's otherwise a clean 64-bit system:

sudo dpkg --add-architecture i386
sudo sed -i '/^Components:/ s/$/ contrib non-free non-free-firmware/' /etc/apt/sources.list.d/debian.sources
sudo apt update
sudo apt install steamcmd

I initially tried chasing this by pointing apt at unstable, which is exactly the wrong move - trixie doesn't have an unstable suite to pull from, and all that did was generate a pile of warnings and waste twenty minutes. Lesson: when a package "doesn't exist," check which component it lives in before you start reaching for bigger hammers.

Auto-starting the server: systemd, not sysctl

Small but worth naming, because I hear people conflate these constantly (I did too, once, years ago): sysctl is for kernel parameters. What you want for "run this on boot and restart it if it dies" is a systemd unit:

[Unit]
Description=Palworld Dedicated Server
After=network.target

[Service]
Type=simple
User=palworld
Group=palworld
WorkingDirectory=/home/palworld/.local/share/Steam/steamapps/common/PalServer
ExecStart=/home/palworld/.local/share/Steam/steamapps/common/PalServer/PalServer.sh
Restart=on-failure
RestartSec=10
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

That LimitNOFILE bump matters - Palworld servers are known to want a higher open-file limit than the systemd default gives you.

The SMB mount that wasn't allowed to happen

I wanted to pull some save files in from a Samba share on my Proxmox host. Straightforward, I thought - install cifs-utils, mount the share, done. Except every mount attempt came back permission denied, even though smbclient proved the credentials were completely correct.

The tell was that dmesg itself failed with "Operation not permitted," even under sudo. That's the fingerprint of an unprivileged LXC container - it doesn't get the kernel capabilities mount.cifs needs, full stop, no matter how correct your credentials are. No amount of fiddling with vers=, sec=ntlmssp, or credentials file permissions was ever going to fix it, because the problem wasn't the mount command - it was the container.

The right fix was to stop trying to mount inside the container entirely: mount the CIFS share on the Proxmox host, then bind it into the container as a mount point:

# on the host
mount -t cifs //192.168.1.12/public/shared/PalWorld /mnt/palworld-share -o credentials=/etc/samba/creds-palworld

# then
pct set 100 -mp0 /mnt/palworld-share,mp=/mnt/palworld-share
pct reboot 100

No capability loosening on the container needed. This is one of those things where the actual fix is almost embarrassingly simple once you know it, but the error message gives you zero hint that "unprivileged container" is the root cause.

The UDP odyssey (this is the big one)

This is where most of my week went, and it's the part I want to actually walk through honestly, because I got it wrong more than once.

I only have one public IP and one exposed host through my DMZ, so I front everything with nginx and route by hostname. That works great for HTTPS - SNI does the routing for you. It does not work for a raw UDP game port, because there's no hostname to route on at the UDP layer. Palworld talks straight to an IP:port.

nginx's stream module handles this - it's a completely separate config block from the http one, living at the top level of nginx.conf, not inside it:

stream {
    upstream palworld_udp {
        server 192.168.1.90:8211;
    }

    server {
        listen 8211 udp;
        proxy_pass palworld_udp;
        proxy_timeout 60s;
    }
}

I got this working internally almost immediately. Externally, nothing. Classic "works on my machine" - except "my machine" was my own LAN, which is a much easier test than I gave it credit for.

Mistake #1: proxy_timeout 1s. My first pass at this config had a one-second timeout on the UDP proxy session. Fine on a LAN with near-zero latency. Fatal over the real internet, where a one-second gap between packets during the connection handshake is completely normal - nginx would tear the session down before the handshake even finished. Bumped it to 60s. Progress, but still not fixed.

Mistake #2, and the one that actually embarrasses me a little: proxy_responses 0. I'd set this thinking it was a reasonable default. It is not. That directive tells nginx's UDP proxy to not relay responses back to the client - it's meant for fire-and-forget protocols like syslog, where nothing is expected in return. Palworld is a request/response protocol through and through. With that setting in place, nginx would faithfully forward the client's packet to the game server, receive the game server's reply... and just drop it on the floor instead of sending it back out. I confirmed this with tcpdump - three lines of traffic (client in, forward to backend, reply from backend) and then silence where a fourth line should have been.

stream {
    upstream palworld_udp {
        server 192.168.1.90:8211;
    }
    server {
        listen 8211 udp;
        proxy_pass palworld_udp;
        proxy_timeout 60s;
        # no proxy_responses line - the default relays replies, which is what you want
    }
}

Once that was gone, the round trip actually completed. tcpdump is not optional equipment for this kind of debugging - without watching the packets literally stop at one specific hop, I'd have kept guessing at the router.

And a red herring worth naming, because it wasted real time: online "open port checker" tools are TCP-only. Running one against my UDP game port and seeing "closed" told me nothing useful - it wasn't even testing the right protocol. If you're debugging UDP, those tools are close to useless; tcpdump while attempting a real connection is the only test that actually tells you the truth.

The REST API and the setting that wouldn't stick

Palworld ships a REST admin API - player counts, kick/ban, clean shutdown, all over HTTP on port 8212. I wanted it for a simple status page. Enabling it should be a one-line ini edit:

RESTAPIEnabled=True
RESTAPIPort=8212
AdminPassword="something-reasonably-long"

I set it. Restarted the service. Confirmed the file was correct with grep. Still got Unauthorized (AdminPassword is empty) on every request, including with a perfectly valid password. Confirmed the ini's contents byte-for-byte with xxd in case of some invisible character - clean. No duplicate keys. No parse errors. The server was, somehow, just not reading a setting that was sitting right there in the file it had definitely loaded.

Turns out there's a second file: WorldOption.sav. Once a Palworld world has been generated, the server starts reading most world settings - apparently including AdminPassword - from this binary save file instead of the ini, and it silently overrides anything you put in PalWorldSettings.ini from that point forward. The ini edit was completely correct. It just wasn't being consulted anymore.

The fix: stop the server, delete WorldOption.sav, restart. That forces one clean read from the ini, and the server regenerates a fresh .sav from whatever's actually in the ini at that point. Worth knowing this isn't a permanent fix so much as a "you get exactly one clean boot" fix - change AdminPassword again later, and you'll be deleting that file again.

I would not have found this without just searching for the literal error message alongside "PalWorldSettings.ini" - sometimes that's still the fastest path, even after you've checked everything that seems checkable.

DMZ vs. port forwarding - the belated correction

I'd been running everything through my router's DMZ setting, pointed at my nginx box. It works, but it means every port on that box is exposed, whether anything's listening there or not. Once I had the actual list of ports something was really using - 80, 443, and 8211/udp - switching to explicit port-forward rules and turning DMZ off was a five-minute change that meaningfully shrinks what's actually reachable from the internet. I should have done this from day one instead of reaching for DMZ because it was the first thing I found.

The last gotcha: testing over cellular lies to you

Even after all of the above was fixed, I still saw intermittent "connection timed out" while testing from my phone's hotspot - mid-session, after successfully connecting and starting to build a character. Packet capture showed the server responding just fine, continuously, into total silence from the client side.

This one wasn't my infrastructure at all - it's carrier-grade NAT on the mobile network expiring the connection's NAT mapping during a quiet stretch (like sitting on a character-creation screen not sending anything). The server has no way to know the client's public-side mapping just vanished underneath it.

Confirming this took setting up a free WireGuard tunnel through an Oracle Cloud always-free instance and routing my test traffic through that instead of the cellular hotspot. WireGuard's PersistentKeepalive keeps its own NAT mapping alive regardless of game activity, which sidesteps the whole problem. Once I tested through the tunnel, everything was rock solid - confirming the server-side setup had been correct for a while, and cellular hotspots are just an unreliable way to validate a UDP game server.

What I'd tell past-me

  • Check which apt component a package lives in before assuming it's missing entirely.
  • sysctl and systemd are not the same thing, and confusing them wastes your own time.
  • Unprivileged LXC containers can't mount CIFS shares directly - mount on the host, bind into the container.
  • For UDP proxying: watch the packets with tcpdump, don't guess. TCP-only port checkers will actively mislead you.
  • proxy_responses 0 is not a safe default for anything request/response-shaped.
  • If a config value looks right in the file but the app insists otherwise, check whether there's a second source of truth (a save file, a cache, a database) that's silently taking precedence.
  • Test UDP game servers from a real home connection, not a cellular hotspot - carrier NAT will produce failures that have nothing to do with your setup.

None of these were exotic problems. Every single one had a boring, findable explanation once I stopped assuming and started watching the actual traffic. That's most of what debugging is, honestly - it's just occasionally really annoying to remember in the moment.