docs: split the README into task-focused pages, rework the examples

The README had grown to 388 lines of reference material and was reported as too
difficult to follow. It is now a 72-line landing page - a working compose block,
the game table, and an index - with the detail moved to whichever page owns it:

  docs/quickstart.md            first server, start to finish
  docs/games.md                 per game: files, keys, config names, quirks
  docs/configuration.md         environment variable reference
  docs/volumes-and-configs.md   volume layout, config symlinks, logs
  docs/rcon.md                  passwords, rcon-cli, who may send RCON
  docs/iw4madmin.md             sidecar setup, parsers, the whitelist trap
  docs/healthcheck.md           what healthy means, restarts, autoheal
  docs/troubleshooting.md       symptom-first FAQ

Nothing was dropped; the length came out of duplication. The troubleshooting
page is deliberately symptom-first, and every entry is a failure that actually
happened - the T5 key that presents as a broken server, the CoD4x RCON password
silently refused for being under 8 characters, the IW4MAdmin whitelist.

Examples are split by scenario under examples/, replacing the single 228-line
file: single-server, per-game (a block for all seven), multi-server,
with-iw4madmin, and env.example for secrets. EXAMPLE-docker-compose.yml stays as
a signpost because forum posts link to it.

The IW4MAdmin example and docs now mount the log FILE rather than the app
directory. Pointing IW4MAdmin at Plutainer's logs/ symlink means it silently
ingests nothing: it decides whether to read by comparing the file's size, and
.NET reports a symlink's size as the length of the link text, so the log never
appears to grow. Mounting the file makes Docker resolve the symlink at mount
time. Verified across all eleven servers.

docs/ and examples/ are excluded from the build context.
This commit is contained in:
Amos 2026-08-15 17:22:30 +01:00
parent d117dede7d
commit df24be7446
No known key found for this signature in database
18 changed files with 1527 additions and 557 deletions

View file

@ -1,2 +1,5 @@
EXAMPLE-docker-compose.yml
.github/
examples/
docs/
*.md

View file

@ -4,7 +4,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Plutainer is a Docker image for running Plutonium, IW4x, and Alterware dedicated game servers (Call of Duty titles: T4/WaW, T5/BO1, T6/BO2, IW5/MW3, IW4x/MW2, T7x/BO3). It uses Wine on Arch Linux to run the Windows game server binaries, configured entirely via environment variables.
Plutainer is a Docker image for running Plutonium, IW4x, Alterware and CoD4x dedicated game servers (Call of Duty titles: T4/WaW, T5/BO1, T6/BO2, IW5/MW3, IW4x/MW2, T7x/BO3, CoD4x/CoD4). It uses Wine on Arch Linux to run the Windows game server binaries, configured entirely via environment variables. CoD4x is the exception: upstream ships a native Linux server, so it runs directly with no Wine (and is therefore amd64-only — the binary is 32-bit x86).
## Documentation layout
User-facing docs are split by concern; `README.md` is a landing page and should stay short. Put new material in the page that owns the topic rather than growing the README:
| File | Owns |
| --- | --- |
| `README.md` | What Plutainer is, supported games, links out |
| `docs/quickstart.md` | First server, start to finish |
| `docs/games.md` | Per-game: required game files, keys, config names, ports, quirks, arch support, bundled configs |
| `docs/configuration.md` | Environment variable reference |
| `docs/volumes-and-configs.md` | Volume layout, config symlink flow, raw mode, logs, permissions |
| `docs/rcon.md` | Passwords, `rcon-cli`, who may send RCON |
| `docs/iw4madmin.md` | Sidecar setup, parser table, the T5/T6 whitelist explanation |
| `docs/healthcheck.md` | What healthy means, restart behaviour, autoheal |
| `docs/troubleshooting.md` | Symptom-first FAQ |
| `examples/*.yml` | Copy-paste compose files |
| `MIGRATION.md` | v1 → v2 |
`docs/` and `examples/` are excluded from the build context in `.dockerignore`.
## Build & Test
@ -45,11 +65,13 @@ amd64 is required. **arm64 is best-effort** (`optional: true` + `continue-on-err
**Current state: iw4x does not work on arm64.** The source build fails and the image ships `/home/plutainer/.plutainer/iw4x-launcher.unavailable` instead of the binary. Plutonium (t4/t5/t6/iw5) and Alterware (t7x) are unaffected on both architectures. Background: iw4x/launcher#76.
**cod4x is amd64-only by nature**, not by build failure: upstream's native Linux server is a 32-bit x86 ELF, which cannot execute on arm64 at all. `cod4xentry.sh` refuses with an explanation.
**Degradation.** `Dockerfile.arm64` lets the launcher build fail, writing the `.unavailable` marker in its place. Stage 3 copies `/out/` as a **directory**, not a file — a file `COPY` of a missing path aborts the build, which is the coupling being avoided; the marker also keeps the directory non-empty. `iw4xentry.sh` tests `-x` on the binary and `hold_indefinitely`s if absent. That is a capability check, not an arch check, so iw4x starts working again as soon as an image ships a binary, with no code change.
**`IW4X_LAUNCHER_REF` is load-bearing — do not remove it.** Both Dockerfiles fetch the launcher inside a `RUN`, so the layer cache key never changes on its own and upstream changes stay invisible until eviction. CI resolves the upstream ref (release tag for amd64, commit SHA for arm64) and passes it purely to key the cache. This matters most on arm64, where the `|| { … }` fallback makes the step exit 0 on failure, so a failed build caches as a success and would keep shipping the marker indefinitely.
**Both Dockerfiles carry their own seed-configs block — keep them in sync.**
**Both Dockerfiles carry their own seed-configs block — keep them in sync.** Both are now a single `COPY seed-configs/`; the files are vendored (see below), not fetched at build time.
## Architecture
@ -57,9 +79,19 @@ Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`. All e
1. **`entrypoint.sh`** — Top-level dispatcher. Sources `game-config.sh`, calls `detect_game_type` (requires `PLUTAINER_GAME`), `check_volume_version` (refuses v1 volumes), then `exec`s the family-specific entry script. On any failure: `hold_indefinitely` (sleep infinity) instead of exiting, to avoid restart loops.
**T5/T6 gate unauthenticated queries on the RCON whitelist — this is what makes or breaks IW4MAdmin.** IW4MAdmin's T5/T6 parsers open with `getinfo` (unlike its BOIII, T4 CO-OP/ZM and TeknoMW3 parsers, which set `RConGetInfo = null`). On T5/T6, `getinfo`/`getstatus` are refused off-loopback unless the sender is whitelisted, and an *empty* whitelist does **not** mean "everyone" for those queries, though it does for RCON commands. Measured on T6 zombies, off-loopback:
| whitelist state | RCON | `getinfo` |
| --- | --- | --- |
| upstream placeholder IPs | blocked | blocked |
| empty | works | blocked |
| gateway whitelisted | works | works |
So a sidecar admin tool completes the RCON handshake (`rcon <pw> version`, `sv_running` both answer) and then fails on repeated unanswered `getinfo`, aborting startup. The sender's address is this container's *bridge gateway*, which Docker assigns at run time, so it cannot be baked into a config file — `resolve_rcon_whitelist_args` detects it and appends `+rconWhitelistAdd`. T4 and IW5 answer queries regardless and are deliberately skipped, since adding an entry would newly restrict their RCON for no gain; iw4x and t7x have no such command.
2. **`plutoentry.sh`** — Plutonium server entrypoint. Symlinks game files from the read-only gamefiles mount, runs `plutonium-updater`, seeds bundled configs into the SOT location, fans out symlinks from `app/configs/` to the engine and (if `PLUTAINER_MOD` is set) the mod config dir, calls `ensure_config_present` (auto-lift + refusal), then `launch_game wine ...` (30s exit-throttle wrapper).
3. **`iw4xentry.sh`** — IW4x server entrypoint. Same shape: symlinks game files, runs `iw4x-launcher`, seeds bundled configs, fans out config symlinks (engine + optional mod dir), validates, `launch_game wine iw4x.exe`. Seed bundle is `iw4x/iw4-server-configs` with `cfg_root_rel="userraw"`, so its top-level `userraw/*.cfg` lift into `configs/` and the playlist `*.info` files stay under `runtime/gamefiles/userraw/`. The Dockerfile appends a stock-MW2 `sv_maprotation` to the seed's `server.cfg`/`serverlan.cfg` — upstream ships it commented out, unlike every other seed here, which would leave `+map_rotate` with nothing to load on a first run.
3. **`iw4xentry.sh`** — IW4x server entrypoint. Same shape: symlinks game files, runs `iw4x-launcher`, seeds bundled configs, fans out config symlinks (engine + optional mod dir), validates, `launch_game wine iw4x.exe`. Seed bundle is `iw4x/iw4-server-configs` with `cfg_root_rel="userraw"`, so its top-level `userraw/*.cfg` lift into `configs/` and the playlist `*.info` files stay under `runtime/gamefiles/userraw/`. `tools/refresh-seeds.sh` appends a stock-MW2 `sv_maprotation` to the seed's `server.cfg` — upstream ships it commented out there, unlike every other seed here, which would leave `+map_rotate` with nothing to load on a first run. (`serverlan.cfg` already carries an active one upstream, and the guard skips it; `partyserver*.cfg` run lobby mode off playlists and are left alone.) The append is committed into the vendored file, so what ships is what is in the repo.
**The launcher has no `--path` flag.** It canonicalises `/proc/self/exe` and uses its own directory as the installation root, ignoring cwd. The entrypoint therefore `cp`s the binary into `runtime/gamefiles/` and runs it from there, putting the install root inside the volume. It must be a real copy — `canonical()` resolves a symlink back to `.plutainer/`, an image layer, and the ~800MB it fetches would be re-downloaded on every container recreate.
@ -77,9 +109,23 @@ Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`. All e
A launcher failure is fatal only on first run (no `iw4x.exe` yet); otherwise it warns and starts the existing install.
4. **`alterentry.sh`** — Alterware (T7x/BO3) entrypoint. Symlinks game files, uses `wget -N` (timestamping) to fetch `t7x.exe` only when upstream is newer, seeds Dss0/t7-server-config bundle, fans out config symlinks, starts `Xvfb` (T7x requires a display), `launch_game wine t7x.exe`. No mod dir (alterware MOD is a Steam Workshop ID).
4. **`alterentry.sh`** — Alterware (T7x/BO3) entrypoint. Symlinks game files, uses `wget -N` (timestamping) to fetch `t7x.exe` only when upstream is newer, seeds Dss0/t7-server-config bundle, fans out config symlinks, `launch_game wine t7x.exe -headless -dedicated ...`. No mod dir (alterware MOD is a Steam Workshop ID).
5. **`game-config.sh`** — Shared shell library sourced by all other scripts. Key helpers:
**`-headless` is what removes the X dependency — do not drop it.** t7x's `console` component calls `Sys_CreateConsole` and builds a real Win32 console *window* unless `game::is_headless()`; with `-headless` it attaches to the parent console and `fputs`es to stdout instead. Without it, under Wine with no display, the process hangs at `err:winediag:nodrv_CreateWindow` and never binds its port. The image ships no X server at all now; the flag is the only thing standing in for one. `-dedicated` is separate and still required (it forces `is_server` rather than relying on the "server exe present, client exe absent" fallback).
5. **`cod4xentry.sh`** — CoD4x (Call of Duty 4) entrypoint. MP only. Same shape as the rest: mirrors `main/` and `zone/english/` from the read-only mount, stages the server binary and CoD4x assets, seeds configs, fans out config symlinks, validates, `launch_game ./cod4x18_dedrun`.
**The only family that does not use Wine.** Upstream publishes a native Linux dedicated server, `cod4x18_dedrun`, and it is a plain console app — no window, so no display either. The Windows build was tested and dies at `nodrv_CreateWindow` exactly as T7x does without `-headless`, which would have meant reintroducing Xvfb. The native binary was the risk to check instead: it is a 32-bit x86 ELF, and 32-bit Linux socket code is what Docker's seccomp blocks via `socketcall(2)` — the very reason this image sits on an Arch pure-WoW64 base. Measured: it opens its UDP and TCP sockets fine. Cost is `lib32-glibc` + `lib32-gcc-libs` from multilib.
**Therefore CoD4x is amd64-only** — a 32-bit x86 ELF cannot run on arm64 at all. The entrypoint checks for the binary and `hold_indefinitely`s with an explanation, the same capability-check (not arch-check) pattern `iw4xentry.sh` uses.
The binary is **copied** into the volume, not symlinked: CoD4x self-updates in place, which would fail against a read-only image layer, and a copy means an updated build survives container recreation. Same reasoning as the IW4x launcher, different cause.
**Two assets come from the *client* release, and are not optional.** The server release ships only binaries and plugin zips; the server refuses to load any map without `zone/english/cod4x_patchv2.ff`, which upstream publishes under `CoD4x_Client_pub`. `jcod4x_00.iwd` is referenced too. Nothing else from the client is used — `cod4x_021.dll`, `launcher.dll`, `core` and `mss` are client-side, and `cod4x_ambfix.ff` is referenced zero times by a running server. Both refs are pinned (`COD4X_SERVER_REF`, `COD4X_CLIENT_REF`) rather than tracking latest: the last server release is 2022 and the binary self-updates anyway.
Without `PLUTAINER_COD4X_AUTH_TOKEN` the launch passes `sv_authorizemode -1`; the server runs and is fully playable, just unlisted on the master.
6. **`game-config.sh`** — Shared shell library sourced by all other scripts. Key helpers:
- Volume path constants: `PLUTAINER_APP_DIR`, `PLUTAINER_CONFIGS_DIR`, `PLUTAINER_RUNTIME_DIR`, `PLUTAINER_GAMEFILES_DIR`, `PLUTAINER_PLUTONIUM_DIR`, `PLUTAINER_SOURCE_DIR`.
- `hold_indefinitely <msg>`: print the error, then `exec sleep infinity` so the container stays `Up` instead of looping through restarts. Used for any startup validation failure.
- `launch_game <cmd>...`: wraps the game invocation; on exit, sleeps 30s before letting the script exit, so docker's restart policy throttles to ~1 restart per 30s.
@ -94,17 +140,33 @@ Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`. All e
- `link_configs <engine-dir1> [engine-dir2 ...]`: variadic. Fans out symlinks from every `configs/*.cfg` into each engine dir using relative paths. Refuses to overwrite a real (non-symlink) file at engine path (warns instead). Reaps dangling cfg symlinks. No-op when `PLUTAINER_USE_RAW_CONFIGS=true`.
- `ensure_config_present`: checks that `CONFIG_FILE` exists at `CONFIG_SOT_DIR`. If absent there but present as a real file at the ALT location, moves it (auto-lift). If absent everywhere, prints a refusal with a `find -iname` case-insensitive hint, returns non-zero.
- `check_volume_version`: refuses v1 volumes with explicit migration instructions; initialises fresh volumes; writes `.plutainer-version=2`.
- `resolve_rcon_whitelist_args`: builds `+rconWhitelistAdd <ip>` launch args (into `RCON_WHITELIST_ARGS`) from the container's detected default gateway plus `PLUTAINER_RCON_WHITELIST`. **T5/T6 only** — see the IW4MAdmin note under `plutoentry.sh`. Disable the gateway entry with `PLUTAINER_RCON_WHITELIST_GATEWAY=false`.
- `apply_rcon_password`: writes `PLUTAINER_RCON_PASSWORD` into `CONFIG_SOT_DIR/CONFIG_FILE`. **Opt-in and never destructive** — unset or empty is a no-op, so it can't null out a password the user set by hand. Rewrites the value on an existing `rcon_password` line (keeping its trailing `//` comment) or appends one. Uses python3, not sed: the value is arbitrary user input that would otherwise need escaping against sed's replacement metacharacters. There is deliberately **no default value** — a shipped placeholder would be a known credential on a port anyone can find by scanning for `getstatus` responders. Called by all three entrypoints after `ensure_config_present`.
- `extract_rcon_password`: parses `rcon_password` from `CONFIG_PATH`. Handles double-quoted, single-quoted, and unquoted values. Strips `//` comments. On failure, prints a structured `[WARN]` (don't block startup) telling the user the accepted forms and not to set the password via `PLUTAINER_EXTRA_ARGS`.
6. **`migrate-v1-to-v2.sh`** — One-shot migration tool, run via `docker run --entrypoint`. Moves `app/gamefiles``app/runtime/gamefiles`, `app/plutonium``app/runtime/plutonium`, lifts top-level cfg files from known engine config dirs into `app/configs/` and replaces them with relative symlinks, clears stale `app/logs/` entries, writes `.plutainer-version=2`. Supports `--dry-run`.
7. **`migrate-v1-to-v2.sh`** — One-shot migration tool, run via `docker run --entrypoint`. Moves `app/gamefiles``app/runtime/gamefiles`, `app/plutonium``app/runtime/plutonium`, lifts top-level cfg files from known engine config dirs into `app/configs/` and replaces them with relative symlinks, clears stale `app/logs/` entries, writes `.plutainer-version=2`. Supports `--dry-run`.
7. **`log-watcher.sh`** — Background poller started by each entrypoint before `exec wine`. Discovers every `*.log` under `/home/plutainer/app/` (excluding `app/logs/` itself to avoid cycles) and maintains relative symlinks at `/home/plutainer/app/logs/<basename>` pointing at the active one. Active = newest mtime >= container boot time. Agnostic to log name. Symlinks are relative so they resolve the same on host, in this container, or in a sidecar IW4MAdmin container. Disable with `PLUTAINER_LOG_SYMLINKS=false`; poll interval via `PLUTAINER_LOG_POLL_INTERVAL` (default 2s).
8. **`log-watcher.sh`** — Background poller started by each entrypoint before `exec wine`. Discovers every `*.log` under `/home/plutainer/app/` (excluding `app/logs/` itself to avoid cycles) and maintains relative symlinks at `/home/plutainer/app/logs/<basename>` pointing at the active one. Active = newest mtime >= container boot time. Agnostic to log name. Symlinks are relative so they resolve the same on host, in this container, or in a sidecar IW4MAdmin container. Disable with `PLUTAINER_LOG_SYMLINKS=false`; poll interval via `PLUTAINER_LOG_POLL_INTERVAL` (default 2s).
8. **`healthcheck.sh`** — Sources `game-config.sh`, then uses `pyquake3.py` to send an RCON `status` command. Enabled by default; disable with `PLUTAINER_HEALTHCHECK=false`. HEALTHCHECK directive uses `--start-period=5m` to accommodate first-run downloads.
9. **`healthcheck.sh`** — Sources `game-config.sh`, resolves the port, then uses `pyquake3.py`'s `update()` to send an **unauthenticated `getstatus`** and requires a non-empty map name in the reply. Enabled by default; disable with `PLUTAINER_HEALTHCHECK=false`. HEALTHCHECK directive uses `--start-period=5m` to accommodate first-run downloads.
9. **`rcon-cli`** — Python script providing interactive and one-shot RCON access via `docker exec`. Calls `game-config.sh` to resolve port/credentials. Supports Plutonium, IW4x, and Alterware.
**Why not RCON `status`, which it used before:** both queries are handled by the same connectionless-packet path in the same server frame loop, and both read the map from the same `mapname`/`sv_mapname` cvar, so their failure detection is identical — a stalled loop replies to neither, and a server that has lost its map reports no map to either. RCON only added a dependency on `rcon_password`, which every bundled seed ships empty, so a perfectly healthy first-run server could never report healthy. Match the map key case-insensitively: iw4x/t4/t5/t6 answer `mapname`, t7x answers `MapName`.
10. **`pyquake3.py`** — Python 3 Quake 3 protocol library (UDP). Used by the health check and `rcon-cli` for RCON queries.
**`getstatus` first, `getinfo` second — the order is load-bearing in both directions.** IW5 (MW3) does not answer `getstatus` at all, only `getinfo`, so without the fallback a healthy IW5 server reports unhealthy forever. T7x answers both, but its `infoResponse` advertises the *lobby's* map while `statusResponse` reports the map actually running (observed: `getinfo``mp_chinatown` while `getstatus``mp_spire` on the same server), so preferring `getinfo` would report the wrong map. Verified across iw4x, t4 MP/ZM, t6 MP/ZM, t7x MP/ZM (all via `getstatus`) and iw5 (via `getinfo`).
10. **`rcon-cli`** — Python script providing interactive and one-shot RCON access via `docker exec`. Calls `game-config.sh` to resolve port/credentials. Supports Plutonium, IW4x, and Alterware.
11. **`pyquake3.py`** — Minimal Quake 3 connectionless-protocol client (UDP), trimmed from the upstream GPL library to the two paths in use: `query_values(query)` (unauthenticated `getstatus`/`getinfo`) backs the health check, `rcon()` backs `rcon-cli`. The upstream `Player`/`parse_players`/`rcon_update` machinery was removed — nothing consumed it.
Two engine quirks live in `parse_packet`, both found the hard way:
- **T7x prefixes its `statusResponse` with a stray `0x44` byte** before the `\xff\xff\xff\xff` connectionless prefix. Demanding the prefix at offset 0 rejected it as `Malformed packet`, which is why **RCON never worked on t7x at all** — the old healthcheck failed there regardless of password. The prefix is now located within an 8-byte window.
- **Not every reply has a payload:** t5's zombies build answers unexpected connectionless packets with a bare `disconnect` and no newline. That is now parsed as "type, empty body" so the caller reports "replied without a map name" instead of a parse error.
12. **`tools/refresh-seeds.sh`** — The only thing that should rewrite `seed-configs/`. Resolves each upstream repo's branch to a commit SHA, downloads that exact tarball, copies the per-repo subpaths, strips `*REFERENCE*` dirs and `.bat`/`.sh`/`README*`, appends the iw4x `sv_maprotation` block, applies `harden_rcon_for_docker` (below), and writes `seed-configs/<game>/SOURCE` with the commit.
**`harden_rcon_for_docker` — two upstream defaults that are wrong inside a container.** `rcon_localhost_bypass` is forced to `1` where the cvar exists (t5 ships `0`, which subjects even loopback to whitelist and rate-limit checks; `rcon-cli` runs over loopback inside the container). And every `rconWhitelistAdd` line is commented out: the seeds ship example IPs from another network (`192.168.0.7`, `10.0.0.12`, `172.16.8.7`), and per upstream's own comment a *non-empty* whitelist admits only those plus loopback — so the placeholders silently block the sidecar IW4MAdmin the user is trying to connect. Verified on a live t4 server: RCON from the Docker gateway was dropped until that gateway's exact IP was whitelisted, and works with no manual step once the placeholders are gone. **Ranges are not an option**`rconWhitelistAdd "172.16.0.0/12"` answers `Error: Invalid address`, only single addresses are accepted — and enumerating Docker gateways is futile since subnets are user-defined (`172.17.0.1`, `172.26.10.1`, …). Commenting them out restores upstream's own "empty = all IPs" default; RCON still requires the password, which every seed ships empty. Takes game names to refresh a subset; honours `GITHUB_TOKEN` for API rate limits.
**Seeds are vendored, not fetched at build time.** Six `wget`s of six third-party repos meant any one of them disappearing broke the build for *every* game — and `alterware/t7x` 404'd during development, so this is not hypothetical. Worse, the `RUN` string never changed, so BuildKit cached the layer indefinitely: upstream edits were invisible until eviction and no image could say which revision it shipped. A `COPY` re-hashes on content, so updates are a reviewable commit. Five of the six upstream repos declare no license (`iw4x/iw4-server-configs` is BSD-3-Clause); the published images already redistributed these bytes, so vendoring changes nothing legally, but keep the attribution table in README.
## Volume Layout (v2)

View file

@ -1,228 +1,13 @@
services:
iw4xmp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: iw4xmp-1
restart: unless-stopped
ports:
- "28970:28970/udp"
#- "28970:28970/tcp" # TCP is needed for modlist metadata (not required if not hosting mods)
volumes:
- /opt/GameFiles/IW4xServerFiles:/home/plutainer/gamefiles:ro
- ./iw4xmp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=iw4x
- PLUTAINER_CONFIG_FILE=server.cfg
- PLUTAINER_PORT=28970
- PLUTAINER_AUTO_UPDATE=false
networks:
- games-net
t6zm-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t6zm-1
hostname: t6zm-1
restart: unless-stopped
ports:
- "4976:4976/udp"
volumes:
- /opt/GameFiles/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6zm-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t6zm
- PLUTAINER_CONFIG_FILE=dedicated_zm.cfg
- PLUTAINER_PORT=4976
- PLUTO_SERVER_KEY=${T6ZM_PLUTO_SERVER_KEY}
networks:
- games-net
t6mp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t6mp-1
hostname: t6mp-1
restart: unless-stopped
ports:
- "4977:4977/udp"
volumes:
- /opt/GameFiles/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6mp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t6mp
- PLUTAINER_CONFIG_FILE=dedicated.cfg
- PLUTAINER_PORT=4977
- PLUTO_SERVER_KEY=${T6MP_PLUTO_SERVER_KEY}
networks:
- games-net
t4sp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t4sp-1
hostname: t4sp-1
restart: unless-stopped
ports:
- "28960:28960/udp"
volumes:
- /opt/GameFiles/T4ServerFiles:/home/plutainer/gamefiles:ro
- ./t4sp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t4sp
- PLUTAINER_CONFIG_FILE=server_zm.cfg
- PLUTAINER_PORT=28960
- PLUTO_SERVER_KEY=${T4SP_PLUTO_SERVER_KEY}
networks:
- games-net
t4mp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t4mp-1
hostname: t4mp-1
restart: unless-stopped
ports:
- "28961:28961/udp"
volumes:
- /opt/GameFiles/T4ServerFiles:/home/plutainer/gamefiles:ro
- ./t4mp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t4mp
- PLUTAINER_CONFIG_FILE=server.cfg
- PLUTAINER_PORT=28961
- PLUTO_SERVER_KEY=${T4MP_PLUTO_SERVER_KEY}
networks:
- games-net
t5mp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t5mp-1
hostname: t5mp-1
restart: unless-stopped
ports:
- "28962:28962/udp"
volumes:
- /opt/GameFiles/T5ServerFiles:/home/plutainer/gamefiles:ro
- ./t5mp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t5mp
- PLUTAINER_CONFIG_FILE=dedicated.cfg
- PLUTAINER_PORT=28962
- PLUTO_SERVER_KEY=${T5MP_PLUTO_SERVER_KEY}
- PLUTO_MAX_CLIENTS=18
networks:
- games-net
t5sp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t5sp-1
hostname: t5sp-1
restart: unless-stopped
ports:
- "28963:28963/udp"
volumes:
- /opt/GameFiles/T5ServerFiles:/home/plutainer/gamefiles:ro
- ./t5sp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t5sp
- PLUTAINER_CONFIG_FILE=dedicated_sp.cfg
- PLUTAINER_PORT=28963
- PLUTAINER_MOD=mods/Stairway_to_hell_bo1
- PLUTO_SERVER_KEY=${T5SP_PLUTO_SERVER_KEY}
- PLUTO_MAX_CLIENTS=4
networks:
- games-net
iw5mp-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: iw5mp-1
hostname: iw5mp-1
restart: unless-stopped
ports:
- "27016:27016/udp"
volumes:
- /opt/GameFiles/IW5ServerFiles:/home/plutainer/gamefiles:ro
- ./iw5mp-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=iw5mp
- PLUTAINER_CONFIG_FILE=server.cfg
- PLUTAINER_PORT=27016
- PLUTO_SERVER_KEY=${IW5MP_PLUTO_SERVER_KEY}
networks:
- games-net
t7x-zm-1:
image: ghcr.io/ayymoss/plutainer:v2
container_name: t7x-zm-1
restart: unless-stopped
ports:
- "27017:27017/udp"
volumes:
- /opt/GameFiles/T7xServerFiles:/home/plutainer/gamefiles:ro
- ./t7x-zm-1:/home/plutainer/app
environment:
- PLUTAINER_GAME=t7x
- PLUTAINER_CONFIG_FILE=server_zm.cfg
- PLUTAINER_PORT=27017
#- PLUTAINER_MOD=2833912385
#- PLUTAINER_AUTO_UPDATE=false
networks:
- games-net
iw4madmin:
image: ghcr.io/raidmax/iw4madmin:latest
container_name: iw4madmin
restart: unless-stopped
ports:
- "1624:1624"
#- You need to expose any IW4X_NET_LOG_IP ports.
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- ./IW4MAdmin/Configuration:/app/Configuration
- ./IW4MAdmin/Plugins:/app/Plugins
- ./IW4MAdmin/Localization:/app/Localization
- ./IW4MAdmin/Database:/app/Database
- ./IW4MAdmin/Log:/app/Log
# v2: mount each server's app/logs/ as a single read-only dir. log-watcher.sh
# maintains stable symlinks inside, regardless of where the game writes them.
- ./iw4xmp-1/logs:/app/gamelogs/iw4xmp-1:ro
- ./iw5mp-1/logs:/app/gamelogs/iw5mp-1:ro
- ./t4mp-1/logs:/app/gamelogs/t4mp-1:ro
- ./t4sp-1/logs:/app/gamelogs/t4sp-1:ro
- ./t5mp-1/logs:/app/gamelogs/t5mp-1:ro
- ./t5sp-1/logs:/app/gamelogs/t5sp-1:ro
- ./t6mp-1/logs:/app/gamelogs/t6mp-1:ro
- ./t6zm-1/logs:/app/gamelogs/t6zm-1:ro
depends_on:
iw4xmp-1:
condition: service_healthy
t6zm-1:
condition: service_healthy
t6mp-1:
condition: service_healthy
t4sp-1:
condition: service_healthy
t4mp-1:
condition: service_healthy
t5mp-1:
condition: service_healthy
t5sp-1:
condition: service_healthy
iw5mp-1:
condition: service_healthy
networks:
iw4m-net:
ipv4_address: 100.64.10.2
networks:
iw4m-net:
driver: bridge
ipam:
config:
- subnet: 100.64.10.0/30
gateway: 100.64.10.1
games-net:
driver: bridge
ipam:
config:
- subnet: 172.26.10.0/24
gateway: 172.26.10.1
# This file has moved.
#
# Examples now live in examples/, one per scenario:
#
# examples/single-server.yml one server — start here
# examples/per-game.yml a service block for every supported game
# examples/multi-server.yml several servers sharing game files
# examples/with-iw4madmin.yml servers plus an IW4MAdmin sidecar
# examples/env.example keys and passwords
#
# Walkthrough: docs/quickstart.md
#
# Kept as a signpost because older guides and forum posts link here.

371
README.md
View file

@ -1,339 +1,72 @@
# Plutainer: Dockerized Plutonium, IW4x & Alterware Game Servers
# Plutainer
This repository contains the necessary files to build and run dedicated game servers for Plutonium, IW4x, and Alterware using Docker. The image is designed to be flexible and configurable through environment variables.
The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:latest`
> **Tag layout:**
> - `:latest` (and the `:v2` alias) — current Plutainer v2. New volume layout, unified `PLUTAINER_*` environment variables. Built from `main`. Multi-arch.
> - `:edge`**amd64 only.** Published straight from the amd64 build job so it appears within a few minutes instead of waiting on the slower arm64 build. Useful for testing a fresh commit; pulling it on an arm64 host fails with a platform mismatch.
> - `:v1-final` (and the `:v1` alias) — frozen snapshot of the old v1 image. Legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars, flat `app/gamefiles/` + `app/plutonium/` layout. Shows a deprecation banner on every start. No further updates, fixes, or security patches.
>
> **Upgrading from v1?** See [MIGRATION.md](MIGRATION.md) — covers the env var rename, the volume migration command (one `docker run`), and how to pin `:v1-final` if you want to defer the migration.
> **Architecture support:** `linux/amd64` and `linux/arm64` are both published.
>
> **IW4x is not currently supported on arm64.** Upstream `iw4x/launcher` publishes `x86_64` binaries only, so the arm64 image compiles it from source, and that build is presently broken ([iw4x/launcher#76](https://github.com/iw4x/launcher/issues/76)). `PLUTAINER_GAME=iw4x` on arm64 refuses to start and says why. Plutonium (`t4`/`t5`/`t6`/`iw5`) and Alterware (`t7x`) work normally on both architectures, and IW4x on arm64 resumes automatically once a build produces a working launcher.
>
> If the arm64 build fails outright, `:latest`/`:v2` are published amd64-only rather than held back. Check with `docker manifest inspect ghcr.io/ayymoss/plutainer:latest` before upgrading an arm64 host.
## Overview
The primary goal of this Docker image is to simplify the setup and management of dedicated servers for the following games:
* **Plutonium:**
* T4 (Call of Duty: World at War) - `t4mp`, `t4sp`
* T5 (Call of Duty: Black Ops) - `t5mp`, `t5sp`
* T6 (Call of Duty: Black Ops II) - `t6mp`, `t6zm`
* IW5 (Call of Duty: Modern Warfare 3) - `iw5mp`
* **IW4x:** (Call of Duty: Modern Warfare 2) - `iw4x`
* **Alterware:**
* T7x (Call of Duty: Black Ops III) - `t7x`
The container includes the installation of Wine, Plutonium, IW4x, and Alterware launchers, and sets up a non-root user for enhanced security.
## Prerequisites
Before you can use this Docker image, you will need to have the base game files for the server you wish to host. This image does not provide any copyrighted game files. You must legally own the games.
You will also need to have Docker and Docker Compose installed on your system.
The gamefiles mount is read-only and should hold only the base game's own files. Anything an updater can fetch goes into the `app/` volume instead, so don't stage it in the mount.
For **IW4x**, the mount needs only the stock MW2 install: `main/`, `zone/english/`, `zone/dlc/`, `binkw32.dll`, `localization.txt`, `mss32.dll`. On first start `iw4x-launcher` fetches the rest into `app/runtime/gamefiles/` (~12 GB, kept across container recreation): `iw4x.exe`, `iw4x.dll`, `zonebuilder.exe`, the `iw4x/` asset directory and its `.iwd` archives, all of `zone/patch/` and `zone/zonebuilder/`, and the DLC fastfiles.
`zone/patch/` and `zone/zonebuilder/` are owned entirely by the launcher — copies in your gamefiles mount are ignored, so a slimmed-down server install is fine. Client-only assets (`main/video/`, `logo.bmp`, `splash.bmp`) are unused; [`mxve/shrink-iw4x`](https://github.com/mxve/shrink-iw4x) strips those and the media inside `main/*.iwd`, taking a full install from ~15 GB to ~6 GB.
## Getting Started: `docker-compose.yml`
Instead of using a long `docker run` command, it is highly recommended to use `docker-compose` to manage your server. See [EXAMPLE-docker-compose.yml](EXAMPLE-docker-compose.yml) for complete examples.
## Configuration
### Environment Variables
The container is configured entirely through environment variables. You must set `PLUTAINER_GAME` to one of the supported game tags.
#### Unified (`PLUTAINER_*`) — apply to all games
| Variable | Description | Default |
| --- | --- | --- |
| `PLUTAINER_GAME` | **Required.** Game tag: `t4mp`, `t4sp`, `t5mp`, `t5sp`, `t6mp`, `t6zm`, `iw5mp`, `iw4x`, or `t7x`. | |
| `PLUTAINER_CONFIG_FILE` | **Required.** Filename of your server's config (e.g., `dedicated.cfg`). Lives in `app/configs/` (see [Volume Layout](#volume-layout)). | |
| `PLUTAINER_PORT` | Network port for the server. | Game-specific (see [Default Ports](#default-ports)). |
| `PLUTAINER_SERVER_NAME` | Display name used in startup logs. | Game-family-specific default. |
| `PLUTAINER_MOD` | Mod folder name (Plutonium/IW4x) or Steam Workshop ID (T7x). Omit if no mod. | |
| `PLUTAINER_AUTO_UPDATE` | Set to `"false"` to skip update checks at startup. | `true` |
| `PLUTAINER_HEALTHCHECK` | Set to `"false"` to disable the RCON health check. | `true` |
| `PLUTAINER_SKIP_SEED` | Set to `"true"` to skip first-run [config seeding](#bundled-config-seeds). | `false` |
| `PLUTAINER_EXTRA_ARGS` | Extra arguments appended to the launch command. | |
| `PLUTAINER_MAP_ROTATE` | Set to `"false"` to drop the automatic map-rotate launch arg (`+map_rotate`, or `+start_map_rotate` on IW5), leaving map selection to your playlist or cfg. Not applicable to T7x. | `true` |
| `PLUTAINER_USE_RAW_CONFIGS` | Set to `"true"` to put cfg files directly in the engine path under `app/runtime/...` and skip the `app/configs/` symlink system. See [Raw Configs Mode](#raw-configs-mode). | `false` |
| `PLUTAINER_LOG_SYMLINKS` | Set to `"false"` to disable the [log symlink watcher](#log-symlinks). | `true` |
| `PLUTAINER_LOG_POLL_INTERVAL` | Seconds between log watcher polls. | `2` |
#### Game-specific (unique to one stack)
These cannot be unified because they only apply to a single engine family:
| Variable | Description | Applies to |
| --- | --- | --- |
| `PLUTO_SERVER_KEY` | **Required for Plutonium.** Server key from <https://platform.plutonium.pw/serverkeys>. | Plutonium only |
| `PLUTO_MAX_CLIENTS` | Maximum players (Plutonium T5 only — other games set this in the cfg). | Plutonium T5 only |
| `IW4X_NET_LOG_IP` | IP:port for IW4x remote netlogging (`g_log_add`). | IW4x only |
#### Default ports
| Game | Default |
| --- | --- |
| iw4x | 28960 |
| iw5 | 27016 |
| t4, t5 | 28960 |
| t6 | 4976 |
| t7x | 27017 |
> The legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` prefixed env vars from the `:latest` (v1) image are **not accepted** on `:v2`. Use the unified `PLUTAINER_*` names above. The only old names that remain are `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, and `IW4X_NET_LOG_IP` — they are single-family vars and never had a unified form.
***
### Volume Layout
The container expects two volume mounts:
| Container path | Purpose | Recommended host mount |
| --- | --- | --- |
| `/home/plutainer/gamefiles` | Read-only base game files you own. | Bind-mount with `:ro`. |
| `/home/plutainer/app` | Persistent server state, configs, and logs. | Bind-mount or named volume. |
On a fresh `app/` mount, the container initialises this layout on first start:
```
app/
configs/ # Your server's *.cfg files. Edit here.
logs/ # Stable symlinks to active *.log files (see Log Symlinks).
runtime/
gamefiles/ # Symlinks into the read-only gamefiles mount, plus
# writable game state (mods, maps, plutonium storage).
plutonium/ # Plutonium binaries and storage.
.plutainer-version # "2" — marks volume layout version.
```
**Where to put your `*.cfg` files:** drop them in `app/configs/` and set `PLUTAINER_CONFIG_FILE` to the filename. The container creates a symlink at the engine's expected path on each start, so the game still reads from its usual location — you just have one predictable place to edit.
Example: for a T6 server with `PLUTAINER_CONFIG_FILE=dedicated_zm.cfg`, you edit `app/configs/dedicated_zm.cfg`, and the container symlinks `app/runtime/plutonium/storage/t6/dedicated_zm.cfg → ../../../../configs/dedicated_zm.cfg`.
If you set `PLUTAINER_MOD`, the same cfg also gets symlinked into the mod's config dir (e.g. `app/runtime/plutonium/storage/t6/<mod>/dedicated_zm.cfg`), so the engine finds it whether it looks in the base dir or the mod-scoped one.
Nested configs (e.g. cfg files referenced by mods using subdirectories) stay at their engine path under `app/runtime/` and are not lifted to `configs/`. You can still edit them there.
**Updater-owned paths:** for IW4x, everything under `app/runtime/gamefiles/zone/patch/` and `zone/zonebuilder/` belongs to `iw4x-launcher`. Files you add there whose names collide with its own are overwritten on update, and a *symlink* placed there stops the launcher extracting at all, which silently disables updates. Put custom content elsewhere — `userraw/` is the usual place for scripts and assets, and is never touched by the updater.
**Auto-lift:** if you set `PLUTAINER_CONFIG_FILE=dedicated.cfg` but the file is at `app/runtime/.../dedicated.cfg` (as a real file, not symlink) rather than `app/configs/dedicated.cfg`, the container moves it into `app/configs/` on next start and the symlink fan-out picks it up. One-time fix, no manual migration.
**Filename mismatch:** if the configured file doesn't exist anywhere, the container refuses to start with a clear error and (if there's a case-only mismatch like `Server.cfg` vs `server.cfg`) tells you which case-insensitive match it found. Filenames remain case-sensitive — the container won't auto-rename.
***
### Raw Configs Mode
Set `PLUTAINER_USE_RAW_CONFIGS=true` to opt out of the `app/configs/` SOT model. With this on:
- The engine config dir under `app/runtime/...` becomes the source of truth.
- `app/configs/` is left untouched (whatever's there is ignored).
- No symlinks are placed; cfg files live where the game reads them.
- Seed configs go directly into the engine dir.
Use this when you want the v1 editing experience inside the v2 directory layout (e.g. tooling on your host expects to find `t6zm-1/runtime/plutonium/storage/t6/dedicated.cfg` as a real file). Default-off so most users get the "edit in one folder" affordance without thinking about it.
You can toggle this between restarts. Plutainer doesn't migrate files when you flip the flag — that's on you.
***
### Upgrading from v1
If you have an existing deployment that was running an older `:latest` (now `:v1-final`), pulling the new `:latest` (v2) refuses to start: v2 detects either v1 environment variables (`PLUTO_*`/`IW4X_*`/`ALTER_*`) or the v1 volume layout (`app/gamefiles/`, `app/plutonium/` at the top level with no `.plutainer-version` marker) and prints a combined refusal block in `docker logs` listing exactly what was detected, plus the two paths forward.
**Full step-by-step guide:** [MIGRATION.md](MIGRATION.md) — env var mapping table, the one-command volume migration, dry-run option, and how to pin `:v1-final` if you would rather defer.
Quick summary of the migration path:
1. `docker compose down`
2. Run the migration tool once per volume (append `--dry-run` to preview):
```sh
docker run --rm \
-v <YOUR_APP_VOLUME>:/home/plutainer/app \
--entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \
ghcr.io/ayymoss/plutainer:v2
```
3. Rename `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars to `PLUTAINER_*` in your compose (full table in MIGRATION.md).
4. `docker compose up -d`
If you also have IW4MAdmin sidecar mounts pointing at log paths like `./t6zm-1/plutonium/storage/...`, update them to `./t6zm-1/runtime/plutonium/storage/...` — or better, switch to the stable [log symlink directory](#log-symlinks).
***
### Bundled Config Seeds
To make first-run setup painless, the image bundles default configs from community repos and copies them into the bind-mounted `app/` volume on container start. Files that already exist are **never overwritten** — existing user configs are always kept as-is.
Top-level `*.cfg` files from each seed bundle land in `app/configs/` (flat). Other assets (mod scripts, maps, nested cfgs, lobby scripts) land under `app/runtime/` at their engine-expected paths.
| Game | Source repo |
| --- | --- |
| Plutonium T4 | [xerxes-at/T4ServerConfigs](https://github.com/xerxes-at/T4ServerConfigs) |
| Plutonium T5 | [xerxes-at/T5ServerConfig](https://github.com/xerxes-at/T5ServerConfig) |
| Plutonium T6 | [xerxes-at/T6ServerConfigs](https://github.com/xerxes-at/T6ServerConfigs) |
| Plutonium IW5 | [xerxes-at/IW5ServerConfig](https://github.com/xerxes-at/IW5ServerConfig) |
| Alterware T7x | [Dss0/t7-server-config](https://github.com/Dss0/t7-server-config) (includes `t7x/lobby_scripts/` required for `sv_lobby_mode`) |
| IW4x | [iw4x/iw4-server-configs](https://github.com/iw4x/iw4-server-configs) (`userraw/``server.cfg`, `serverlan.cfg`, `partyserver.cfg`, `partyserverlan.cfg`, plus the playlist `*.info` files) |
IW4x seeds four configs into `app/configs/` — set `PLUTAINER_CONFIG_FILE` to whichever you want (`server.cfg` is the normal dedicated one; the `partyserver*` pair run lobby mode from playlists). The playlist `*.info` files land under `app/runtime/gamefiles/userraw/`.
> `iw4x/iw4-server-configs` ships `sv_maprotation` commented out, unlike the other seeds, which would leave `+map_rotate` with nothing to load. Plutainer appends a stock-MW2 rotation to `server.cfg` and `serverlan.cfg` at image build time under an `// Added by Plutainer` comment block. Stock maps only, so no DLC fastfiles required. Edit freely, or set `PLUTAINER_MAP_ROTATE=false` to select maps from a playlist instead.
**Set `rcon_password` before relying on the healthcheck.** All of these upstream repos ship it empty, and the healthcheck sends an RCON `status` — until you set one, a seeded server plays fine but reports `unhealthy`. `rcon-cli` needs it too.
To opt out — for example if you manage configs entirely yourself and don't want any default files appearing in your bind mount — set `PLUTAINER_SKIP_SEED=true`.
The seed snapshot is frozen at image build time. Pulling a newer image only seeds files that don't yet exist in your bind mount, so the upstream repo never silently overwrites your edits.
***
### Permissioning
#### Mount Permissions
When you mount volumes from your host machine into the container, the `plutainer` user (with UID `1000`) needs to have the appropriate permissions to read and write to those directories. If the ownership on your host directories is incorrect, the server may fail to start or be unable to save data.
On many desktop Linux distributions, the first user you create is automatically assigned UID `1000`. If you are that user, you may not need to do anything. However, if you created the directories as `root` (e.g., using `sudo mkdir`), you will need to update their ownership.
#### How to Fix Permissions
To ensure the container has the correct access, change the ownership of your persistent data directory to match the container's user. Run the following command on your host machine, adjusting the path to match your setup:
```sh
sudo chown -R 1000:1000 /opt/pluto-servers/t6zm-server-1/
```
The `-R` flag applies the ownership recursively, ensuring all files and sub-folders have the correct permissions.
***
### RCON CLI
The container includes a built-in RCON client for sending commands to your server. It automatically detects the game type, port, and RCON password from your configuration — no extra setup needed.
```sh
# Send a single command
docker exec <container_name> rcon-cli status
# Open an interactive RCON session
docker exec -i <container_name> rcon-cli
```
Your server configuration file must have `rcon_password` set for `rcon-cli` to work. Accepted forms in the cfg:
```
set rcon_password "your_password_here"
seta rcon_password 'also_works'
set rcon_password unquoted_also_ok
```
Comment-only lines (`// ...`) are ignored. If multiple uncommented `rcon_password` lines exist, the last one wins. If the parser can't find one, the container prints a `[WARN]` at startup but keeps running — healthcheck and rcon-cli are then unavailable until you add it. **Do not** set `rcon_password` via `PLUTAINER_EXTRA_ARGS` — Plutainer cannot read it back from there.
***
### Restart behavior
Plutainer distinguishes between *configuration errors* (your fault) and *runtime crashes* (the game's fault):
- **Configuration error** (e.g. missing `PLUTAINER_CONFIG_FILE`, wrong volume version, unparseable `PLUTAINER_GAME`): the container prints the error and then `sleep infinity` to **hold** in the `Up` state. No restart loop. Fix the issue and run `docker restart <container>`.
- **Runtime crash** (wine exits, game segfaults, etc): the container sleeps **30 seconds** after the game process exits, then exits itself. Docker's `restart: unless-stopped` (or your chosen policy) then restarts it. This rate-limits crash loops to ~1 restart per 30s instead of hammering immediately.
Healthcheck still runs on a held container and will eventually mark it unhealthy — useful signal for orchestration.
***
### Log Symlinks
The container maintains a flat directory of symlinks at `app/logs/` pointing at the active `*.log` file for each basename. Game logs move around per game/mod (e.g. `runtime/plutonium/storage/t5/mods/<mod>/logs/games_zm.log`); the watcher surfaces them all in one predictable place so IW4MAdmin (or any other log reader) doesn't have to chase the exact path.
Mount `app/logs/` as the source for downstream log consumers:
Run a Call of Duty dedicated server in Docker. One image, seven games, configured with environment variables.
```yaml
volumes:
- ./t6zm-1/logs:/app/gamelogs/t6zm-1:ro
services:
my-server:
image: ghcr.io/ayymoss/plutainer:latest
ports: ["4976:4976/udp"]
volumes:
- /path/to/BO2/files:/home/plutainer/gamefiles:ro
- ./my-server:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTO_SERVER_KEY: your-key-here
restart: unless-stopped
```
Symlinks are relative, so they resolve correctly from the host, this container, or a sidecar container mounting the same `app/` volume.
`docker compose up -d` and you have a Black Ops II zombies server. Plutainer writes a working config on first start, so there is nothing to prepare beyond the game files you own.
Disable with `PLUTAINER_LOG_SYMLINKS=false`; change poll interval with `PLUTAINER_LOG_POLL_INTERVAL` (default 2s).
**[→ Start here: the Quickstart](docs/quickstart.md)**
***
## Supported games
### Advanced: IW4MAdmin & RCON
| Game | `PLUTAINER_GAME` | Notes |
| --- | --- | --- |
| World at War (T4) | `t4mp`, `t4sp` | Plutonium. Needs a server key |
| Black Ops (T5) | `t5mp`, `t5sp` | Plutonium. Needs a **valid** server key — [see why](docs/games.md#t5-black-ops) |
| Black Ops II (T6) | `t6mp`, `t6zm` | Plutonium. Needs a server key |
| Modern Warfare 3 (IW5) | `iw5mp` | Plutonium. Needs a server key |
| Modern Warfare 2 (IW4x) | `iw4x` | No key. amd64 only |
| Black Ops III (T7x) | `t7x` | Alterware. No key |
| Modern Warfare (CoD4x) | `cod4x` | No key. Multiplayer only, amd64 only |
Connecting a containerized IW4MAdmin to your Plutainer server requires special network configuration due to the way Docker handles container-container networking via its proxy.
Image: `ghcr.io/ayymoss/plutainer:latest` — multi-arch (amd64 + arm64), with [two exceptions](docs/games.md#architecture-support).
This guide applies to a specific scenario:
## Documentation
* Your Plutainer game server is running in a container.
* IW4MAdmin is running in a **separate container on the same host**, but on a **different Docker bridge network**.
| If you want to… | Read |
| --- | --- |
| Get a first server running | [Quickstart](docs/quickstart.md) |
| Know what *your* game needs | [Games](docs/games.md) — files, keys, config names, ports |
| Look up an environment variable | [Configuration](docs/configuration.md) |
| Understand where configs and logs live | [Volumes & configs](docs/volumes-and-configs.md) |
| Send RCON commands | [RCON](docs/rcon.md) |
| Connect IW4MAdmin | [IW4MAdmin](docs/iw4madmin.md) |
| Know when a server counts as healthy | [Healthcheck & restarts](docs/healthcheck.md) |
| Fix something that's broken | **[Troubleshooting & FAQ](docs/troubleshooting.md)** |
| Copy a working compose file | [Examples](examples/) |
| Upgrade from v1 | [Migration guide](MIGRATION.md) |
Do **not** run IW4MAdmin from within the same bridge network as your Plutainer containers.
In this setup, when IW4MAdmin sends an RCON command, the game server sees the request as coming from its own network's **gateway IP**, not the IW4MAdmin container's IP.
## What Plutainer does for you
#### Solution: Whitelist the Gateway
- **Writes a working config on first start.** Community defaults are seeded into `app/configs/`, and never overwrite files you've edited.
- **Fetches the server binaries.** Plutonium, IW4x and T7x updaters run at startup; CoD4x ships in the image. You supply only the base game files.
- **Puts every config in one folder.** Edit `app/configs/whatever.cfg`; Plutainer symlinks it to wherever the engine expects it.
- **Keeps logs findable.** `app/logs/` holds stable symlinks to the active log files, wherever the game moved them.
- **Fails loudly, not endlessly.** A misconfiguration holds the container in `Up` with a readable error instead of a restart loop.
- **Reports real health.** The healthcheck asks the server for its current map — no RCON password required.
You must whitelist your Plutainer container's network gateway IP for RCON commands.
## Support
**Example:** Consider this `docker-compose.yml` network configuration:
Discord: <https://discord.gg/JekrGGWAUg> — for Plutainer setup and configuration, including IW4MAdmin.
```yaml
networks:
pluto-net:
driver: bridge
ipam:
config:
- subnet: 172.26.10.0/24
gateway: 172.26.10.1 # <--- This is the gateway IP
```
Plutonium-specific game issues are out of scope, and some familiarity with Docker is assumed. New to Docker? Start at <https://docs.docker.com/get-started/>.
If your game server is attached to `pluto-net`, you must add `"172.26.10.1"` to your server's `.cfg` RCON whitelist directive to grant IW4MAdmin access.
This issue does **not** occur if you are running IW4MAdmin directly on the host machine (bare-metal) or on an entirely different machine.
***
### Healthcheck
The container includes a robust health check script that verifies the server is running and responsive. It works by:
1. Detecting the game type and port.
2. Locating your server configuration file in `app/configs/`.
3. Extracting your `rcon_password` from the config.
4. Sending an RCON `status` command to the server.
5. Checking for a valid response.
The health check is enabled by default. You can disable it by setting `PLUTAINER_HEALTHCHECK=false`. This can be useful for debugging or if you do not wish to set an RCON password.
For the healthcheck to work correctly, games that support RCon whitelists need to have localhost permitted and/or `127.0.0.1`.
To have your servers restarted automatically, add [Auto Heal](https://github.com/willfarrell/docker-autoheal) to the compose.
***
### Support?
Discord Support: <https://discord.gg/PjrFw4tNES>
Please note that I will not be supporting Plutonium-specific issues. There is an expectation that you're already familiar with Docker. If you're brand new, please visit <https://docs.docker.com/get-started/>
This Discord is to be specific to Plutainer and its setup and configuration (including IW4MAdmin).
***
#### Credits
## Credits
- Corey, for a production testing ground @ <https://cukservers.net/>
- HGM, for the name 'Plutainer' @ <https://hgmserve.rs/>
- The config authors credited in [Games](docs/games.md#bundled-configs).

71
docs/configuration.md Normal file
View file

@ -0,0 +1,71 @@
# Configuration
Every setting is an environment variable. Only two are required.
## Required
| Variable | Description |
| --- | --- |
| `PLUTAINER_GAME` | Which game — `t4mp`, `t4sp`, `t5mp`, `t5sp`, `t6mp`, `t6zm`, `iw5mp`, `iw4x`, `t7x`, `cod4x` |
| `PLUTAINER_CONFIG_FILE` | Which config to run, e.g. `dedicated_zm.cfg`. Must exist in `app/configs/` — Plutainer seeds one on first start ([names per game](games.md)) |
Plutonium games (`t4*`, `t5*`, `t6*`, `iw5mp`) also require `PLUTO_SERVER_KEY`.
## Common
| Variable | Description | Default |
| --- | --- | --- |
| `PLUTAINER_PORT` | Network port | [per game](games.md) |
| `PLUTAINER_RCON_PASSWORD` | Sets `rcon_password` in your config at startup. Opt-in — unset leaves your config untouched | unset |
| `PLUTAINER_SERVER_NAME` | Name shown in Plutainer's own startup logs (not the in-game hostname — that's `sv_hostname` in your cfg) | per family |
| `PLUTAINER_MOD` | Mod folder name. On T7x, a Steam Workshop ID instead | unset |
| `PLUTAINER_MAP_ROTATE` | `false` drops the automatic `+map_rotate` (`+start_map_rotate` on IW5), leaving map choice to your cfg or playlist. N/A on T7x | `true` |
| `PLUTAINER_EXTRA_ARGS` | Extra arguments appended to the launch command | unset |
| `PLUTAINER_AUTO_UPDATE` | `false` skips update checks at startup | `true` |
| `PLUTAINER_HEALTHCHECK` | `false` disables the healthcheck | `true` |
| `PLUTAINER_SKIP_SEED` | `true` skips seeding default configs | `false` |
| `PLUTAINER_USE_RAW_CONFIGS` | `true` puts cfg files directly at the engine path instead of `app/configs/` — see [Volumes & configs](volumes-and-configs.md#raw-configs-mode) | `false` |
| `PLUTAINER_LOG_SYMLINKS` | `false` disables the `app/logs/` symlink watcher | `true` |
| `PLUTAINER_LOG_POLL_INTERVAL` | Seconds between log watcher polls | `2` |
| `PLUTAINER_LOG_MAX_SIZE` | Rotate a game log once it reaches this size. Accepts `64M`, `1G`, or plain bytes. `0` disables rotation | `64M` |
| `PLUTAINER_LOG_KEEP` | How many rotated copies to keep. `0` truncates without keeping one | `1` |
> **Don't set `rcon_password` through `PLUTAINER_EXTRA_ARGS`.** Plutainer can't read it back from there, so `rcon-cli` and IW4MAdmin won't find it. Use `PLUTAINER_RCON_PASSWORD` or the config file.
## Game-specific
These only apply to one engine family.
| Variable | Description | Applies to |
| --- | --- | --- |
| `PLUTO_SERVER_KEY` | **Required.** Key from <https://platform.plutonium.pw/serverkeys> | Plutonium |
| `PLUTO_MAX_CLIENTS` | Maximum players (other games set this in the cfg) | Plutonium T5 |
| `IW4X_NET_LOG_IP` | `IP:port` for remote netlogging (`g_log_add`) | IW4x |
| `PLUTAINER_COD4X_AUTH_TOKEN` | Masterserver token. Without one the server runs but stays unlisted | CoD4x |
| `PLUTAINER_COD4X_AUTHORIZE_MODE` | `sv_authorizemode` used when no token is set (default `-1`) | CoD4x |
| `PLUTAINER_DEDICATED` | `dedicated` value: `2` public, `1` LAN (default `2`) | CoD4x |
| `PLUTAINER_RCON_WHITELIST` | Extra addresses allowed to send RCON, comma separated. See [IW4MAdmin](iw4madmin.md) | T5, T6 |
| `PLUTAINER_RCON_WHITELIST_GATEWAY` | `false` stops auto-whitelisting the Docker gateway (default `true`) | T5, T6 |
## Ports
| Game | Default |
| --- | --- |
| t4, t5, iw4x, cod4x | 28960 |
| t6 | 4976 |
| iw5 | 27016 |
| t7x | 27017 |
Publish as **UDP**. IW4x additionally wants TCP published if you host mods, for modlist metadata:
```yaml
ports:
- "28960:28960/udp"
- "28960:28960/tcp" # IW4x mod hosting only
```
## Legacy variables
The `PLUTO_*` / `IW4X_*` / `ALTER_*` names from the v1 image are **not accepted** and are silently ignored. Three survive because they only ever applied to one family: `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, `IW4X_NET_LOG_IP`.
Upgrading from v1? See [MIGRATION.md](../MIGRATION.md). Plutainer detects a v1 volume or v1 variables and refuses to start with instructions rather than silently doing the wrong thing.

121
docs/games.md Normal file
View file

@ -0,0 +1,121 @@
# Games
What each game needs from you, and what Plutainer supplies. Find your game, check the three columns, done.
## At a glance
| Game | `PLUTAINER_GAME` | Server key? | Default port | Config files seeded |
| --- | --- | --- | --- | --- |
| World at War | `t4mp` `t4sp` | yes | 28960 | `server.cfg`, `server_zm.cfg`, `server_coop.cfg` |
| Black Ops | `t5mp` `t5sp` | **yes, must be valid** | 28960 | `dedicated.cfg`, `dedicated_sp.cfg` |
| Black Ops II | `t6mp` `t6zm` | yes | 4976 | `dedicated.cfg`, `dedicated_zm.cfg` |
| Modern Warfare 3 | `iw5mp` | yes | 27016 | `server.cfg` |
| Modern Warfare 2 | `iw4x` | no | 28960 | `server.cfg`, `serverlan.cfg`, `partyserver.cfg`, `partyserverlan.cfg` |
| Black Ops III | `t7x` | no | 27017 | `server.cfg`, `server_zm.cfg`, `server_cp.cfg` |
| Modern Warfare | `cod4x` | no | 28960 | `server.cfg` |
`PLUTAINER_CONFIG_FILE` must name one of the seeded files, or a config you place in `app/configs/` yourself. Get it wrong and the container refuses to start with a hint listing what it found — including case-only mismatches like `Server.cfg` vs `server.cfg`.
The `sp` tags are how Plutonium runs zombies/co-op: **T4 zombies is `t4sp`** with `server_zm.cfg`, **T5 zombies is `t5sp`** with `dedicated_sp.cfg`.
## What goes in the gamefiles mount
Mounted read-only at `/home/plutainer/gamefiles`. One copy can be shared by any number of servers.
### Plutonium (T4, T5, T6, IW5)
That game's base install. Plutonium's updater fetches its own binaries into `app/runtime/plutonium/` on first start (~500 MB).
### IW4x
A stock MW2 install: `main/`, `zone/english/`, `zone/dlc/`, `binkw32.dll`, `localization.txt`, `mss32.dll`.
On first start `iw4x-launcher` fetches another 12 GB into `app/runtime/gamefiles/``iw4x.exe`, `iw4x.dll`, `zonebuilder.exe`, the `iw4x/` asset directory, all of `zone/patch/` and `zone/zonebuilder/`, and the DLC fastfiles. That download persists across container recreation.
`zone/patch/` and `zone/zonebuilder/` belong entirely to the launcher — copies in your mount are ignored, so a slimmed install is fine. Client-only assets (`main/video/`, `logo.bmp`, `splash.bmp`) are unused; [`mxve/shrink-iw4x`](https://github.com/mxve/shrink-iw4x) strips those and the media inside `main/*.iwd`, taking a full install from ~15 GB to ~6 GB.
> **Never put your own files in `zone/patch/` or `zone/zonebuilder/`.** A *symlink* there stops the launcher extracting at all, which silently disables updates. Custom scripts and assets go in `userraw/`, which the updater never touches.
### T7x
BO3 server files: `BlackOps3_UnrankedDedicatedServer.exe`, `zone/`, `machinecfg`, `codlogo.bmp`, and the `steam_api64` / `steamclient64` / `tier0_s64` / `vstdlib_s64` DLLs. Plutainer downloads `t7x.exe` itself and re-fetches only when upstream is newer.
### CoD4x
**Only `main/` and `zone/`.** The client binaries (`iw3mp.exe`, `iw3sp.exe`, `binkw32.dll`, `d3dx9_34.dll`, `mss32.dll`, the bitmaps) are never read by a dedicated server and can be deleted from your mount.
Plutainer ships the server binary and the two assets a stock install lacks — `cod4x_patchv2.ff` and `jcod4x_00.iwd` — so nothing is downloaded at runtime.
## Per-game notes
### T5 (Black Ops)
**A placeholder key will not work.** T4, T6 and IW5 start and play with any key string; T5 does not. It pulls `online_tu14_mp_english.wad` through Plutonium's authenticated service, so with an invalid key the log loops:
```
Error: Unable to fetch file online_tu14_mp_english.wad.
Early out of maprotate, waiting for WAD!
```
The server process runs, binds its port, and never loads a map — a client sees *"Server is not running a map"*. It looks like a broken server; it's an unauthenticated key. Use a real one from <https://platform.plutonium.pw/serverkeys>.
T5 also only answers status queries from localhost, so external query tools see nothing even when it's perfectly healthy. Plutainer's healthcheck runs inside the container, so it is unaffected.
### T7x (Black Ops III)
Launches with `-headless`, which is what removes the need for a virtual display — without it the server hangs on window creation and never binds its port. `-dedicated` is passed separately and is also required.
`PLUTAINER_MOD` here is a **Steam Workshop ID**, not a folder name.
### CoD4x (Modern Warfare)
Multiplayer only. This is the one family that does **not** run under Wine: upstream ships a native Linux server, and Plutainer runs it directly.
Without an auth token the server runs fine but stays unlisted on the master, logging `Server needs to provide a valid token in cvar sv_authtoken`. That's expected. Set `PLUTAINER_COD4X_AUTH_TOKEN` to be listed.
**RCON passwords must be at least 8 characters.** Shorter ones are refused with `No rconpassword set on server or password is shorter than 8 characters`, which reads like a wrong password rather than a too-short one.
**The server self-updates.** CoD4x rewrites its own binary in `app/runtime/gamefiles/`, which is why Plutainer copies it there rather than symlinking it out of the image. Tested behaviour:
- A build the updater fetched is **kept** across container restarts.
- If a newer Plutainer image ships a newer pinned build, that one is restaged — briefly downgrading a self-updated binary, which the updater then corrects on the next start. Set `PLUTAINER_AUTO_UPDATE=false` to pin whatever is in your volume.
- A stale `autoupdate.lock` left behind by a killed container is harmless; startup time is unaffected.
### IW4x (Modern Warfare 2)
Four seeded configs: `server.cfg` is the normal dedicated one, the `partyserver*` pair run lobby mode from playlists, and the `*lan` variants are LAN mode.
Upstream ships `sv_maprotation` commented out, which would leave `+map_rotate` with nothing to load, so Plutainer's copy of `server.cfg` carries a stock-MW2 rotation under an `// Added by Plutainer` comment. Edit it freely, or set `PLUTAINER_MAP_ROTATE=false` and drive maps from a playlist.
## Architecture support
`linux/amd64` and `linux/arm64` are both published, with two exceptions:
- **IW4x does not work on arm64.** Upstream publishes `x86_64` binaries only, so the arm64 image builds the launcher from source, and that build is currently broken ([iw4x/launcher#76](https://github.com/iw4x/launcher/issues/76)). It refuses to start and says why. It will work again automatically once upstream builds.
- **CoD4x is amd64-only, permanently.** Its server is a 32-bit x86 Linux binary, which cannot execute on arm64 at all.
Plutonium and T7x work on both. If an arm64 build fails outright, `:latest` publishes amd64-only rather than being held back — check with `docker manifest inspect ghcr.io/ayymoss/plutainer:latest` before upgrading an arm64 host.
## Bundled configs
On first start Plutainer copies a working config into `app/configs/`. Existing files are **never** overwritten, so your edits survive every image update. Opt out entirely with `PLUTAINER_SKIP_SEED=true`.
| Game | Source |
| --- | --- |
| T4 | [xerxes-at/T4ServerConfigs](https://github.com/xerxes-at/T4ServerConfigs) |
| T5 | [xerxes-at/T5ServerConfig](https://github.com/xerxes-at/T5ServerConfig) |
| T6 | [xerxes-at/T6ServerConfigs](https://github.com/xerxes-at/T6ServerConfigs) |
| IW5 | [xerxes-at/IW5ServerConfig](https://github.com/xerxes-at/IW5ServerConfig) |
| T7x | [Dss0/t7-server-config](https://github.com/Dss0/t7-server-config) — includes the lobby scripts `sv_lobby_mode` needs |
| IW4x | [iw4x/iw4-server-configs](https://github.com/iw4x/iw4-server-configs) |
| CoD4x | Maintained in this repo, adapted from [matracey/docker-cod4](https://github.com/matracey/docker-cod4) |
These are vendored into the repository under `seed-configs/`, not downloaded at build time, so a build can't break because someone renamed a repo — which happens. Each game's `seed-configs/<game>/SOURCE` records the exact upstream commit.
Plutainer makes three changes to what upstream ships, each marked with a `[Plutainer]` comment:
- **All passwords blanked**`rcon_password`, `g_password`, `sv_privatePassword`. A password shipped in a public image is a password everyone knows: as an RCON password it invites strangers in, and as a *join* password it locks your own players out.
- **Placeholder `rconWhitelistAdd` entries commented out** — they pointed at someone else's LAN and blocked the admin tools you'd actually connect. See [IW4MAdmin](iw4madmin.md#the-whitelist-rule-that-catches-everyone).
- **`rcon_localhost_bypass` forced to `1`** where the cvar exists, so `rcon-cli` works from inside the container.
Maintainers: update them with `tools/refresh-seeds.sh` (all games) or `tools/refresh-seeds.sh t6 iw4x` (a subset), then commit the diff.

58
docs/healthcheck.md Normal file
View file

@ -0,0 +1,58 @@
# Healthcheck & restarts
## What "healthy" means
Plutainer asks the server for its status and requires it to **name a loaded map**. A container is healthy only when the game is genuinely serving — not merely when the process is alive.
1. Work out the game and port.
2. Send an unauthenticated `getstatus` to `127.0.0.1`, falling back to `getinfo`.
3. Require a map name in the reply.
```
[OK] Health check passed: Server is responsive on port 4976 (map: zm_buried (via getstatus)).
```
**No RCON password required.** The engine answers these queries from the same connectionless handler, in the same server frame loop, that answers RCON `status`, and reports the map from the same cvar — so a stalled server or one that has lost its map fails this check exactly as it would have failed an RCON-based one, by not replying or by replying with no map.
Why both queries: IW5 and T5 only answer `getinfo`; everything else answers `getstatus`, which is preferred because T7x's `getinfo` reports the *lobby's* map rather than the running one.
Disable with `PLUTAINER_HEALTHCHECK=false`.
### Start period
The healthcheck allows **five minutes** before failures count, because a first start downloads a lot (IW4x 12 GB, Plutonium ~500 MB). During that window `docker ps` shows `starting`.
## Restart behaviour
Plutainer separates *your* mistakes from the game's:
**Configuration errors** — missing `PLUTAINER_CONFIG_FILE`, an unknown `PLUTAINER_GAME`, a v1 volume, a missing Plutonium key. The container prints the reason and then holds in `Up` with `sleep infinity`.
No restart loop, no log spam burying the error. Fix it and `docker restart <container>`. The healthcheck keeps running and eventually marks the container unhealthy, so orchestration still sees something is wrong.
**Runtime crashes** — the game exits on its own. Plutainer waits **30 seconds**, then exits with the game's code, letting your `restart:` policy take over. That throttles a crash loop to roughly one restart per 30s instead of hammering.
`STOPSIGNAL` is `SIGKILL`, so `docker stop` is immediate either way.
## Auto-restarting unhealthy servers
Docker restarts containers that *exit*; it does nothing about a container that's `Up` but unhealthy. Add [Auto Heal](https://github.com/willfarrell/docker-autoheal):
```yaml
autoheal:
image: willfarrell/autoheal
restart: unless-stopped
environment:
- AUTOHEAL_CONTAINER_LABEL=autoheal
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
then label the servers you want watched:
```yaml
labels:
- autoheal=true
```
Be deliberate about pairing this with a *configuration* failure: a held container is unhealthy by design, and autoheal will restart it forever without fixing anything. Read the logs before assuming a restart loop is the game's fault.

171
docs/iw4madmin.md Normal file
View file

@ -0,0 +1,171 @@
# IW4MAdmin
Running [IW4MAdmin](https://github.com/RaidMax/IW4M-Admin) alongside Plutainer servers.
Verified against eleven servers at once — every supported game, MP and ZM — all attached.
## What IW4MAdmin needs from each server
1. **An RCON password.** Set `PLUTAINER_RCON_PASSWORD` ([RCON](rcon.md)).
2. **The game log**, so it can read events — and it must be mounted as a **file**, not reached through a symlink. See [the symlink trap](#the-symlink-trap-no-events-at-all) below; getting this wrong costs you every in-game event with no error message.
3. **Network reach** to the server's port.
## Compose
```yaml
services:
t6zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t6zm-1
restart: unless-stopped
ports: ["4976:4976/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTAINER_RCON_PASSWORD: ${T6ZM_RCON}
PLUTO_SERVER_KEY: ${T6ZM_KEY}
networks: [games-net]
iw4madmin:
image: ghcr.io/raidmax/iw4madmin:latest
container_name: iw4madmin
restart: unless-stopped
ports: ["1624:1624"]
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- ./IW4MAdmin/Configuration:/app/Configuration
- ./IW4MAdmin/Plugins:/app/Plugins
- ./IW4MAdmin/Localization:/app/Localization
- ./IW4MAdmin/Database:/app/Database
- ./IW4MAdmin/Log:/app/Log
# Mount the LOG FILE itself, not the directory. Docker resolves the
# symlink on the host at mount time, so the container sees a real file.
- ./t6zm-1/logs/games_zm.log:/gamelogs/t6zm-1/logs/games_zm.log:ro
depends_on:
t6zm-1:
condition: service_healthy # wait for the server, not just the container
networks: [iw4m-net]
networks:
games-net:
driver: bridge
iw4m-net:
driver: bridge
```
`depends_on: service_healthy` matters more than it looks: IW4x spends its first few minutes downloading 12 GB, and IW4MAdmin aborts startup if a configured server doesn't answer. Without it, a fresh deployment can come up with IW4MAdmin dead.
A full working example is in [`examples/with-iw4madmin.yml`](../examples/with-iw4madmin.yml).
## Server entries
In `IW4MAdmin/Configuration/IW4MAdminSettings.json`. Address the servers by your **host IP** and published port:
```json
{
"Servers": [
{
"IPAddress": "10.0.0.5",
"Port": 4976,
"Password": "your-rcon-password",
"ManualLogPath": "/gamelogs/t6zm-1/logs/games_zm.log",
"RConParserVersion": "Plutonium T6 Parser (2024)",
"EventParserVersion": "Plutonium T6 Parser (2024)"
}
]
}
```
`ManualLogPath` matches the mount path above. Because the mount was taken through `logs/`, Docker resolved Plutainer's stable symlink at mount time and IW4MAdmin sees a plain file — which is what it needs.
### Parsers and log names
| Game | Parser | Log file |
| --- | --- | --- |
| T4 MP | `Plutonium T4 MP Parser` | `games_mp.log` |
| T4 ZM (`t4sp`) | `Plutonium T4 CO-OP/Zombies Parser` | `games_zm.log` |
| T5 MP | `Plutonium T5 Parser` | `games_mp.log` |
| T5 ZM (`t5sp`) | `Plutonium T5 Parser` | `games_sp.log` |
| T6 MP / ZM | `Plutonium T6 Parser (2024)` | `games_mp.log` / `games_zm.log` |
| IW5 | `Plutonium IW5 Parser` | `games_mp.log` |
| IW4x | `IW4x Parser` | `games_mp.log` |
| T7x | `BOIII Parser` | `games_mp.log` / `games_zm.log` |
| CoD4x | `CoD4x Parser` | `games_mp.log` |
IW4MAdmin needs a pre-existing `IW4MAdminSettings.json` before its container will start — it won't generate one unattended.
## The symlink trap: no events at all
If IW4MAdmin connects, shows the server online and responds to RCON, but **never sees chat, joins, or in-game `!commands`**, this is almost certainly why.
IW4MAdmin decides whether to read by watching the log file's size:
```csharp
var fileSize = _reader.Length; // new FileInfo(path).Length
var fileDiff = fileSize - _previousFileSize;
if (fileDiff < 1 ...) return; // nothing new, don't read
```
On Linux, .NET's `FileInfo.Length` for a **symlink** returns the length of the link text, not of the target. Plutainer's `app/logs/games_mp.log` is a symlink, so its "size" is a constant 38 bytes no matter how much the real log grows. The difference is never positive, so **IW4MAdmin never reads a single line** — and logs no error, because nothing failed.
Measured directly: with `ManualLogPath` pointing at the symlink, a real map change grew the log from 191 to 861 bytes and IW4MAdmin logged nothing. Repointing it at the real file and repeating the map change produced `New map loaded` within two seconds.
Two ways to get it right:
- **Mount the log file** (what the examples do). Docker resolves the host-side symlink when it creates the mount, so the container sees a regular file and you still get to name it via the stable `logs/` path.
- **Point at the real path** under `runtime/`, e.g. `/gamelogs/t6zm-1/runtime/plutonium/storage/t6/main/logs/games_zm.log`. Works, but the path differs per game and moves when you change mods.
One caveat with the file mount: the target must exist when the container starts, or Docker creates a **directory** in its place. Start the game server first — `depends_on: condition: service_healthy` handles both this and the startup race.
## The whitelist rule that catches everyone
If IW4MAdmin attaches to your other games but **T5 and T6 fail** with:
```
Not monitoring server due to uncorrectable errors [10.0.0.5:4976]
NetworkException: Reached maximum retry attempts to send RCon data to server
```
…here's what's happening. T5 and T6 gate *unauthenticated* queries — `getinfo` and `getstatus` — on the RCON whitelist, and IW4MAdmin's T5/T6 parsers open the connection with `getinfo`. So the RCON handshake succeeds and the connection still fails:
```
getinfo -> (no reply) ← fails here
rcon <pw> version -> "Plutonium T6 …" ← RCON is fine
rcon <pw> sv_running -> "1"
getinfo ×6 -> (no reply) ← gives up
```
Measured on T6, from off-loopback:
| whitelist state | RCON | `getinfo` |
| --- | --- | --- |
| upstream's placeholder IPs | blocked | blocked |
| empty | works | blocked |
| gateway whitelisted | works | works |
Note the middle row: an **empty whitelist is not permissive** for these queries, even though it is for RCON commands — which is the opposite of what the comment in the stock config implies.
**Plutainer handles this automatically.** The address a sidecar appears to come from is the server container's Docker bridge gateway, assigned at run time and therefore impossible to bake into a config file — so Plutainer detects it at startup and passes `+rconWhitelistAdd <gateway>` as a launch argument. Your config is not modified.
You only need to intervene if your admin tool is on another machine:
```yaml
environment:
PLUTAINER_RCON_WHITELIST: "10.10.1.20"
```
`PLUTAINER_RCON_WHITELIST_GATEWAY=false` disables the automatic entry. Note that any whitelist entry makes the whitelist non-empty, restricting T5/T6 RCON to the listed addresses plus loopback — the posture upstream's placeholder entries intended.
T4, IW5, IW4x, T7x and CoD4x answer queries regardless and get no whitelist entries.
## Networking
Running IW4MAdmin on a **separate bridge network** from the game servers is the tested arrangement, and what the examples use. Traffic then arrives at the game from the game's own gateway, which is the address Plutainer whitelists.
Running IW4MAdmin bare-metal on the host, or on a different machine entirely, also works — in that case whitelist its address with `PLUTAINER_RCON_WHITELIST` for T5/T6.

112
docs/quickstart.md Normal file
View file

@ -0,0 +1,112 @@
# Quickstart
Getting one server running, start to finish. Budget ten minutes, most of it waiting for downloads.
## Before you start
You need three things:
1. **Docker and Docker Compose** on a Linux host.
2. **The base game files**, which you must own. Plutainer ships no game content. What exactly each game needs is in [Games](games.md).
3. **A Plutonium server key** — only for T4, T5, T6 and IW5. Free from <https://platform.plutonium.pw/serverkeys>. IW4x, T7x and CoD4x need nothing.
## 1. Put the game files somewhere
Anywhere on the host. They're mounted read-only, so they can be shared by as many servers as you like:
```
/opt/game-files/
T6ServerFiles/
IW4xServerFiles/
```
## 2. Write a compose file
Pick your game's example from [`examples/`](../examples/), or start from this one:
```yaml
services:
t6zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t6zm-1
restart: unless-stopped
ports:
- "4976:4976/udp"
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTO_SERVER_KEY: ${T6ZM_KEY}
PLUTAINER_RCON_PASSWORD: change-me-or-remove-this-line
```
Four settings matter:
| | |
| --- | --- |
| `PLUTAINER_GAME` | Which game. [Full list](games.md) |
| `PLUTAINER_CONFIG_FILE` | Which config to run. **Must be one Plutainer seeds** unless you supply your own — [names per game](games.md) |
| the gamefiles mount | Your base game files, read-only |
| the app mount | Where server data, configs and logs live |
Put the key in a `.env` file next to your compose file so it stays out of the compose:
```
T6ZM_KEY=your-key-here
```
## 3. Start it
```sh
docker compose up -d
docker compose logs -f
```
**First start takes a while.** Plutonium downloads ~500 MB, IW4x 12 GB. CoD4x is ready in under a minute since everything ships in the image. The healthcheck allows five minutes before it starts judging.
## 4. Check it worked
```sh
docker ps
```
`healthy` means the server answered a status query and reported a loaded map — it's genuinely up, not just running. If it says `unhealthy` or never leaves `starting`, go to [Troubleshooting](troubleshooting.md).
## 5. Edit your config
Everything is in one folder, whatever the game:
```sh
nano ./t6zm-1/configs/dedicated_zm.cfg
docker restart t6zm-1
```
Plutainer symlinks that file to wherever the engine expects it, so you never go hunting through `runtime/`. Details in [Volumes & configs](volumes-and-configs.md).
## What you get
```
t6zm-1/
configs/ ← edit your *.cfg here
logs/ ← stable symlinks to the live logs
runtime/ ← game files, binaries, engine state (leave alone)
```
## Next steps
- **Send commands to the server** → [RCON](rcon.md). Set `PLUTAINER_RCON_PASSWORD` first; it's empty by default so RCON is off.
- **Add an admin tool** → [IW4MAdmin](iw4madmin.md)
- **Run several servers** → [`examples/multi-server.yml`](../examples/multi-server.yml). Each needs its own port and its own app directory; the gamefiles mount can be shared.
- **Restart servers automatically when they die** → [Healthcheck & restarts](healthcheck.md)
## A note on permissions
The container runs as UID `1000`. If you create the app directory as root, the container may not be able to write to it:
```sh
sudo chown -R 1000:1000 ./t6zm-1
```
Most desktop Linux users are already UID 1000 and never hit this. If the logs say `Permission denied` while creating `configs`, `logs` or `runtime`, this is why.

81
docs/rcon.md Normal file
View file

@ -0,0 +1,81 @@
# RCON
Sending commands to a running server.
## Set a password first
**RCON is disabled out of the box.** Every bundled config ships `rcon_password ""`, deliberately — a password shipped inside a public image is a password everyone has, on a port anyone can find by scanning.
Two ways to set one:
```yaml
environment:
PLUTAINER_RCON_PASSWORD: "something-only-you-know"
```
or edit `app/configs/<your>.cfg` directly and restart.
`PLUTAINER_RCON_PASSWORD` is opt-in and never destructive: leave it unset (or empty, which is what an unfilled compose variable looks like) and your config is untouched, so a password you set by hand is never wiped. When set, it rewrites the value on the existing `rcon_password` line — keeping any trailing `//` comment — or appends one if there isn't a line.
The healthcheck does **not** need any of this; it uses an unauthenticated status query.
> **CoD4x rejects passwords shorter than 8 characters.** It answers
> `No rconpassword set on server or password is shorter than 8 characters`
> and ignores the command — which looks exactly like a wrong password.
## rcon-cli
Built into the image. It works out which game, port and password to use by itself:
```sh
# one-shot
docker exec t6zm-1 rcon-cli status
# interactive session
docker exec -i t6zm-1 rcon-cli
```
```
> status
map: zm_buried
num score bot ping guid name address
--- ----- --- ---- -------------------------------- ---------------- --------
```
## Password formats it understands
In your cfg, all of these parse:
```
set rcon_password "your_password_here"
seta rcon_password 'also_works'
set rcon_password unquoted_also_ok
```
Commented lines (`// …`) are ignored. If several uncommented `rcon_password` lines exist, the last wins. Set it via `PLUTAINER_EXTRA_ARGS` and Plutainer *cannot* read it back — `rcon-cli` and IW4MAdmin will both fail to authenticate.
## Who is allowed to send RCON
Loopback — which is where `rcon-cli` runs from, inside the container — always works.
From outside the container it depends on the game:
| Game | Off-container RCON |
| --- | --- |
| T4, IW5, IW4x, T7x, CoD4x | allowed once a password is set |
| **T5, T6** | only from whitelisted addresses |
For T5/T6 Plutainer automatically whitelists the container's Docker gateway at startup, which is the address a sidecar admin tool appears to come from. If your admin tool is on **another machine**, add it:
```yaml
environment:
PLUTAINER_RCON_WHITELIST: "10.10.1.20,10.10.1.21"
```
Only single addresses are accepted — a CIDR range like `172.16.0.0/12` is rejected by the engine with `Error: Invalid address`.
The full explanation, including why this bites IW4MAdmin specifically, is in [IW4MAdmin](iw4madmin.md#the-whitelist-rule-that-catches-everyone).
## Rate limiting
T5 and T6 configs ship `rcon_rate_limit "500"` (milliseconds, per IP). If you use IW4MAdmin's Game Interface and see dropped commands, lowering it to `100` is the usual fix — upstream's own comment says so.

189
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,189 @@
# 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](healthcheck.md#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](games.md#at-a-glance). 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:
```sh
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](../MIGRATION.md) — one `docker run` migrates the volume.
### First start is taking forever
Expected. IW4x downloads 12 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` — expected without `PLUTAINER_COD4X_AUTH_TOKEN`. The server is playable, just unlisted.
- **Plutonium**: `Could not send heartbeat to nix! … 401` means 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:
```sh
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](healthcheck.md#auto-restarting-unhealthy-servers).
### 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](rcon.md)). 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.md#the-whitelist-rule-that-catches-everyone).
### 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](../examples/with-iw4madmin.yml)).
### 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:
```yaml
- ./t6zm-1/logs/games_zm.log:/gamelogs/t6zm-1/logs/games_zm.log:ro
```
Full explanation in [IW4MAdmin](iw4madmin.md#the-symlink-trap-no-events-at-all).
### 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](volumes-and-configs.md#raw-configs-mode)).
### 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](games.md#bundled-configs). 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](https://github.com/iw4x/launcher/issues/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.
### `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.

125
docs/volumes-and-configs.md Normal file
View file

@ -0,0 +1,125 @@
# Volumes & configs
Where everything lives, and why your configs are in one folder instead of scattered through the game tree.
## Two mounts
| Container path | What it is | Mount as |
| --- | --- | --- |
| `/home/plutainer/gamefiles` | Base game files you own | bind-mount, `:ro` |
| `/home/plutainer/app` | Server state, configs, logs | bind-mount or named volume |
The gamefiles mount is read-only and shareable — point ten servers at the same copy. Anything an updater downloads goes into `app/` instead, so don't stage binaries in the gamefiles mount.
## What appears in `app/`
Created on first start:
```
app/
configs/ ← your *.cfg files. This is the one you care about
logs/ ← stable symlinks to the active *.log files
runtime/
gamefiles/ ← symlinks into your read-only mount + writable game state
plutonium/ ← Plutonium binaries and storage
.plutainer-version ← layout marker
```
You edit `configs/`. You read `logs/`. You can ignore `runtime/`.
## How configs actually work
Every game reads its config from a different place — `main/`, `admin/`, `userraw/`, `zone/`, `plutonium/storage/t6/`. Rather than make you learn each one, Plutainer keeps the real file in `app/configs/` and puts a symlink at the engine's path.
```
app/configs/dedicated_zm.cfg ← the real file, you edit this
app/runtime/plutonium/storage/t6/dedicated_zm.cfg ────┘ ← symlink, the game reads this
```
Consequences worth knowing:
- **Edit in one place**, whatever the game.
- **RCON `writeconfig` writes through the symlink**, so it updates the real file in `configs/`.
- **If `PLUTAINER_MOD` is set**, the same config is also linked into the mod's config dir, so the engine finds it whether it looks in the base or mod-scoped location.
- **Nested configs stay put.** Files in subdirectories (gametype configs, mod configs) remain under `runtime/` at their engine path. Edit them there.
- **A real file at the engine path is never clobbered.** Plutainer warns and leaves it alone.
### Auto-lift
Put a config at the engine path by hand and Plutainer moves it into `app/configs/` on the next start, then links it back. One-time, no manual migration.
### Filename mismatches
If `PLUTAINER_CONFIG_FILE` names something that doesn't exist, the container refuses to start and prints a case-insensitive search of what it did find — so `Server.cfg` vs `server.cfg` is obvious immediately. Filenames stay case-sensitive; nothing is auto-renamed.
## Raw configs mode
`PLUTAINER_USE_RAW_CONFIGS=true` turns the symlink system off:
- The engine config dir under `app/runtime/…` becomes the source of truth
- `app/configs/` is ignored
- Seeds go straight to the engine dir
Use it when host-side tooling expects the real file at the engine path. You can toggle it between restarts, but Plutainer won't move existing files when you flip it — that part's yours.
## Logs
`app/logs/` holds symlinks to the live log file for each basename. Game logs move around per game and per mod (`runtime/plutonium/storage/t5/mods/<mod>/logs/games_zm.log`), so the watcher surfaces them all in one predictable place:
```
app/logs/games_mp.log -> ../runtime/gamefiles/main/games_mp.log
```
Point log readers at that directory:
```yaml
volumes:
- ./t6zm-1/logs:/app/gamelogs/t6zm-1:ro
```
The symlinks are **relative**, so they resolve identically from the host, from inside the container, or from a sidecar mounting the same `app/` volume.
> **Mounting one of these into a sidecar: mount the file, not the directory.** `- ./t6zm-1/logs/games_zm.log:/gamelogs/t6zm-1/logs/games_zm.log:ro` makes Docker resolve the symlink at mount time, so the sidecar sees a real file. Mount the directory instead and the sidecar sees a symlink — which breaks any reader that judges "has this file grown?" by the file's size, IW4MAdmin included. Details in [IW4MAdmin](iw4madmin.md#the-symlink-trap-no-events-at-all).
Disable with `PLUTAINER_LOG_SYMLINKS=false`; tune with `PLUTAINER_LOG_POLL_INTERVAL`.
### Rotation
Game logs are rotated once they reach **64 MB**, keeping one previous copy alongside the live file (`games_mp.log.1`). Worst case is therefore ~128 MB per server — deliberately modest, because thirty servers each sitting on gigabyte logs adds up to tens of gigabytes nobody reads.
```yaml
environment:
PLUTAINER_LOG_MAX_SIZE: "64M" # 0 disables rotation entirely
PLUTAINER_LOG_KEEP: "1" # 0 keeps no copy at all
```
This is not just housekeeping. **A large enough log stops the server logging permanently.** CoD4x buffers log output and hands each chunk to `fwrite()`; when that write fails the engine prints a warning and drops the data, having already advanced its buffer. Nothing retries and nothing reopens the file, so the log is dead for the life of the process — and since IW4MAdmin reads in-game commands from the log, it looks like the admin tool has stopped responding even though RCON still works.
Rotation is copy-truncate: the live file is copied aside and then truncated in place. That is safe because every engine here opens its game log in **append mode** (CoD4x is literally `fopen(path, "ab")`), so the next write seeks to end-of-file and resumes at offset 0. Renaming would be wrong — the engine holds an open handle and would keep writing to the renamed file, so the path your admin tool reads would silently stop updating, which is the very failure being fixed.
Verified on all eleven server types, and confirmed on a live CoD4x server: after truncation a map change wrote a fresh `InitGame:` line to the emptied file.
Two related details, both for the benefit of log readers that track a byte offset:
- After truncating, Plutainer writes a one-line marker so the file is never seen at zero bytes. IW4MAdmin treats a zero offset as "no position yet" and re-syncs instead of reading, which would otherwise swallow the first batch of events after every rotation.
- A brand-new empty log is primed with the same kind of line, for the same reason — otherwise the first map's events on a fresh deployment are never ingested. Measured across eleven servers: the three whose logs happened to be empty missed their first event and caught the second; priming removes that.
## Trimming the gamefiles mount
Deleting files from the read-only mount is fine — Plutainer relinks from scratch on each start and removes links whose target is gone. Nothing needs rebuilding. What each game genuinely needs is in [Games](games.md#what-goes-in-the-gamefiles-mount).
## Permissions
The container runs as UID `1000`. If `docker logs` shows:
```
mkdir: cannot create directory '/home/plutainer/app/configs': Permission denied
```
fix ownership on the host:
```sh
sudo chown -R 1000:1000 ./your-server-dir
```
This usually bites when the directory was created with `sudo`.

31
examples/README.md Normal file
View file

@ -0,0 +1,31 @@
# Examples
Copy the one that matches what you're doing, adjust the paths, `docker compose up -d`.
| File | What it is |
| --- | --- |
| [`single-server.yml`](single-server.yml) | One server. Start here |
| [`per-game.yml`](per-game.yml) | One ready-made service block for **every** supported game — copy the ones you want |
| [`multi-server.yml`](multi-server.yml) | Several servers sharing one set of game files |
| [`with-iw4madmin.yml`](with-iw4madmin.yml) | Servers plus an IW4MAdmin sidecar |
| [`env.example`](env.example) | Keys and passwords, kept out of the compose file |
## The three things you must change
1. **The gamefiles path**`/opt/game-files/…` in every example. Point it at your own copy.
2. **`PLUTAINER_CONFIG_FILE`** — must name a config Plutainer seeds for that game ([list](../docs/games.md#at-a-glance)).
3. **Ports** — one server per port. Publish UDP.
## Secrets
Put keys and RCON passwords in a `.env` file next to your compose file, and reference them as `${VARIABLE}`:
```
cp examples/env.example .env
```
`.env` is picked up automatically by `docker compose`. Don't commit it.
## New to this?
Read the [Quickstart](../docs/quickstart.md) first — it walks through the same thing with explanations.

29
examples/env.example Normal file
View file

@ -0,0 +1,29 @@
# Copy to .env next to your docker-compose.yml. Never commit it.
#
# cp examples/env.example .env
#
# docker compose reads .env automatically and substitutes ${NAME} in the
# compose file.
# Plutonium server keys — one per server, from
# https://platform.plutonium.pw/serverkeys
# Required for t4/t5/t6/iw5. T5 in particular will not load a map without a
# valid one.
T4MP_KEY=
T4ZM_KEY=
T5MP_KEY=
T5ZM_KEY=
T6MP_KEY=
T6ZM_KEY=
IW5MP_KEY=
# RCON passwords. Optional, but RCON (and IW4MAdmin) stay disabled without
# them. Use a different one per server.
T6MP_RCON=
T6ZM_RCON=
IW4X_RCON=
COD4X_RCON=
# CoD4x masterserver token — optional. Without it the server runs fine but
# stays unlisted.
COD4X_TOKEN=

79
examples/multi-server.yml Normal file
View file

@ -0,0 +1,79 @@
# Several servers on one host.
#
# The pattern: one game-files copy shared read-only by everything that uses it,
# one app directory and one port per server.
x-plutainer: &plutainer
image: ghcr.io/ayymoss/plutainer:latest
restart: unless-stopped
networks: [games-net]
services:
t6zm-1:
<<: *plutainer
container_name: t6zm-1
ports: ["4976:4976/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro # shared
- ./t6zm-1:/home/plutainer/app # per-server
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTAINER_PORT: "4976"
PLUTO_SERVER_KEY: ${T6ZM_KEY}
PLUTAINER_RCON_PASSWORD: ${T6ZM_RCON}
t6mp-1:
<<: *plutainer
container_name: t6mp-1
ports: ["4977:4977/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro # same files
- ./t6mp-1:/home/plutainer/app # own data
environment:
PLUTAINER_GAME: t6mp
PLUTAINER_CONFIG_FILE: dedicated.cfg
PLUTAINER_PORT: "4977"
PLUTO_SERVER_KEY: ${T6MP_KEY}
PLUTAINER_RCON_PASSWORD: ${T6MP_RCON}
iw4x-1:
<<: *plutainer
container_name: iw4x-1
ports: ["28960:28960/udp"]
volumes:
- /opt/game-files/IW4xServerFiles:/home/plutainer/gamefiles:ro
- ./iw4x-1:/home/plutainer/app
environment:
PLUTAINER_GAME: iw4x
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28960"
PLUTAINER_RCON_PASSWORD: ${IW4X_RCON}
cod4x-1:
<<: *plutainer
container_name: cod4x-1
ports: ["28965:28965/udp"]
volumes:
- /opt/game-files/IW3ServerFiles:/home/plutainer/gamefiles:ro
- ./cod4x-1:/home/plutainer/app
environment:
PLUTAINER_GAME: cod4x
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28965"
PLUTAINER_RCON_PASSWORD: ${COD4X_RCON}
# Restarts servers that go unhealthy. Docker's own restart policy only
# reacts to a container *exiting*, which a hung server never does.
autoheal:
image: willfarrell/autoheal
container_name: autoheal
restart: unless-stopped
environment:
- AUTOHEAL_CONTAINER_LABEL=all
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
games-net:
driver: bridge

173
examples/per-game.yml Normal file
View file

@ -0,0 +1,173 @@
# One service block per supported game. Delete what you don't want.
#
# Every block is complete and independent — copy one into your own compose file
# and it works, provided the gamefiles path and port are right.
#
# Ports here are the per-game defaults where possible; where two servers would
# collide, the second is offset. One port per server.
services:
# ---------------------------------------------------------------- Plutonium
# T4 — World at War. Multiplayer.
t4mp-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t4mp-1
restart: unless-stopped
ports: ["28960:28960/udp"]
volumes:
- /opt/game-files/T4ServerFiles:/home/plutainer/gamefiles:ro
- ./t4mp-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t4mp
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28960"
PLUTO_SERVER_KEY: ${T4MP_KEY}
# T4 — World at War. Zombies runs on the sp binary.
t4zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t4zm-1
restart: unless-stopped
ports: ["28961:28961/udp"]
volumes:
- /opt/game-files/T4ServerFiles:/home/plutainer/gamefiles:ro
- ./t4zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t4sp
PLUTAINER_CONFIG_FILE: server_zm.cfg
PLUTAINER_PORT: "28961"
PLUTO_SERVER_KEY: ${T4ZM_KEY}
# T5 — Black Ops. Multiplayer.
# NOTE: T5 needs a *valid* Plutonium key. A placeholder key leaves it stuck
# in a map-rotation loop forever. See docs/games.md#t5-black-ops.
t5mp-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t5mp-1
restart: unless-stopped
ports: ["28962:28962/udp"]
volumes:
- /opt/game-files/T5ServerFiles:/home/plutainer/gamefiles:ro
- ./t5mp-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t5mp
PLUTAINER_CONFIG_FILE: dedicated.cfg
PLUTAINER_PORT: "28962"
PLUTO_SERVER_KEY: ${T5MP_KEY}
# T5 — Black Ops. Zombies runs on the sp binary.
t5zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t5zm-1
restart: unless-stopped
ports: ["28963:28963/udp"]
volumes:
- /opt/game-files/T5ServerFiles:/home/plutainer/gamefiles:ro
- ./t5zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t5sp
PLUTAINER_CONFIG_FILE: dedicated_sp.cfg
PLUTAINER_PORT: "28963"
PLUTO_SERVER_KEY: ${T5ZM_KEY}
# Mods are a path under the game files, e.g.:
# PLUTAINER_MOD: mods/Stairway_to_hell_bo1
# T6 — Black Ops II. Multiplayer.
t6mp-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t6mp-1
restart: unless-stopped
ports: ["4977:4977/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6mp-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6mp
PLUTAINER_CONFIG_FILE: dedicated.cfg
PLUTAINER_PORT: "4977"
PLUTO_SERVER_KEY: ${T6MP_KEY}
# T6 — Black Ops II. Zombies.
t6zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t6zm-1
restart: unless-stopped
ports: ["4976:4976/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTAINER_PORT: "4976"
PLUTO_SERVER_KEY: ${T6ZM_KEY}
# IW5 — Modern Warfare 3. Multiplayer only.
iw5mp-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: iw5mp-1
restart: unless-stopped
ports: ["27016:27016/udp"]
volumes:
- /opt/game-files/IW5ServerFiles:/home/plutainer/gamefiles:ro
- ./iw5mp-1:/home/plutainer/app
environment:
PLUTAINER_GAME: iw5mp
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "27016"
PLUTO_SERVER_KEY: ${IW5MP_KEY}
# --------------------------------------------------------------------- IW4x
# Modern Warfare 2. No server key. First start downloads 1-2 GB.
# amd64 only — see docs/games.md#architecture-support.
iw4x-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: iw4x-1
restart: unless-stopped
ports:
- "28964:28964/udp"
# TCP is only needed if you host mods (modlist metadata):
# - "28964:28964/tcp"
volumes:
- /opt/game-files/IW4xServerFiles:/home/plutainer/gamefiles:ro
- ./iw4x-1:/home/plutainer/app
environment:
PLUTAINER_GAME: iw4x
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28964"
# ---------------------------------------------------------------- Alterware
# T7x — Black Ops III. No server key.
t7x-zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t7x-zm-1
restart: unless-stopped
ports: ["27017:27017/udp"]
volumes:
- /opt/game-files/T7xServerFiles:/home/plutainer/gamefiles:ro
- ./t7x-zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t7x
PLUTAINER_CONFIG_FILE: server_zm.cfg
PLUTAINER_PORT: "27017"
# On T7x, PLUTAINER_MOD is a Steam Workshop ID:
# PLUTAINER_MOD: "2833912385"
# -------------------------------------------------------------------- CoD4x
# Modern Warfare. No server key, multiplayer only, amd64 only.
# The gamefiles mount needs only main/ and zone/.
cod4x-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: cod4x-1
restart: unless-stopped
ports: ["28965:28965/udp"]
volumes:
- /opt/game-files/IW3ServerFiles:/home/plutainer/gamefiles:ro
- ./cod4x-1:/home/plutainer/app
environment:
PLUTAINER_GAME: cod4x
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28965"
# Optional: without a token the server runs but stays unlisted.
# PLUTAINER_COD4X_AUTH_TOKEN: ${COD4X_TOKEN}

View file

@ -0,0 +1,38 @@
# One Black Ops II zombies server — the smallest thing that works.
#
# 1. Point the gamefiles path at your own copy
# 2. Put T6ZM_KEY in a .env file beside this one (see env.example)
# 3. docker compose up -d
#
# Swap the three PLUTAINER_* values to run a different game — see
# docs/games.md for the tags, config names and default ports.
services:
t6zm-1:
image: ghcr.io/ayymoss/plutainer:latest
container_name: t6zm-1
hostname: t6zm-1
restart: unless-stopped
ports:
- "4976:4976/udp"
volumes:
# Your game files. Read-only, and shareable with other servers.
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
# Server data: configs, logs, downloaded binaries. Yours to keep.
- ./t6zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTAINER_PORT: "4976"
# Plutonium titles only (t4/t5/t6/iw5). Get one at
# https://platform.plutonium.pw/serverkeys
PLUTO_SERVER_KEY: ${T6ZM_KEY}
# Optional. RCON is disabled until a password exists.
PLUTAINER_RCON_PASSWORD: ${T6ZM_RCON}
# After first start, edit ./t6zm-1/configs/dedicated_zm.cfg and restart.

109
examples/with-iw4madmin.yml Normal file
View file

@ -0,0 +1,109 @@
# Game servers plus an IW4MAdmin sidecar.
#
# Three things make this work, and all three are easy to miss:
#
# 1. Every server has an RCON password (IW4MAdmin authenticates with it).
# 2. IW4MAdmin mounts each server's LOG FILE, not a directory. Its reader
# checks the file's size to decide whether to read, and .NET reports a
# symlink's size as the length of the link text — so pointing it at
# Plutainer's logs/ symlink means it silently never reads an event.
# Mounting through logs/ makes Docker resolve the symlink at mount time,
# so the container sees a real file. See docs/iw4madmin.md.
# 3. depends_on: service_healthy — IW4MAdmin aborts startup if a configured
# server doesn't answer, and IW4x can take minutes to download on a
# first run.
#
# On T5/T6, Plutainer whitelists the Docker gateway for RCON automatically.
# See docs/iw4madmin.md.
x-plutainer: &plutainer
image: ghcr.io/ayymoss/plutainer:latest
restart: unless-stopped
networks: [games-net]
services:
t6zm-1:
<<: *plutainer
container_name: t6zm-1
ports: ["4976:4976/udp"]
volumes:
- /opt/game-files/T6ServerFiles:/home/plutainer/gamefiles:ro
- ./t6zm-1:/home/plutainer/app
environment:
PLUTAINER_GAME: t6zm
PLUTAINER_CONFIG_FILE: dedicated_zm.cfg
PLUTAINER_PORT: "4976"
PLUTO_SERVER_KEY: ${T6ZM_KEY}
PLUTAINER_RCON_PASSWORD: ${T6ZM_RCON}
cod4x-1:
<<: *plutainer
container_name: cod4x-1
ports: ["28965:28965/udp"]
volumes:
- /opt/game-files/IW3ServerFiles:/home/plutainer/gamefiles:ro
- ./cod4x-1:/home/plutainer/app
environment:
PLUTAINER_GAME: cod4x
PLUTAINER_CONFIG_FILE: server.cfg
PLUTAINER_PORT: "28965"
PLUTAINER_RCON_PASSWORD: ${COD4X_RCON}
iw4madmin:
image: ghcr.io/raidmax/iw4madmin:latest
container_name: iw4madmin
restart: unless-stopped
ports:
- "1624:1624"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- ./IW4MAdmin/Configuration:/app/Configuration
- ./IW4MAdmin/Plugins:/app/Plugins
- ./IW4MAdmin/Localization:/app/Localization
- ./IW4MAdmin/Database:/app/Database
- ./IW4MAdmin/Log:/app/Log
# The log FILE per server, read-only. The game server must be running
# before this container starts, or Docker creates a directory here —
# depends_on below handles that.
- ./t6zm-1/logs/games_zm.log:/gamelogs/t6zm-1/logs/games_zm.log:ro
- ./cod4x-1/logs/games_mp.log:/gamelogs/cod4x-1/logs/games_mp.log:ro
depends_on:
t6zm-1:
condition: service_healthy
cod4x-1:
condition: service_healthy
networks: [iw4m-net]
networks:
games-net:
driver: bridge
iw4m-net:
driver: bridge
# IW4MAdmin needs Configuration/IW4MAdminSettings.json to exist before it will
# start. Server entries look like this — address servers by your host IP and
# published port. Parser names per game are in docs/iw4madmin.md.
#
# {
# "Servers": [
# {
# "IPAddress": "10.0.0.5",
# "Port": 4976,
# "Password": "your-rcon-password",
# "ManualLogPath": "/gamelogs/t6zm-1/logs/games_zm.log",
# "RConParserVersion": "Plutonium T6 Parser (2024)",
# "EventParserVersion": "Plutonium T6 Parser (2024)"
# },
# {
# "IPAddress": "10.0.0.5",
# "Port": 28965,
# "Password": "your-rcon-password",
# "ManualLogPath": "/gamelogs/cod4x-1/logs/games_mp.log",
# "RConParserVersion": "CoD4x Parser",
# "EventParserVersion": "CoD4x Parser"
# }
# ]
# }