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.
┌─────────────────────────────────────────────────────────┐
│ 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)
- 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)
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.
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 hereEvery 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.
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 hereThe PZ_DATA_DIR value and the third -v must be the same path. See
PZ_DATA_DIR must match on both sides.
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 panelNo 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.
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 --buildThe 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 --buildWhichever 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.
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-tokenStarting 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.
Running a published image (routes 1 and 2):
docker compose pull && docker compose up -dBuilding 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.
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.
| 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.
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
localhostand makes router port-forwarding work. InC:\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.
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.
helplists 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
showoptionsand applied instantly withchangeoption; 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
AntiCheatPermissioncheck fires on the access-level change and kicks them, so the panel confirms first.
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.env—TZ=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.
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:
- shipped defaults
- whatever is already in the file — external edits survive
- panel overrides set on the Configuration tab
- 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.
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.
alarmneeds an admin physically standing inside a building — it returnsNot in a roomfrom RCON every time, so a button for it could never work.createhordespawns real zombies next to a named player. Deliberately left out for now.thunder/lightningclaim inhelpthat the username is optional. It is not over RCON — they returnPass 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.
Worth knowing if you extend this:
- Long output is split across packets —
helpis ~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 = -1on 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.
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.
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-myserverreplacing myserver with the server's slug, shown under its name in the panel.
Do not use docker stop.
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.
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.
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".
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=truein.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.
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.
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 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.
npm install
npm run devThe 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.tsMigrations are applied automatically on first DB access at runtime.
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
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