Skip to content

Repository files navigation

PZ Panel

A self-hosted admin panel for Project Zomboid Build 42 dedicated servers.

Create servers from a web UI, start/stop them, watch the console live, and edit settings without touching a .ini file by hand. The panel runs in a container and manages each game server as its own sibling container.

Build 42 has been the default Steam branch since 42.20 went stable (29 July 2026), so no beta opt-in is needed — the panel installs app 380870 straight from SteamCMD.


How it works

┌─────────────────────────────────────────────────────────┐
│ Host Docker daemon                                      │
│                                                         │
│  ┌───────────────┐        creates / starts / stops      │
│  │   pz-panel    │─────────────────┐                    │
│  │  (Next.js)    │                 ▼                    │
│  │               │      ┌──────────────────────┐        │
│  │  /var/run/    │      │ pz-server-myserver   │        │
│  │  docker.sock  │      │  SteamCMD + PZ B42   │        │
│  └───────────────┘      │  UDP 16261/16262     │        │
│                         └──────────────────────┘        │
│                         ┌──────────────────────┐        │
│                         │ pz-server-other      │        │
│                         │  UDP 16263/16264     │        │
│                         └──────────────────────┘        │
└─────────────────────────────────────────────────────────┘

The panel talks to the host's Docker daemon over the mounted socket, so game servers are siblings of the panel, not children. They keep running when you restart or update the panel.

One image, two roles. The panel and the game servers run the same image. A container started with the default command is the panel; the panel creates game-server containers from that same image with the entrypoint overridden and running as uid 1000. A fresh install therefore pulls once — 342 MB rather than 391 MB across two images — and can create a server immediately, with nothing to build. Set PZ_SERVER_IMAGE to point game servers at a different image if you ever need the two decoupled.

Each server gets:

  • its own container, named pz-server-<slug>
  • its own game-files volume (pz-install-<slug>) so updates are incremental — at the cost of ~7 GB per server, since installs are not shared
  • an entrypoint override selecting the game-server role, and a healthcheck probing RCON
  • a data directory at $PZ_DATA_DIR/<slug> holding saves, configs, and logs
  • an automatically assigned port triple (game UDP, direct UDP, RCON TCP)

Requirements

  • x86_64 CPU. Project Zomboid only publishes an x86_64 Linux dedicated server — there is no ARM build, so Apple Silicon, Raspberry Pi, and ARM VPSes cannot run it. The panel detects this and says so rather than failing later.
  • Docker with Compose v2
  • ~4 GB RAM per server (Build 42 is hungrier than 41)
  • ~8 GB disk per server — the Build 42 dedicated server download is 7.2 GB, and each server keeps its own copy of the game files
  • Linux host, or WSL2 on Windows (see Windows and WSL2)

Setup

Three ways to run it. All three end at the same place: open the panel, paste the claim token from the logs, create your admin account.

1. Docker Compose, without cloning the repo

One file, one command.

mkdir pz-panel && cd pz-panel
curl -O https://raw.githubusercontent.com/0xjemm/pz-panel/main/docker-compose.yml
docker compose up -d
docker compose logs panel      # the claim token is printed here

Every setting in that file is a plain value — open it and change what you need. The two worth checking are TZ, which scheduled tasks use, and PZ_DATA_DIR, which decides where your worlds are stored.

2. Plain docker run

Same thing without Compose:

docker run -d --name pz-panel \
  --restart unless-stopped \
  -p 3000:3000 \
  -e PZ_DATA_DIR=/opt/pz-panel/servers \
  -e TZ=UTC \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v pz-panel-data:/app/data \
  -v /opt/pz-panel/servers:/opt/pz-panel/servers \
  0xjemm/pz-panel:latest

docker logs pz-panel           # the claim token is printed here

The PZ_DATA_DIR value and the third -v must be the same path. See PZ_DATA_DIR must match on both sides.

3. From the repository

Same compose file, plus the dev override and the source.

git clone https://github.com/0xjemm/pz-panel && cd pz-panel
docker compose up -d
docker compose logs panel

No sudo and no directories to create — the session secret is generated on first boot and Docker creates the data directory.

To keep machine-specific paths out of the repository, put them in docker-compose.override.yml. Compose loads it automatically and it is git-ignored:

services:
  panel:
    environment:
      PZ_DATA_DIR: /srv/zomboid
      TZ: Europe/London
    volumes: !override
      - /var/run/docker.sock:/var/run/docker.sock
      - panel-data:/app/data
      - /srv/zomboid:/srv/zomboid

!override replaces the base volume list rather than appending to it, so the default data directory is not mounted as well.

Building from source

Only needed when changing the code. Add the dev override, which swaps the published image for a local build:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

The override is separate rather than automatic on purpose. If the main compose file carried a build: section, Compose would still prefer the published image and your changes would silently do nothing.

Passing -f also stops Compose auto-loading docker-compose.override.yml, so if you keep local paths there, list it as well or they are ignored:

docker compose -f docker-compose.yml \
               -f docker-compose.override.yml \
               -f docker-compose.dev.yml up -d --build

Claiming the panel

Whichever route you took, the logs contain:

================================================================
  PZ Panel — first-run setup

  Claim token:  2209c4a0f961440d0562b155f7441da0
  ...

Open http://localhost:3000, paste the token, and create the admin account. You can create servers straight away — there is no second image to fetch.

Why the token

Without it, /setup is an unauthenticated account-creation endpoint: whoever reaches a freshly started panel first becomes its admin. On a shared network that is not necessarily you. Requiring a value that only appears in the container's logs proves the claimant controls the host.

The token is generated on first boot, stored at /app/data/setup-token (mode 0600) so it survives a restart, and deleted the moment an admin account exists. Failed attempts never consume it. If you lose it:

docker compose exec panel cat /app/data/setup-token

Starting a server for the first time downloads 7.2 GB from Steam — watch the console on the server's page; SteamCMD is quiet for long stretches while it preallocates.

Updating

Running a published image (routes 1 and 2):

docker compose pull && docker compose up -d

Building from source — rebuild rather than pull:

git pull
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

(Add -f docker-compose.override.yml if you keep local settings there.)

Pin a version instead of tracking latest if you would rather upgrade deliberately — 0xjemm/pz-panel:0.1.0. Published images are linux/amd64 only, because Project Zomboid ships no ARM dedicated server.

Database migrations run automatically on first use after an upgrade. Game servers keep running while the panel restarts, and pick up the new image the next time they are restarted from the panel.

PZ_DATA_DIR must match on both sides

This is the one setting that will bite you if you change it carelessly. The panel writes config files to $PZ_DATA_DIR/<slug> and passes that same path to the Docker daemon as a bind-mount source — where it is resolved on the host, not inside the panel container. The compose file therefore mounts the host path to the identical path inside the panel:

- ${PZ_DATA_DIR}:${PZ_DATA_DIR}

If you point PZ_DATA_DIR somewhere else, change it in .env only — both sides read the same variable and stay in sync.


Ports

Purpose Protocol First server Second server
Game UDP 16261 16263
Direct connect UDP 16262 16264
Steam registration UDP 8766 8768
Steam registration (2) UDP 8767 8769
RCON TCP 27015 27016
Panel web UI TCP 3000

Forward UDP, not TCP, for the game ports. Forwarding TCP is the single most common reason a server looks online but nobody can connect.

The Steam pair only matters if you tick Public listing — Steam uses it to register the server in the in-game browser. Direct connections never touch it.

Windows and WSL2

If you run Docker natively inside a WSL2 distro (not Docker Desktop), Windows forwards localhost into WSL for TCP only. Project Zomboid is UDP, so a Windows game client cannot reach the server on localhost:16261. Either:

  • connect to the WSL2 VM's IP (ip -4 addr show eth0) — works locally, but the address changes on restart and is useless to anyone outside the machine, or

  • switch WSL to mirrored networking, which fixes localhost and makes router port-forwarding work. In C:\Users\<you>\.wslconfig:

    [wsl2]
    networkingMode=mirrored

    Then wsl --shutdown. Needs Windows 11 and WSL ≥ 2.0.0.

Docker Desktop proxies published ports itself and is expected to handle UDP on localhost, but that path has not been verified here.

Ports are mapped 1:1 (host port == container port) on purpose. Project Zomboid advertises its own configured port to clients and to the server browser, so a remapped port produces a server that appears online and then refuses joins.


Console, players and configuration (RCON)

Each server page has four tabs: Overview, Console, Players, and Configuration.

  • Console — a real RCON terminal with command history (↑/↓) plus the live container log below it. help lists all 60 commands the server supports.
  • Players — who is online, refreshed every 15s, with kick, ban, ban+IP, and role assignment, plus a server-wide broadcast box.
  • Events — weather and world events. Rain with an intensity slider (1–100), thunderstorms with a duration in in-game hours, and stop controls. Helicopter and gunshot fire immediately; thunder and lightning need a player picked from the connected list.
  • Configuration — every server option, grouped and filterable. While the server is running these are read live via showoptions and applied instantly with changeoption; while stopped they are saved and applied on next start. Options that cannot take effect live (ports, Mods, Map) are tagged restart.

Changing a connected player's role disconnects them. The server's AntiCheatPermission check fires on the access-level change and kicks them, so the panel confirms first.

Scheduled tasks and backups

The Maintenance tab handles both.

Scheduled tasks use standard 5-field cron. Four types: restart (warns players first), backup, save, and broadcast. Each has a Run now button so you can test a schedule without waiting for it, and the last run's status and error are shown inline.

Set TZ. Containers default to UTC, so a task set for "4am daily" fires at 4am UTC. Put your own zone in .envTZ=Europe/London — or the Maintenance tab will tell you it is using UTC. The scheduler logs its timezone at startup.

Restart tasks broadcast a countdown before acting, 15/5/1 minutes by default. The scheduler wakes every 30 seconds; each warning and each run fires exactly once per occurrence.

Backups archive the world to panel-backups/ in the server's data directory as tar.gz. This is deliberately not the game's own backups/ folder — PZ writes there for BackupsOnStart and rotates it with BackupsCount, and sharing the directory would leave panel archives subject to its pruning.

A backup issues save over RCON first when the server is running, so the archive captures a flushed world. It is still a hot copy; the fully consistent option is to back up while stopped.

Restored files are chowned to uid/gid 1000 afterwards. The panel runs as root (it needs the Docker socket) while the game runs as 1000, so a directory the panel creates is otherwise unwritable by the server — which surfaces as a Permission denied crash inside WorldDictionary.init, not as a panel error.

Restore stops the server, snapshots the current world first, then extracts. It leaves the server stopped rather than auto-starting, so a bad restore does not disappear into a boot loop. If extraction fails, the previous world is put back.


Who owns the .ini

changeoption and the in-game admin panel both write directly to <slug>.ini. The panel regenerates that file on every start, so it merges rather than overwrites:

  1. shipped defaults
  2. whatever is already in the file — external edits survive
  3. panel overrides set on the Configuration tab
  4. options the panel always owns

That last group — ports, RCON password, join password, Mods, Map, MaxPlayers, PVP, Public — is always rewritten from the panel's database, because those have to stay consistent with the container's port mappings and volumes. Edit them on the Overview tab; the Configuration tab shows them read-only.

RCON is never exposed to your network

The RCON port is bound to 127.0.0.1 on the host, not 0.0.0.0. It is a full admin channel — anyone who can reach it and guess the password controls the server. The panel reaches it over a private Docker network (pz-panel-net) instead, addressing each server by container name.

Servers created before that network existed are attached to it automatically the first time the panel talks to them, so no manual repair is needed.

Events not exposed, and why

  • alarm needs an admin physically standing inside a building — it returns Not in a room from RCON every time, so a button for it could never work.
  • createhorde spawns real zombies next to a named player. Deliberately left out for now.
  • thunder / lightning claim in help that the username is optional. It is not over RCON — they return Pass a username. Hence the player picker.

chopper and gunshot report success even with nobody connected, since they centre on a random player. The panel says so rather than letting a no-op look like it worked.

Notes on PZ's RCON

Worth knowing if you extend this:

  • Long output is split across packets — help is ~5.9 KB. PZ does not implement the usual sentinel handshake, so the client treats a quiet gap as end-of-response.
  • Authentication returns two packets: an empty type-0 echo, then the real verdict as type 2 with id = -1 on failure. Reading only the first packet makes a wrong password look like a command that returned nothing.
  • There is no command that returns player coordinates, which is why a live world map needs a server-side Lua mod rather than RCON.

Server status

The badge distinguishes the container being up from the server being usable:

Badge Meaning
Initializing Container running; SteamCMD or world loading still in progress
Running Accepting players
Not responding Container up but RCON unreachable
Stopped Container exists but is not running

This comes from a healthcheck the panel sets on each game-server container, probing the RCON port — which starts listening at the same moment the server accepts players. It is set per container rather than baked into the image, because the same image also runs the panel, where a game-server readiness probe would be meaningless. Container state alone reports "running" the instant the entrypoint starts — minutes before anyone can connect, and up to an hour on a first install.


Stopping servers safely

Project Zomboid does not save the world on SIGTERM. Its start-server.sh launches the JVM without exec, and the JVM has no handler that flushes the map — a plain docker stop kills it mid-state and can corrupt chunks.

The panel therefore stops a server by writing the quit console command to the container's stdin, which triggers SaveAll and a clean exit (about 10 seconds on an idle server). docker stop is only used as a fallback if that fails.

If you manage a server container by hand, use:

echo quit | docker attach --sig-proxy=false pz-server-myserver

replacing myserver with the server's slug, shown under its name in the panel. Do not use docker stop.


Users and roles

The account created during setup is an admin. Admins can add more people from Users in the header, choosing a username, an initial password to hand over, and a role.

Admin Operator Viewer
See status, players, logs, mods
Start / stop / restart
Kick, ban, set in-game roles
Broadcasts, weather, events
Scheduled tasks
Create backups
Restore or delete backups
Edit settings and configuration
RCON console
Create or delete servers
Manage users

The split follows two lines. Reversible actions — starting a server, kicking someone, making it rain — are Operator work. Unrecoverable ones stay with admins.

The RCON console is admin-only despite looking like a convenience. It accepts setaccesslevel, additem and quit, so console access is equivalent to full control of the game server and, through quit, its availability.

Permissions are enforced in every server action, not just hidden in the UI. Hiding a button prevents a mistake; the check inside the action is what stops someone calling it directly. Both read the same table in src/lib/permissions.ts, so they cannot drift apart.

Nobody can demote or delete themselves, and the last admin cannot be demoted or removed — any of those would lock user management away with no recovery short of editing the database.

Login protection

Five failed attempts against a username lock it for 15 minutes. Where a proxy supplies a client address, 20 failures from one address lock it too.

The lockout message is identical whether or not the account exists, so it cannot be used to discover valid usernames, and it blocks the correct password as well — otherwise the lock would only be a speed bump.

Counters live in the database rather than memory, so restarting the panel does not clear a lockout. Direct LAN connections have no client address at all (Next exposes headers, not the socket), so unknown callers are not IP-limited rather than sharing one bucket, which would let a single attacker lock everyone out. The per-username limit is the control that stops password guessing.

Security

Mounting the Docker socket gives the panel root-equivalent control of the host. Anyone who can log into the panel, or exploit it, can start a privileged container and take over the machine. That is the standard tradeoff for a self-hosted container manager, but it is a real one:

Why the socket is equivalent to root, concretely

The Docker daemon runs as root. Anything that can talk to its socket can ask it to start a container that mounts the host's filesystem — -v /:/host — and then read or write any file on the machine as root, including /etc/shadow and /root/.ssh. Nothing about that is limited to the containers this panel created, and no capability dropping inside the panel container prevents it, because the work is done by the daemon rather than by the caller.

That is why the advice below is "do not expose it", not "use a strong password".

Finding and checking your socket

Most installs use /var/run/docker.sock. Rootless Docker does not — print the real path with:

docker context inspect --format '{{.Endpoints.docker.Host}}'

If it differs, change both sides of the socket line in docker-compose.yml. Then confirm the panel can reach it:

docker compose exec -T panel node -e \
  "new (require('dockerode'))().ping().then(()=>console.log('docker ok')).catch(e=>console.log('FAILED:',e.message))"

docker ok means the panel can manage servers. Anything else prints the reason, and the dashboard shows "Cannot reach the Docker daemon" with the same message.

  • Do not expose the panel directly to the internet. Keep it on a LAN or behind a VPN/reverse proxy with its own auth.
  • Use a strong admin password. There is currently one account and no rate limiting on login — the claim token protects first-run setup, not the login form.
  • If you serve the panel over HTTPS, set COOKIE_SECURE=true in .env.

To narrow the blast radius, put a docker-socket-proxy in front of the socket and point DOCKER_SOCKET at it, allowing only the container, image, and volume endpoints.


Mods

Build 42 changed the mod folder structure, so Build 41 mods do not generally work. Check each mod's Workshop page for B42 support before adding it.

A mod is identified by up to three different strings, and it needs an entry in every list that applies to it. A missing entry is silent — the mod simply does not load, with no error. All lists are semicolon-separated.

Field What it is Example
Workshop IDs Numeric ID from the Workshop URL 2392709985
Mod IDs ID from the mod's mod.info \Brita_2
Map folders The map's folder name (maps only) Bedford Falls

These are frequently all different strings for the same mod, and one Workshop item can contain several mod IDs, so the lists are often different lengths.

Build 42 mod IDs generally need a leading backslash (\ModID). Copy whatever format the mod's own page gives you rather than guessing — the panel passes the field through verbatim.

Mod Manager

The Mods tab checks a server's mod configuration and explains what is wrong rather than leaving you to work it out from a server that silently misbehaves.

It cross-checks the three lists against each other — the most valuable finding being Workshop items set but no mod IDs, which downloads every mod and enables none of them — and looks each Workshop item up on Steam to report:

  • Build 41 only — the item's Workshop tags list Build 41 but not Build 42
  • Build not declared — neither tag is present, so there is nothing to check against. It may work; the panel cannot tell
  • Wrong game — the ID belongs to another game entirely
  • Not on Steam — deleted, made private, or mistyped
  • Updated since start — the mod changed after the running server booted, so a restart is needed to load it

What this can and cannot tell you: the verdict comes from the author's Workshop tags, so it is a declaration rather than proof. A mod tagged Build 42 can still be broken, and one that is simply untagged shows as Build not declared rather than a green pass it has not earned. The check reliably catches one thing — a mod that says Build 41 and not Build 42.

Adding a mod takes a Workshop ID or the item's URL, looks it up with Steam, and shows the title, tags and build declaration before anything is saved. It reports rather than refuses: tags are often merely out of date, so blocking would stop you adding mods that actually work.

Verify against downloaded files is the check that can actually confirm a mod ID. Once the server has downloaded a Workshop item, the panel reads the mod.info files it left behind and compares them with your configuration. It reports mods that were downloaded but never enabled, map folders present but not listed, and configured IDs with nothing matching on disk — then offers to fill the fields in from what is really there.

That matters because a mod's ID lives inside its own mod.info and is routinely different from both the Workshop ID and the folder it sits in. Getting it wrong produces no error anywhere; the mod simply never loads.

The scan runs a short-lived read-only container against the server's game-files volume, using the panel's own image, so nothing extra is pulled and no mod archives are transferred.

Mods are edited on this tab, next to those checks. Lookups use Steam's public GetPublishedFileDetails endpoint, which needs no API key. If Steam is unreachable the list-consistency checks still run, since those catch the most common mistakes and need no network.

Ask server for updates runs checkModsNeedUpdate. That command is asynchronous — it writes its answer to the server log and in-game chat rather than returning it — so the panel points you at the Console tab instead of inventing a result.

Map mods

Map mods need the third field. The panel builds the Map= line for you and appends Muldraugh, KY last — the server reads the list left to right, so the vanilla map must always be at the end or it shadows everything after it. Never type it in yourself; if you do, the panel moves it to the end anyway.

Order matters for the rest: earlier entries win where two maps overlap.

The panel also regenerates <slug>_spawnregions.lua with an entry per map, so players can actually spawn on a modded map instead of only travelling to it. If a map mod ships no spawnpoints.lua, the server logs a missing-file warning — remove that map from the field if it happens.


Local development

npm install
npm run dev

The panel needs a reachable Docker socket even in dev. Set PZ_DATA_DIR and SESSION_SECRET in .env.local.

better-sqlite3 is pinned to v11 because v13's prebuilt binary requires glibc ≥ 2.33 and building it from source needs a C++20 compiler — neither is available on older hosts such as Ubuntu 20.04. The v11 prebuild works on both old hosts and the Debian Bookworm image the panel ships on.

Database migrations:

npm run db:generate   # after editing src/lib/db/schema.ts

Migrations are applied automatically on first DB access at runtime.


Project layout

src/
  app/                     routes (dashboard, server detail, auth, SSE APIs)
  components/              UI
  lib/
    actions/               server actions (auth, server CRUD + lifecycle)
    db/                    Drizzle schema + lazy SQLite handle
    pz/
      config.ts            generates <slug>.ini and SandboxVars.lua
      ports.ts             port allocation + slugify
    docker.ts              container lifecycle
    auth.ts                scrypt hashing + JWT session cookie
docker/
  pz-server-entrypoint.sh  game-server role of the shared image

Roadmap

Not built yet:

  • RCON console input (the plumbing is in place — each server already has an RCON port and generated password)
  • Player list, kick/ban from the UI
  • Full sandbox settings editor (the backend merges arbitrary sandbox keys already; only the defaults are exposed so far)
  • Scheduled restarts and backup management
  • Multiple admin accounts and roles

About

Self-hosted admin panel for Project Zomboid Build 42 dedicated servers — Docker-native, with RCON console, scheduled tasks, backups and mod checking

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages