# 7 Days to Die — panel-native dedicated server module. # # Unlike the earlier vinanrra/LGSM-based revision, this module owns the # whole stack: # # 1. A minimal Debian runtime image (see ./Dockerfile) is built locally # as panel-7dtd:latest. It contains ONLY the libraries the game binary # needs — no LGSM, no tmux, no GitHub-fetch-on-start, no surprise # backups. Just the server process. # 2. Game files are installed + validated into a Docker volume by the # panel's generic SteamCMD updater (`agent/internal/updater/steamcmd.go`). # That sidecar uses the official steamcmd/steamcmd:latest image. # 3. The entrypoint renders our serverconfig.xml template, drops into the # `panel` user, and execs startserver.sh under tini with line-buffered # stdout so progress streams live to the panel console. # # First-run flow for operators: # - Create instance in the panel → container spec created, volumes empty # - Click "Update" on the instance → SteamCMD sidecar downloads ~17 GB # - Click "Start" → entrypoint launches 7DTD directly # # Subsequent starts skip SteamCMD entirely — instant launch. id: 7dtd name: "7 Days to Die" version: 0.4.0 authors: - panel contributors supported_modes: - docker # Create the instance but DON'T auto-start it. The operator finishes setup # (world/RWG settings, cluster join + mod-seed, RefugeBot) and starts it # manually. This also removes the warm-seed-then-immediate-start race that # could OOM the agent host. The first SteamCMD update / warm-seed still runs on # create (auto_update_on_create stays default-true); only the START is # suppressed — enforced by a shouldAutoStart() guard in the agent's # onUpdateComplete (the warm-seed completion hook), since that path isn't # otherwise gated by this flag. auto_start_on_create: false runtime: docker: # Built locally from ./Dockerfile. Rebuild with: # docker build -t panel-7dtd:latest modules/7dtd image: panel-7dtd:latest # The Files tab exposes TWO roots — operators can switch between them # with a dropdown at the top of the tab: # # Saves & Configs (/game-saves) — world data, serverconfig.xml, # admin files. Default view. # Game Files (/game) — binaries + Mods/ folder + Data/. # Where you drop a mod tarball to install it. # # `browseable_root` is kept for backward compat (single-root pre- # feature clients) but `browseable_roots` wins if both are set. # Host networking — the container shares the host's network namespace, # so any port the game OR a mod binds is directly on the LAN. No # `-p host:container` publishing required; mods like RefugeBot can # port-hunt freely (the AMP model — drop the .dll, mod sniffs free # port, LAN_IP: just works). # # Tradeoff: every 7DTD on this agent shares lo. The panel's port # allocator + the env-mapped ports below keep distinct instances # from clobbering each other on the canonical 7DTD ports. network_mode: host browseable_root: /game browseable_roots: - name: "Game Files" path: /game hint: "AMP-style flat layout: Data/, Mods/, steamapps/, User/, serverconfig.xml" - name: "Saves & Configs" path: /game-saves hint: "Underlying save volume — telnet password, rendered template, raw .local tree" # Pre-declare the port env keys so the agent's resolve.go filter lets # panel-allocated values flow through to the container env (only keys # already present in docker.env get overridden by config_values). # Defaults match the manifest port defaults below. env: GAME_PORT: "26900" TELNET_PORT: "8081" WEBDASH_PORT: "8080" REFUGEBOT_PORT: "8082" # Cluster membership marker. Empty for standalone servers. The # controller's 7dtd cluster manager sets this (via config_values) # when the server joins a cluster; the entrypoint reads it to # symlink this world's Player save folder into /cluster/Player. # Pre-declared here so resolve.go's filter lets the override flow # through (only keys already present in docker.env get overridden). CLUSTER_ID: "" # Operator-selected world whose Player folder is shared into # /cluster/Player ("/"). Set via the Cluster tab's # "Shared player world" picker (config_values). Pre-declared here so # resolve.go's env filter lets it flow through to the container; the # entrypoint reads it to override the literal GameWorld/GameName symlink # target — required for RWG worlds whose save folder is named after the # generated seed (e.g. "North Tuxera Valley"), not the literal "RWG". CLUSTER_PLAYER_SAVE: "" volumes: # Saves, configs, admin files. Never touched by SteamCMD validate. - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } # Game install — populated by SteamCMD sidecar. Differential updates # on a named volume avoid Windows NTFS bind-mount misery. - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } # Cluster shared dir. Defaults to a private per-instance named volume # so a standalone server has a harmless empty /cluster. The controller's # 7dtd cluster manager OVERRIDES this (mount_overrides) to a shared host # dir when the server joins a cluster, so every member's entrypoint can # symlink its Player save folder into the same /cluster/Player. - { type: volume, name: "panel-$INSTANCE_ID-cluster", container: "/cluster" } # Bind logs to host so operators can tail / rotate. - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } # Bind the panel-rendered serverconfig.xml into the saves volume so # the entrypoint picks it up. Without this, the entrypoint falls back # to the stock serverconfig.xml shipped with the game (ServerName= # "My Game Host" etc.) and the operator's config_values are ignored. - { target: "$DATA_PATH/serverconfig.xml.rendered", container: "/game-saves/serverconfig.xml.rendered", read_only: true } ports: # 7DTD always binds three contiguous UDP ports starting at ServerPort: # the game port, then game+1 (LiteNetLib alt) and game+2 (peer-to-peer # backup). We only inject `game` into the container env (entrypoint # patches ServerPort in serverconfig.xml). gameA/gameB are declared so # the allocator records them as taken — preventing a sibling instance # from grabbing game+1 or game+2 — but their env field is intentionally # blank (the binary computes those from ServerPort, not env). - { name: game, proto: udp, default: 26900, required: true, env: GAME_PORT } - { name: gameA, proto: udp, default: 26901, required: true } - { name: gameB, proto: udp, default: 26902, required: true } - { name: telnet, proto: tcp, default: 8081, internal: true, env: TELNET_PORT } # Vanilla 7DTD's built-in web dashboard. The TFP entrypoint binds # this when WebDashboardEnabled=true in serverconfig.xml. - { name: webdash, proto: tcp, default: 8080, required: false, env: WEBDASH_PORT } # RefugeBot mod's web UI (Admin Panel + Player Shop). Not required — # only listens when RefugeBot is installed via Settings → Bundled # mods. Declared here so the panel allocates a unique host port for # it (otherwise multiple 7DTDs would all collide on 8082) and Docker # publishes the binding so LAN clients can reach LAN_IP:. # If a 7DTD instance doesn't have RefugeBot installed, the port is # still bound but nothing answers — harmless. - { name: refugebot, proto: tcp, default: 8082, required: false, env: REFUGEBOT_PORT } resources: min_ram_mb: 4096 recommended_ram_mb: 8192 # First-start config: the entrypoint copies /game/serverconfig.xml (the one # shipped with the game install) into /game-saves on first run. rcon: adapter: telnet host_port: telnet auth: password_prompt # Our entrypoint generates a random TelnetPassword on first boot (7DTD # only listens on 0.0.0.0 when a password is set — otherwise it's # container-loopback only and the agent can't reach it). The plaintext # lands here inside the container for the agent to re-read on each RCON # dial attempt. password_from_file: /game-saves/.panel-telnet-password commands: list_players: "lp" say: "say \"{msg}\"" kick: "kick {player} \"{reason}\"" ban: "ban add {player} {duration} \"{reason}\"" shutdown: "shutdown" save: "saveworld" state_sources: # "lp" (list players) returns "Total of N in the game". - type: rcon command: "lp" every: 30s parse: kind: regex pattern: 'Total of (?P\d+) in the game' fields: players_online: players # 7DTD writes its real game log to /game/output_log_*.txt when invoked # via startserver.sh. Our entrypoint bypasses that and streams to stdout # (so docker logs → agent → panel console is the primary path). We still # watch the log files as a belt-and-braces source for event parsing. - type: log_tail files: - "$DATA_PATH/logs/*.log" - "/game/output_log_*.txt" events: join: pattern: "PlayerSpawnedInWorld \\(by .*?\\): PltfmId='(?P[^']+)', CrossId='(?P[^']*)', OwnerID='(?P[^']+)', PlayerName='(?P[^']+)'" kind: join leave: pattern: "Player disconnected: EntityID=\\d+, PltfmId='(?P[^']+)', OwnerID='(?P[^']+)', PlayerName='(?P[^']+)'" kind: leave chat: pattern: "Chat \\(from '.*?', entity id '\\d+', to '.*?'\\): (?:\\[.*?\\])?'(?P[^']+)': (?P.*)" kind: chat death: pattern: "GMSG: Player '(?P[^']+)' died" kind: death # Render serverconfig.xml from templates/serverconfig.xml.tmpl on every # (re)create, using the merged operator config_values + generated secrets # as `.Values.*`. The output lands at $DATA_PATH/serverconfig.xml.rendered # on the agent host and is bind-mounted into /game-saves/ where the # entrypoint copies it over the live serverconfig.xml. config_files: - path: serverconfig.xml.rendered format: xml template: templates/serverconfig.xml.tmpl # V3.0 "Dead Hot Summer" overhauls serverconfig.xml: ~29 gameplay # properties are removed and folded into one encoded SandboxCode property # (the 150 in-game Sandbox Options). A 3.0 server therefore needs a # different template than ≤2.6. Selection is by the instance's normalized # Steam branch (resolveCreateBranch): the 3.0 experimental branch normalizes # to "latest_experimental", so it renders serverconfig.v3.xml.tmpl; every # other branch (v2.6 default, public, historical) keeps the untouched 2.6 # template. When TFP cuts a frozen `v3.0`/`v3.x` branch near stable, add the # matching normalized key(s) here — no code change needed (RenderForBranch / # ConfigFile.TemplateForBranch are data-driven). See # panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D). template_by_branch: latest_experimental: templates/serverconfig.v3.xml.tmpl # 7DTD ships per-version LOCKED Steam branches on app 294420 alongside the # moving `public` branch. Branch names + frozen buildids confirmed live via # `steamcmd +app_info_print 294420` on 2026-06-14: # public -> buildid 22422094 (MOVING default; becomes 3.0 on 2026-06-29) # v2.6 -> buildid 22422094 ("Version 2.6 Stable" — frozen; == public today) # v2.5/v2.4/v2.3/v2.0/v1.4/alpha21.2 ... -> older frozen stables # (latest_experimental + the 3.0 experimental live under privatebranches) # # ORDER MATTERS. UpdateProviders[0] is the default the agent uses for # warm-seed install-path resolution AND for the create-time auto-update when # no provider_id is chosen (pickProviderFromConfigValues). We put the FROZEN # `v2_6` branch FIRST so a server that doesn't explicitly pick a version stays # locked to 2.6 and can NEVER drift onto the moving public branch when it flips # to 3.0. Pinning a server to `-beta v2.6` freezes it at buildid 22422094 # permanently — the corruption guard the whole V3.0 readiness work is built on. # # Operators pick a version at create via the "Game version" dropdown (which # writes config_values.provider_id); the chosen id maps to the matching # provider here. See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md. update_providers: - id: v2_6 # DEFAULT — Version 2.6 Stable, frozen (buildid 22422094) kind: steamcmd app_id: "294420" beta: "v2.6" install_path: /game # goes directly into our /game volume - id: latest_experimental # rolling experimental — V3.0 "Dead Hot Summer" lands here 2026-06-15 kind: steamcmd app_id: "294420" beta: latest_experimental install_path: /game - id: current # public/stable — MOVING. == 2.6 today; becomes 3.0 on 2026-06-29. kind: steamcmd app_id: "294420" install_path: /game - id: stable # back-compat alias for `current` (pre-2026-06-14 rows persisted provider_id=stable) kind: steamcmd app_id: "294420" install_path: /game # Historical version-locked branches — pin here to run an older client. - id: v2_5 kind: steamcmd app_id: "294420" beta: "v2.5" install_path: /game - id: v2_4 kind: steamcmd app_id: "294420" beta: "v2.4" install_path: /game - id: v2_3 kind: steamcmd app_id: "294420" beta: "v2.3" install_path: /game - id: v2_0 kind: steamcmd app_id: "294420" beta: "v2.0" install_path: /game - id: v1_4 kind: steamcmd app_id: "294420" beta: "v1.4" install_path: /game # Backup scope — capture the whole user folder + server config. # # 7DTD writes saves, admin files, and titlestorage into the per-user # tree at $HOME/.local/share/7DaysToDie/ (our entrypoint pins HOME to # /game-saves so this becomes /game-saves/.local/share/7DaysToDie/). # We grab that, the rendered serverconfig.xml, the persisted telnet # password, and any 7DTD-side config under .config — that's everything # an operator would lose if the volume vanished. # # Excluded paths: # - logs/ — large, never useful for restore # - Steam/ — Steam runtime cache, regenerated on next start backup: # Saves + admin files live in /game-saves, not /game. The file browser # defaults to /game so operators can see the binary tree, but the # backup must target /game-saves where the world data actually lives. root: /game-saves include: - .local/share/7DaysToDie - .config - serverconfig.xml - .panel-telnet-password exclude: - logs - Steam # --- Manifest-driven UI metadata (WI-07) -------------------------- # Consumed by the CONTROLLER/dashboard only (the controller loads its # own ./modules copy at startup); the agent ignores these at runtime. # ready_pattern is a JavaScript regex (slash-delimited when it needs # flags) moved verbatim from the dashboard's READY_PATTERNS map. ready_pattern: '/StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i' appearance: emoji: "🧟" grad: "linear-gradient(135deg,#7b3b12,#d97706)" art: "/game-art/7dtd.jpg" steam: "251570"