* feat: add 7 Days to Die server support
* refactor(scripts): two table-driven families behind one entry script each
Plutainer had five "families" that were really four Call of Duty engines
plus a platform, and a separate entry script per engine. Adding 7 Days to
Die made the mismatch obvious: a SteamCMD game shares nothing with a
Quake-derived one, while a Plutonium T6 and a CoD4x server differ far
less from each other than either does from a Steam install.
There are now two families, and they are platforms:
cod servers Plutainer installs and runs from game files you supply
steam servers SteamCMD installs
Engine (plutonium/iw4x/alterware/cod4x, unity/srcds) drops to a field in
that family's game table, where it belongs. Each family gets one entry
script driven by that table, so the four CoD entry scripts collapse into
games/codentry.sh at 92 lines, and adding a game is a table row rather
than a new script. entrypoint.sh has two branches and gains none per game.
Per-engine behaviour is expressed as hooks resolved most-specific-first:
cod_<hook>_<game> -> cod_<hook>_<base game> -> cod_<hook>_<engine>
so a game inherits its engine and overrides only what genuinely differs.
The same mechanism serves both families, which removed fifteen one-line
pass-through functions on the Steam side.
Hook names are strings, so plutainer_require_hooks verifies the mandatory
ones resolve at startup and prints what it tried. That is not theoretical:
during this refactor a comma-separated suffix list was passed where an
array was wanted, and every server died with no output whatsoever until
plutainer_hook was made to say what it could not find.
The library splits along the same line — core/fs/cod/steam — so a CoD
change never requires reading the Steam helpers. Audited: zero
cross-family calls in either direction. Anything both needed (the symlink
helpers) moved to fs.sh. Renamed from *-config.sh, which was inaccurate
once the files held install logic and launch arguments.
Verified on all ten CoD games (T4/T5/T6 MP+ZM, IW5, IW4x, T7x MP+ZM,
CoD4x): launch commands byte-identical to the originals, correct config
symlinks per engine, health and RCON working.
* feat(protocols): A2S, Source RCON and telnet, split by protocol
pyquake3.py was the only wire protocol Plutainer spoke, which was fine
while every game was Quake-derived. The SteamCMD games are not: 7DTD has
a telnet console and no RCON at all, Source games speak Valve's RCON over
TCP, and none of them answer getstatus.
Protocols now live in scripts/protocols/, split by protocol rather than
by game, because the mapping is many-to-many — Quake3 covers seven CoD
titles, A2S covers four Steam games across three engines, and querying is
a different concern from administering.
healthcheck.sh picks its probe from the family table (STEAM_QUERY) rather
than a hardcoded branch, and keeps the same bar for every game: the server
must name a map it is running. A2S reports one, so "healthy" does not
degrade to "a TCP connect succeeded" for the new family.
rcon-cli keeps its name because that is what people search for, but is now
one small class per protocol behind a dispatch table. It also distinguishes
"this game has no remote console" from "it has one and you have it switched
off", and tells you which setting to change in the second case.
Both try loopback and then the container's own address. Source 1 servers
answer only on the latter: an identical A2S query times out on 127.0.0.1
and replies immediately on the container IP, with the server healthy
throughout. Rather than encode which engines behave which way, try both.
pyquake3.py moves to protocols/quake3.py with its GPL attribution intact.
* fix(logs): let a family opt out of rotation, and skip huge install trees
Rotation is copy-truncate, which is only valid against a writer that
opened its log with O_APPEND — true of every CoD engine, and the reason
the existing implementation is safe. A Unity dedicated server's -logfile
writer keeps its own offset instead, so truncating would leave a sparse
hole and the file's apparent size would snap straight back over the limit,
re-triggering rotation on every poll.
That has not been measured against a running 7DTD, so PLUTAINER_LOG_ROTATE
lets the SteamCMD family turn rotation off. Not rotating is the safe
failure — an unbounded log, which is what the game does unmanaged — rather
than a rotation loop copying gigabytes every two seconds.
PLUTAINER_LOG_PRUNE_DIRS keeps the poller out of a SteamCMD install, which
is tens of thousands of files that would otherwise be walked every two
seconds for one log that does not live there.
* build: add SteamCMD, keep STOPSIGNAL SIGKILL
SteamCMD is fetched but deliberately not bootstrapped at build time.
Running `steamcmd.sh +quit` in the image pulls a few hundred MB of Steam
client into $HOME that every Call of Duty user would then carry forever,
and 340 MB of Xvfb was removed for exactly that reason. It also does not
reliably prevent the first-contact failure it appears to fix, which is
handled with a retry instead.
Placed after everything the CoD families need, so adding or bumping it
never invalidates their layers.
STOPSIGNAL stays SIGKILL. A game with world state to flush cannot be
served by a signal that cannot be trapped, but changing the image default
would alter shutdown for seven servers already running in production to
benefit one new family. Those games ask for SIGTERM per service instead:
stop_signal: SIGTERM
stop_grace_period: 90s
Measured: 7DTD forwards the signal, saves, and exits 0 in 3.5s; the same
image with no stop_signal stops in 254ms as before. The hang case needs no
code — Docker sends SIGKILL itself when the grace period expires.
chmod now covers every *.sh by find rather than a hand-maintained list
that silently rots as scripts move between directories.
* feat(steam): add CS2, L4D2 and Half-Life 2: Deathmatch
Three more games through the SteamCMD family, chosen to stress the table
in different directions rather than to pad the list. Each needed a table
row and, at most, one hook.
HL2:DM is the plain srcds case. L4D2 shares its hooks entirely. CS2 shares
seed, configure, stage and admin with them and overrides only its launch
arguments, because Source 2 has no srcds_run wrapper.
L4D2 needs a two-phase install and it is not optional. Valve restricted
anonymous Linux installation of app 222860: every depot including the
9.5 GB content one is flagged windows, so a plain app_update on Linux
fails with "Invalid platform". This is an open upstream issue
(ValveSoftware/steam-for-linux#11522) that takes LinuxGSM down with it, so
"this used to work" is true and not a local fault. Pulling the content as
Windows and re-running as Linux overlays the native binaries depot on top.
STEAM_INSTALL_PLATFORMS expresses that generically. The result is a genuine
native server — srcds_linux, no Wine, reports "os: Linux Dedicated".
CS2 exposed a real bug in the config handling. link_configs refuses to
replace a real file at the engine path, because for the CoD families that
file is the strongest signal of user intent. Inside a SteamCMD install the
same signal means the opposite: the directory is Steam's, so a real file is
a depot default that the next update restores anyway. CS2 ships a 33-byte
game/csgo/cfg/server.cfg reading "// Defaults in server_default.cfg", so
the fan-out skipped it with a warning and the server ran on stock settings
while app/configs/server.cfg sat there looking correct. Measured before
and after: hostname went from "Counter-Strike 2" to the configured name,
and the name a server browser shows changed with it.
SteamCMD's first contact in a fresh container is also unreliable — it
downloads its own client, re-execs, and an app_update issued before that
settles fails with "Missing configuration". Measured on both 7DTD and
HL2:DM, with the next attempt succeeding, so installs are retried.
Seed configs are hand-written rather than vendored: Valve ships no
server.cfg for any of these. All three ship empty rcon_password and
sv_password, like every other seed here.
* docs: rewrite for two families and eleven games
The docs described five families and a script-per-game layout that no
longer exists, and the 7DTD material from the original PR assumed it was
a one-off rather than the first member of a family.
Reworked around the two-platform model, with per-game specifics where a
reader looks for them: disk footprints (CS2 is 67 GB and will exceed the
health grace period on first start), the GSLT and masterserver-token
equivalence, why clean shutdown is opt-in per service, and what the
depot-config takeover does.
Audited rather than assumed: all fourteen game tags now appear in the
README table, the games reference, the configuration reference and a
working compose example; every environment variable the code reads is in
the reference table; every script is described in CLAUDE.md; no stale
references to the deleted entry scripts remain; all internal doc links
resolve. That audit found two undocumented variables, now added.
* chore: remove the EXAMPLE-docker-compose.yml signpost
The file had already been reduced to a stub pointing at examples/, kept on
the theory that older guides and forum posts link to it. Nothing in the
repo references it any more: every doc that shows a compose file points at
examples/ directly, so the stub was carrying its own rationale and nothing
else.
Also drops the two places that still named it — its .dockerignore entry
(examples/ and *.md already cover everything it excluded) and its
paths-ignore entry in the publish workflow, which was suppressing builds
for a file that no longer exists.
---------
Co-authored-by: Keyboard Sped <93077330+CoreyUK@users.noreply.github.com>
10 KiB
Troubleshooting & FAQ
Find your symptom. Every entry is something that has actually happened.
Start here: docker logs <container>. Plutainer refuses loudly rather than looping, so the reason is usually the first [ERROR] line.
Starting up
The container is Up but nothing works, and the log ends with an error
That's deliberate. A configuration mistake holds the container instead of restart-looping, so the error stays readable at the end of the log. Fix it, then docker restart <container>. See Restart behaviour.
Config file not found
PLUTAINER_CONFIG_FILE names something that isn't in app/configs/. The error lists what was found, including case-insensitive matches — Server.cfg and server.cfg are different files.
Valid names per game are in Games. If you expected Plutainer to seed one, check you haven't set PLUTAINER_SKIP_SEED=true.
PLUTO_SERVER_KEY is not set
T4, T5, T6 and IW5 need a key from https://platform.plutonium.pw/serverkeys. IW4x, T7x and CoD4x don't.
mkdir: cannot create directory … Permission denied
The container runs as UID 1000 and can't write to your app directory:
sudo chown -R 1000:1000 ./your-server-dir
Usually caused by creating the directory with sudo.
The container refuses to start and mentions v1
Your volume or your variables are from the old image. See MIGRATION.md — one docker run migrates the volume.
First start is taking forever
Expected. IW4x downloads 1–2 GB, Plutonium ~500 MB, T7x a few MB, CoD4x nothing. docker logs -f shows progress. The healthcheck won't judge for five minutes.
Server runs but nobody can play
T5: log loops Early out of maprotate, waiting for WAD!
Error: Unable to fetch file online_tu14_mp_english.wad. (10ms)
Your Plutonium key isn't valid. T5 pulls that asset through Plutonium's authenticated service, so an invalid or placeholder key means no map ever loads — the client says "Server is not running a map". T4, T6 and IW5 tolerate a junk key; T5 does not. Use a real key.
Players are asked for a password, or can't join
Check g_password in app/configs/<your>.cfg — it should be empty:
set g_password ""
Older CoD4x volumes shipped my_connect_password from upstream. Seeds never overwrite an existing file, so a volume created before that was fixed still has it. Blank it and restart.
The server doesn't appear in the server list
- CoD4x:
Server needs to provide a valid token in cvar sv_authtoken— you have no masterserver token, so the server never registers and will not show up in the browser at all. Players can still connect directly by address. Get a 32-character token from http://cod4master.cod4x.ovh and setPLUTAINER_COD4X_AUTH_TOKEN. - Plutonium:
Could not send heartbeat to nix! … 401means the key was rejected. Playable locally, not listed.
steam_api.so not found (CoD4x)
Harmless on a dedicated server. Ignore it.
Health and monitoring
docker ps says unhealthy but the server seems fine
The healthcheck requires a loaded map, not just a live process. A server sitting in a map-rotation loop is unhealthy on purpose. Run it by hand to see the reason:
docker exec <container> ./healthcheck.sh
It does not need an RCON password — if you're chasing a password problem, that's not this.
Unhealthy containers aren't restarting
Docker only restarts containers that exit. An Up-but-unhealthy container needs Auto Heal.
An external query tool sees nothing, but the container is healthy
T5 and T6 only answer status queries from whitelisted addresses, and T5 effectively only from localhost. The in-container healthcheck is unaffected. For T5/T6, add the querying host to PLUTAINER_RCON_WHITELIST.
RCON and IW4MAdmin
rcon-cli says it can't parse rcon_password
None is set — that's the default. Set PLUTAINER_RCON_PASSWORD or edit the config (RCON). Setting it through PLUTAINER_EXTRA_ARGS does not work; Plutainer can't read it back.
IW4MAdmin stops responding to in-game commands after a while
Check the size of the game log. A large enough log kills logging permanently: the engine's write fails, the buffered data is dropped, and nothing retries or reopens the file. RCON and the webfront keep working, but IW4MAdmin reads in-game !commands from the log, so it looks dead in game. Reported on CoD4x past ~1 GB.
Plutainer rotates game logs at 64 MB by default, so this shouldn't happen — unless rotation was turned off (PLUTAINER_LOG_MAX_SIZE=0) or the log grew before you updated. Restarting the container reopens the log and restores logging immediately.
No rconpassword set on server or password is shorter than 8 characters
CoD4x specifically requires an RCON password of 8 characters or more. Shorter ones are silently refused, which reads like a wrong password. Lengthen it and restart.
IW4MAdmin connects to some games but not T5/T6
Not monitoring server due to uncorrectable errors
NetworkException: Reached maximum retry attempts to send RCon data
Those two gate unauthenticated getinfo on the RCON whitelist, and IW4MAdmin opens with getinfo. Plutainer whitelists the Docker gateway automatically, so check:
- Is the server actually running a Plutainer version with that support?
- Is IW4MAdmin on another machine? Add its address to
PLUTAINER_RCON_WHITELIST. - Did you set
PLUTAINER_RCON_WHITELIST_GATEWAY=false?
Full explanation: IW4MAdmin.
IW4MAdmin dies at startup after a fresh deploy
It aborts if a configured server doesn't answer, and IW4x can take minutes to download on first run. Use depends_on: condition: service_healthy (example).
IW4MAdmin is connected but sees no chat, joins or in-game commands
The classic cause: ManualLogPath points at a symlink. IW4MAdmin decides whether to read by comparing the log's size against last time, and .NET reports a symlink's size as the length of the link text — a constant. The difference is never positive, so it never reads a line, and it logs no error because nothing failed.
Mount the log file rather than a directory, which makes Docker resolve Plutainer's logs/ symlink at mount time:
- ./t6zm-1/logs/games_zm.log:/gamelogs/t6zm-1/logs/games_zm.log:ro
Full explanation in IW4MAdmin.
IW4MAdmin missed events right after a log rotation
Fixed as of the rotation feature: Plutainer writes a marker line immediately after truncating, so the log is never observed at zero bytes. IW4MAdmin treats a zero offset as "no position yet" and re-syncs instead of reading, which used to swallow the first batch of events after each rotation. Verified: the first map change after a rotation is now read.
Configs
My config edits don't take effect
Restart the container — the game reads its config at startup. Confirm you edited app/configs/, not a copy under runtime/.
An image update overwrote my config
It doesn't. Seeding uses "copy only if absent", so an existing file is never touched. The flip side: fixes to the bundled configs only reach new volumes. If a seed default changed and you want it, edit your file or delete it and restart to be re-seeded.
I want my configs at the engine path instead
PLUTAINER_USE_RAW_CONFIGS=true (details).
What are the // [Plutainer] comments in my config?
Three container-specific changes: passwords blanked, placeholder rconWhitelistAdd entries commented out, rcon_localhost_bypass forced on. Reasons in Games. Edit or revert them freely — they're your files.
Architecture
PLUTAINER_GAME=iw4x refuses to start on arm64
Known and expected — upstream's launcher doesn't build for arm64 right now (iw4x/launcher#76). It resumes automatically when upstream is fixed. Everything except CoD4x works on arm64.
CoD4x refuses to start on arm64
Permanent, not a bug: its server is a 32-bit x86 Linux binary and cannot execute on arm64.
A SteamCMD game refuses to start on arm64
Expected: SteamCMD is not present in this image. Valve ships SteamCMD as x86_64 only, so 7dtd and hl2dm need the amd64 image.
SteamCMD games
First start takes forever / goes unhealthy before it finishes
7DTD is about 17 GB. On a slow connection that outlasts the five-minute health grace period, so the container is marked unhealthy while it is still downloading, then recovers by itself. Watch docker logs -f rather than docker ps.
Failed to install app '<id>' (Missing configuration)
SteamCMD's first contact in a fresh container is unreliable — it downloads its own client, re-execs, and an update issued before that settles fails this way. Plutainer retries three times, which has always been enough. If all three fail, the message is real: check the app ID and that the disk has room.
Failed to install app '<id>' (Invalid platform)
That app has no Linux depot available to anonymous SteamCMD. Nothing Plutainer can do. Left 4 Dead 2 is the notable case — see Games.
My world wasn't saved when I stopped the container
Add stop_signal: SIGTERM and stop_grace_period: 90s to that service. The image's default is SIGKILL, which is instant and right for the Call of Duty engines but gives a world-based game no chance to save. See Healthcheck.
The disk filled up
The SteamCMD install lives in your app volume, not the image — 7DTD alone is ~17 GB. Point the volume somewhere with room; app/runtime/steam/<game>/ is the large part, and it can be deleted and re-downloaded without losing worlds or configs.
no matching manifest for linux/arm64
You pulled :edge, which is amd64-only. Use :latest. If :latest is also amd64-only, an arm64 build failed — check docker manifest inspect ghcr.io/ayymoss/plutainer:latest.
Still stuck?
Discord: https://discord.gg/JekrGGWAUg. Bring docker logs <container> output, your compose file with secrets removed, and which game you're running.