Part of HordeForge: High-Performance Systems Engineering for 7 Days to Die.
A 7 Days to Die dedicated server (V3.2.0 line) in a rootless podman container on the LAN host server.lan (192.168.0.100). Stock Navezgane map, stock default difficulty and settings, with the workspace perf, APM and FPS-bot mods loaded: Crucible (7dtd-server-optimizer), Geiger (7dtd-server-apm) and BotMod (7dtd-fps-bots). EAC is off (required for C# mods).
Everything runtime lives on the host under data/; the container is stateless
and disposable.
Running in production on the LAN host. Working: image build, steamcmd
install/validate with bounded retries, config render and admin seed, mod
staging and per-boot sync, graceful stop that saves the world first, save
backups with retention, a verified restore path, and a scheduled readability
check on the archives, and the quadlet service.
make lint and make test gate every push.
Partial: coverage is measured for scripts/lib-env.sh only, so the badge
covers the shared library rather than the whole tree. Rollback of code or mods
is manual (redeploy an older sibling build).
Deliberately not built: no firewall or ACL in front of the listeners, no
signature check on the staged mods, no credential rotation procedure, no
off-host copy of the save archives (the recovery section below states what
losing the host costs), and no SECURITY.md. Each is a ranked gap with its
reasoning in docs/THREAT_MODEL.md; read that before
exposing this host beyond a trusted LAN.
| Path | What it is |
|---|---|
Containerfile |
Image: official steamcmd/steamcmd base + game runtime libs + entrypoint |
entrypoint.sh |
steamcmd install/validate, config render, admin seed, mod sync, run |
config/serverconfig.tmpl.xml |
Stock V3.2.0 template, Navezgane, EAC off, dashboard on |
config/serveradmin_seed.xml |
Dashboard admin (level 0) + webuser seed (password from WEBADMIN_PASSWORD, random at first seed) |
scripts/stage_mods.sh |
Copy built mods from sibling dist/ into mods-available/, recreate the enabled copies |
scripts/deploy.sh |
Stage mods + rsync this project to the server host (--restart also restarts the container) |
scripts/update_mods.sh |
Server-side: restage enabled mods + restart container (no image rebuild) |
scripts/run.sh |
Container lifecycle on the server host (build/start/install-only/logs/stop/backup/restore/verify-backup/status/config/version; --help lists them) |
scripts/perf.sh |
EfficientServer toggle (on/off/status) + telnet apm status snapshot (measure) |
scripts/lib-env.sh |
Shared .env loader, telnet value validation, telnet session helper (sourced by the ops scripts) |
start.sh / stop.sh |
Top-level daily shortcuts: start / graceful stop (wrap run.sh) |
Makefile |
make test, make lint (bash -n + shellcheck + reference check over every shell script; ruff rules/format, mypy strict, yamllint over Python and CI YAML; Containerfile structure check), make sbom. Needs uv on PATH: these targets build .venv from uv.lock themselves |
pyproject.toml, .yamllint.yaml, uv.lock |
Static analysis config (ruff rules + 100-col format, mypy strict, yamllint), the analyzer pins (dev group) and their hash-pinned closure; enforced by make lint locally and in CI from the same recipe |
.github/workflows/ci.yml |
CI: lint, tests, Containerfile and config-template validation; publishes the coverage badge on main |
scripts/sbom.py |
CycloneDX 1.6 inventory of the pinned analyzer closure (pins, sha256 hashes, licenses read from the venv METADATA); make sbom writes dist/sbom.cdx.json, the release workflow records it in the run summary |
scripts/test_lib_env.sh, scripts/test_fuzz_env.sh, scripts/test_coverage_badge.py, scripts/test_check_config_xml.py, scripts/test_config_templates.py, scripts/test_deploy_sh.py, scripts/test_entrypoint_boot.py, scripts/test_systemd_unit.py, scripts/test_containerfile.py, scripts/test_fuzz_xml.py, scripts/test_run_sh.py, scripts/test_perf_sh.py, scripts/test_stage_mods.py, scripts/test_makefile.py, scripts/test_sbom.py |
Tests behind make test; fake-telnet-server.py is their fake telnet endpoint fixture |
scripts/check-config-xml.py, scripts/coverage_badge.py, scripts/harness.py |
CI helpers: config XML well-formedness check, coverage badge renderer, shared check reporter and sandbox PATH builder for the suites above |
systemd/7dtd-server.container |
Quadlet for a durable rootless user service |
docs/THREAT_MODEL.md |
Attack surface of this harness: entry points, trust boundaries, existing controls, ranked gaps |
mods/ (runtime) |
Enabled mods, bind-mounted into the container |
mods-available/ (runtime) |
All staged mod builds |
data/ (runtime) |
game/ (steamcmd install), userdata/ (saves, logs, serveradmin.xml) |
mods/, mods-available/, data/ and .env are git-ignored: mods are
rebuilt in their sibling repos, data is host state.
Mods on this server (enabled tweaks, APM panel, bot options): see
MODS.md.
Everything the gate needs is resolved by the Makefile; the only system
packages are bash, make, uv and
shellcheck (Debian/Ubuntu apt install shellcheck, Fedora
dnf install ShellCheck, macOS brew install shellcheck). kcov is needed
for make coverage only, and podman plus rsync for the ops scripts.
make # the task list
make venv # build .venv from the hash-pinned uv.lock
make test # every suite
make lint # every static check
make check # lint then test: everything CI runs
make format # rewrite the Python in place: ruff format, then ruff check --fix
make sbom # CycloneDX inventory of the pinned closure -> dist/sbom.cdx.jsonmake sbom writes the CycloneDX inventory of that closure to
dist/sbom.cdx.json: every pin with its sha256 hashes and the license the
installed metadata declares, plus a property saying the whole closure is
dev-only and reaches no image. The document is a pure function of the
manifest, so it is generated rather than committed, and the release
workflow records it in the run summary for each tag.
make lint and make test build .venv themselves, so make check on a
clean clone is the whole setup. Both run the same commands CI does, from the
same recipe, with the same pinned tool versions: a green make check is the
push condition, not an approximation of it.
While iterating, run the one suite you changed instead of the whole gate:
make test-one SUITE=test_run_sh.py # scripts/test_*.py
make test-one SUITE=test_lib_env.sh # a bash suiteA Python suite is a plain script: .venv/bin/python scripts/test_run_sh.py
works the same way. Each prints one OK: line per pinned behavior, collects
every failure rather than stopping at the first, and exits nonzero at the end.
Adding a suite means dropping a
new scripts/test_*.py, which the Makefile picks up by wildcard, so nothing
needs listing in two places. A bash suite is named in make test beside
scripts/test_lib_env.sh and scripts/test_fuzz_env.sh.
Commit and PR conventions live in CONTRIBUTING.md and
AGENTS.md.
VERSION is the one place the version is stated: run.sh version prints it,
the org.opencontainers.image.version image label copies it (and
scripts/test_containerfile.py fails the gate when the two drift), and
.github/workflows/release.yml refuses a vX.Y.Z tag that disagrees. The
image is built on the server host, never published from CI, so that tag check
is the whole automated release contract.
Versions are SemVer, against the operator's side of the contract: a documented
config value, a run.sh command or flag, the on-disk state under data/.
| Bump | For | Example |
|---|---|---|
| patch | nothing an operator can observe: lint pins, a failed-run message, an internal refactor | 1.1.2 was analyzer version bumps alone |
| minor | something added, nothing removed or changed: a new command, a new config key with a working default | run.sh config, run.sh restore |
| major | a documented value stops working as written: a narrower accepted domain, a renamed or removed key with no alias, a config schema or save layout change | the 2.0.0 printable-ASCII secret domain |
The policy is what the history does; the gate now enforces the two parts a
mistake is easy to make. scripts/check_release_gate.sh (run by the release
workflow, pinned by scripts/test_release_gate.py) refuses a tag that is not
vX.Y.Z, disagrees with VERSION, has no dated ## [X.Y.Z] - <date>
changelog section, is not newer than every version that changelog already
releases, or carries a ### Breaking changes section without a major bump.
A breaking change is written down for the operator, not for the maintainer: what the value accepted before, what it accepts now, and what to change if you already run the old one. The Unreleased section is where it lives until the release is dated.
# from a machine with SSH access to the server host:
./scripts/deploy.sh # stage mods + rsync to 192.168.0.100
# on the server host (ssh maci@192.168.0.100):
cd ~/7dtd-server
./scripts/run.sh build # build image (pulls steamcmd base)
./scripts/run.sh start # first start downloads the game (~GBs)
./scripts/run.sh logs # watch boot; wait for "StartGame done"
# daily use: ./start.sh and ./stop.sh are the shortcutsrun.sh build passes SOURCE_DATE_EPOCH to podman as --timestamp, so an
exported value gives every layer the same fixed mtime and a second build of one
tree can be diffed against the first instead of trusted:
SOURCE_DATE_EPOCH=1700000000 ./scripts/run.sh buildThe value is seconds, the unit podman reads, so a millisecond stamp is refused rather than quietly pinning every layer to a date in the year 55000.
The base image is a build arg, so a build that must be repeatable from
upstream's side pins it to a digest without editing the Containerfile:
podman build -t localhost/7dtd-server:latest \
--build-arg BASE_IMAGE=docker.io/steamcmd/steamcmd@sha256:<digest> .The apt packages the image installs on top of the base are still resolved at
build time, so a byte-identical image needs both: the digest and the same
SOURCE_DATE_EPOCH.
| Port | Use |
|---|---|
| 26900 | Game: client "Connect to IP" (LiteNetLib) |
| 26902 | LiteNetLib data port (loadgen bots connect here) |
| 8080 | Web dashboard + APM bridge panel (webuser admin, password: WEBADMIN_PASSWORD or the minted-record file data/userdata/Saves/.webadmin-password) |
| 8087 | Telnet console (TELNET_PORT) |
Networking is host mode: the server binds directly on the host, no NAT.
./scripts/run.sh status shows the podman health status next to the container
state: the probe opens a TCP connection to the telnet console and sends no
password, so a server that is running but no longer serving reads unhealthy
(the first 30 minutes after a start are exempt, since a first boot downloads
the depot before the game opens the port). A health status is reported, never
acted on: podman does not restart or kill on it. A container that is stopped,
or running and not healthy, is followed by the last 20 timestamped lines of
its own log, and an unhealthy verdict is followed by the health log, which
names the port the probe found silent; a container podman has no verdict for
reads health: unknown.
./scripts/run.sh start says so too: the line it prints names the container
and the ports, then says nothing is serving yet, because up to a few minutes
of depot and world load sit between the container starting and the console
answering, and the health probe abstains over the same window.
./scripts/run.sh logs follows the container log with a container timestamp
on every line, so the entrypoint's progress lines and the game's own output
(undated by the game) can be ordered against each other. Lines the
entrypoint writes carry three more fields:
[entrypoint ts=2026-09-28T04:17:03Z boot=20260928T041701Z-1 level=info] render serverconfig.xml (telnet port 8087)
ts is UTC, boot is that boot's id, and every line of one boot carries the
same one. level is the severity as a field (info, warn, fatal), so
podman logs 7dtd-server | grep 'level=warn' needs no pattern matching on the
message; the last line before the game's own output names the game log's path.
A container that restarts (--restart unless-stopped, or the
quadlet unit's Restart=always) keeps the old lines, so a crash loop leaves
several boots in the same log; podman logs 7dtd-server | grep 'boot=20260928T041701Z-1' pulls one of them out. A level=fatal line is the
boot's reason for ending and always says which step failed.
The enabled set is EfficientServer, 7dtd-server-apm-bridge and BotMod
(see MODS.md). To unload a mod, remove its dir from mods/; to
load another one, copy it in:
cd ~/7dtd-server
rm -rf mods/BotMod # example: disable the FPS bots
./scripts/run.sh restartmods-available/ is refreshed from the sibling repo builds:
# on the workstation: rebuild the mod, then redeploy
cd 7dtd-server-optimizer && make build # EfficientServer
cd ../7dtd-server-apm && make bridge-build # 7dtd-server-apm-bridge
cd ../7dtd-fps-bots && make build # BotMod
cd ../7dtd-server-container && ./scripts/deploy.shDropping a mod into mods/ and restarting is all it takes. Removing it from
mods/ and restarting disables it (the entrypoint keeps only the stock
0_TFP_Harmony from the depot). scripts/stage_mods.sh owns the enabled set
and wipes everything else out of mods/ on every successful deploy.sh
staging run, so removing a staged mod only holds until the next deploy; drop
its name from NAMES in stage_mods.sh to keep it out. A staging run that
fails part way leaves mods/ exactly as it found it.
Updating a mod never requires rebuilding the container image. The image is
static; mods are bind-mounted from mods/ and re-synced by the entrypoint on
every container start. Rebuild the mod in its sibling repo, then one command
from the workstation:
cd 7dtd-server-optimizer && make build # rebuild the mod you changed
cd ../7dtd-server-container && ./scripts/deploy.sh --restartThis stages the new build, rsyncs it to the server, and restarts the
container (which syncs mods/ into the game's Mods/). On the server
itself, ./scripts/update_mods.sh does the restage + restart step.
Defaults are the stock serverconfig with minimal changes:
GameWorldNavezgane,GameNameNavezgane,GameModeSurvivalEACEnabledfalse,ServerAllowCrossplayfalseWebDashboardEnabledtrue (APM panel), telnet on- All difficulty/rule properties untouched (stock defaults)
Overrides:
export TELNET_PASSWORD=change-me # telnet console password; required,
# there is no default (see below)
export TELNET_PORT=8087 # telnet port (default 8087)
export WEBADMIN_PASSWORD=change-me # dashboard webuser password; if unset a
# random one is minted at first seed and
# written to data/userdata/Saves/.webadmin-password
export STEAMCMD_UPDATE=0 # skip steamcmd validate on next start
export BACKUP_KEEP=14 # save archives kept in backups/ (default 7,
# 1..999999999 read in base 10)./scripts/run.sh config prints the effective configuration of this host,
where each value came from (environment, .env, or the committed default),
and whether the value rules accept it. Secret values are never printed; it
reports them as set or unset. ALLOW_PUBLIC_DEFAULT_TELNET_PASSWORD is in
that report too, so "is this host opted in to the public telnet password" has
an answer without reading the environment by hand. It reports every rejected
value, not just the first, so one bad key does not hide the next.
Every key above is checked before a command uses it, and run.sh config
reports a rejected value instead of dying on it. SEVENDTD_CONTAINER_NAME
and SEVENDTD_IMAGE are no exceptions: both reach podman's argv and the
container name is the body of the anchored podman ps --filter regex
status matches on, so a value carrying regex or option syntax is refused
rather than producing a status that reports a container nobody started.
deploy.sh adds three keys of its own, SEVENDTD_SERVER_HOST,
SEVENDTD_SERVER_USER and SEVENDTD_SERVER_DIR (defaults 192.168.0.100,
maci, and that account's home). It reads them from the environment and from
.env with the precedence above, because they are in the same key set
(.env.example is the complete list, and an unknown key in it is refused).
TELNET_PASSWORD is required. The committed lab default (retest) is public
and a set telnet password makes the game listen on every interface, so a boot
without one stops with a FATAL instead of falling back. To run the lab on the
public default, opt in explicitly:
export ALLOW_PUBLIC_DEFAULT_TELNET_PASSWORD=1The telnet console is full server control (shutdown, admin add,
setgamepref); on a LAN-reachable listener the public default is a takeover
waiting to happen.
The dashboard webuser password is never stored in the repo: at first seed the
entrypoint takes WEBADMIN_PASSWORD (min 8 chars) or generates a random value,
writes only its MD5 digest into data/userdata/Saves/serveradmin.xml, and
records a minted plaintext once in the owner-only file
data/userdata/Saves/.webadmin-password (never in the container log, which
journald retains indefinitely). For the quadlet path, add
Environment=WEBADMIN_PASSWORD=... to systemd/7dtd-server.container before
installing it.
Or keep them in a git-ignored .env file in this directory; copy
.env.example as the starting point. Values are taken
literally (no shell expansion); one matching pair of surrounding quotes is
stripped, and a key this project does not configure is refused (the list
lives in .env.example and in ENV_FILE_KEYS in scripts/lib-env.sh, which a
test keeps in step). The telnet password is rendered into serverconfig.xml at every
start, so it must be printable ASCII: no backslash, pipe, ampersand,
single/double quotes, dollar, backtick, angle brackets, control characters, or
non-ASCII characters; the port must be in 1..65535. Both are checked before the
container starts, by the same code on the host and inside the container, and
the check does not depend on the locale either side happens to run under.
Add players to the admin list via telnet after joining, e.g.
admin add <name-if-online> 0 or admin add Steam <steamid64> 0.
- Every start runs
steamcmd +app_update 294420 validate, so the game updates itself. SetSTEAMCMD_UPDATE=0for offline/fast restarts. - Saves live in
data/userdata/Saves/on the host, never inside the container. Delete and recreate the container freely. - Runtime state beyond saves lives on the host too: the rendered
serverconfig.xml(data/game/serverconfig.xml) and the game install underdata/game/. - Back up the saves with
./scripts/run.sh backup: it asks the running server tosaveworldvia telnet first (best effort; a skipped save only warns), archivesdata/userdata/Saves/anddata/userdata/Logs/tobackups/7dtd-saves-<UTC stamp>.tar.gz(owner-only, it carriesserveradmin.xmland the webadmin record), and keeps the newestBACKUP_KEEParchives (default 7, whole number 1 to 999999999) by that stamp, which is UTC so the order survives a DST transition or a host timezone change.deploy.shnever touchesbackups/. Rollback of code or mods is not automated: redeploy an older sibling build; saves are unaffected by deploys.
The server keeps records about the people who play on it, and every one of them is a file on the host. There is no database and no external service:
| What | Where | Why |
|---|---|---|
World saves: player names, positions, inventories, bases, ban list, admin platform ids (serveradmin.xml) |
data/userdata/Saves/ (0700) |
running the world and enforcing bans |
| Game log: join and leave lines with platform ids, names and client addresses | data/userdata/Logs/ (0600 files) |
the boot and gameplay record ./scripts/run.sh logs and the dashboard read |
| Copies of both, plus the dashboard credential | backups/7dtd-saves-*.tar.gz (0700 dir, 0600 archives) |
restore a world after a bad deploy or a deleted save |
Archives written before the log joined the backup (see CHANGELOG.md) |
the same backups/ archives |
those hold the world only; a current backup adds Logs/, and restore puts it back without rolling the live log over |
data/game/ and data/userdata/ are created 0700 and every file the
container writes into them is 0600, because under rootless podman the
container's root is the host user and the default modes would hand all of it
to every other account on the host. run.sh re-applies the directory modes on
every command, so a tree an earlier version opened is closed on the next run.
What this repo does not do, and neither should you assume it does:
- Nothing is sent anywhere. There is no analytics, no telemetry, no crash reporting and no third-party script. The game install is the only thing fetched off-host, from Steam. The staged mods (APM bridge, bots) are built in the sibling repos and are where any outbound reporting they do lives.
- No retention job prunes the game log; it grows until you delete it.
backups/keeps the newestBACKUP_KEEParchives and prunes the rest on everybackup. - There is no per-player export, correction or deletion command: the game offers none to a server operator. The practical answers are the files themselves: a player's records are the save chunks, an access export is a backup archive, and erasure is deleting them.
To erase everything a departing player left behind, with the server stopped:
rm -rf data/userdata/Saves data/userdata/Logs backups/*
# admin, whitelist and ban entries live in Saves/serveradmin.xml, so removing
# Saves/ clears those too; the next start re-seeds the dashboard webuser.Removing backups/* is the part people forget, and it is where a deleted
player's name survives longest.
backups/ sits on the same host as the saves it protects, so it survives a
bad deploy, a bad config and a deleted world, but not the loss of the host
itself. Copy archives off the host if that loss matters (rsync them to
another machine or an off-host store on whatever schedule you run backups);
nothing in this repo does it for you.
Restoring:
./stop.sh # or ./scripts/run.sh stop
./scripts/run.sh restore # newest archive in backups/
./scripts/run.sh restore backups/7dtd-saves-20260901-120000.tar.gz
./start.shA restore puts the world back and, when the archive carries one, the game log
too: a log file that is missing on the host comes back from the archive, and a
log file that is already there is left as it is, so the connections made since
the backup are not rolled back. Archives written before the log joined the
backup hold Saves/ only and still restore the world.
Checking the backups before you need them:
./scripts/run.sh verify-backup # every archive in backups/
./scripts/run.sh verify-backup backups/7dtd-saves-20260901-120000.tar.gzverify-backup runs the same preflight restore applies (readable gzip/tar,
a Saves/ payload, no entry outside the archive root) without restoring
anything or stopping the server, and prints one OK: line per verified
archive with its size and age. Every refusal goes to stderr, so
./scripts/run.sh verify-backup | grep '^OK:' lists only the archives a
recovery can use. It exits 1 when an archive is unreadable or the newest one
is older than three days, which means the backup schedule stopped running.
The exit code of the backup run that wrote a file is not evidence the file
is still good.
Every command that writes data/, backups/ or the container (start,
run, restart, install-only, stop, backup, restore) takes one
exclusive lock on data/.ops.lock first. The daily timer, the unit's
ExecStop and an operator at the keyboard all reach the host on their own
schedule, and without the lock a backup tar runs straight through a
restore's rm -rf Saves. A command that finds the lock held waits up to
120 seconds, says it is waiting, then gives up without touching anything. The
read-only commands (logs, status, config, verify-backup) and build
never wait.
restore verifies the archive (readable gzip/tar, carries a Saves/
payload, no entry escaping the archive root) before it touches anything, and
refuses while the server runs, because the game would write over the restored
files. The saves it replaces are archived first into backups/ as
7dtd-saves-<UTC stamp>-prerestore.tar.gz, so a restore is reversible: run
restore against that pre-restore archive by name to go back. The
no-argument form never picks a -prerestore archive, so a retried bare
restore re-applies the same backup instead of undoing the first one; when the
live saves already hold that backup's content, the run reports that there is
nothing to restore and takes no second pre-restore snapshot, so retries cost
no retention slot. The archive is unpacked beside Saves/ and moved into
place, so a failed extraction leaves the current world intact. The
restored files keep the owner-only mode the archives use
(serveradmin.xml and the webadmin record are credentials).
- RPO: the time since the last backup. With
7dtd-backup.timerenabled that is under a day, plus whatever ran last before the host was off; with only the ad-hoc command, it is however long since anyone remembered. - RTO: stop (up to about 2 min worst case, the telnet save plus forced stop) plus the extract of the archive, which is minutes for a world of normal size. Start the server and the world loads.
- Configs, the game install and the admin seed are not backed up: they
re-render from
config/or re-download from Steam on the next start. The.webadmin-passwordrecord andserveradmin.xmlare inside the archives, so the dashboard credentials come back with the saves.
podman build -t localhost/7dtd-server:latest .
cp systemd/7dtd-server.container ~/.config/containers/systemd/
cp systemd/7dtd-backup.{service,timer} ~/.config/containers/systemd/
cp systemd/7dtd-backup-verify.{service,timer} ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user enable --now 7dtd-server
systemctl --user enable --now 7dtd-backup.timer
systemctl --user enable --now 7dtd-backup-verify.timer
loginctl enable-linger maci7dtd-backup.timer runs ./scripts/run.sh backup daily (04:17, with up to
10 minutes of jitter, and a missed day runs at the next boot). Without it
nothing bounds the RPO: the world is only as safe as the last time you
remembered. A failed run leaves the unit failed, visible in
systemctl --user status 7dtd-backup.service and the journal.
7dtd-backup-verify.timer runs ./scripts/run.sh verify-backup weekly
(Monday 05:23, plus up to 10 minutes of jitter, and a missed week runs at the
next boot). It re-reads every archive backups/ still holds and fails the
unit when one is unreadable or the newest is older than three days, so a
truncated archive or a backup schedule that stopped running shows up in
systemctl --user status 7dtd-backup-verify.service instead of at the next
restore.
Stops and restarts of the service go through the same graceful path as
./stop.sh (telnet save + shutdown before the container is killed), via the
unit's ExecStop. The unit pins no TELNET_PASSWORD/TELNET_PORT: those
defaults live in scripts/lib-env.sh, shared with the ops scripts. If you
override them (or WEBADMIN_PASSWORD) via Environment= lines in the unit,
mirror the telnet values in .env on the server host so the graceful-stop
login still matches.
ExecStop runs run.sh stop, which waits for the same ops lock every other
mutating command takes before it saves, so the unit's stop budget covers that
wait as well as the stop itself. The unit also lifts systemd's start rate
limit: a server that dies on a bad depot or a corrupt save is restarted, and
after five failures in ten seconds the default limit would leave the unit
failed instead, with no boot to read the failure out of.
- Game downloads on first start only.
podman logs 7dtd-servershows steamcmd progress; a few GB take a while on a slow link. - Client kicks / chunk stream errors: client and server must be the same game version and both vanilla-terrain (no RealEarth on either side).
- Mods not loading: check
data/userdata/Logs/output.logfor0_TFP_Harmonypresence and per-modInitModlines. If a newer depot build shipped, rebuild the mods for it (see AGENTS.md version pin). - SELinux (RHEL host): all mounts carry
:Z, which relabels the sources tocontainer_file_ton each start. If new mods or data appear unwritable, they were copied in after the last start; restart the container. - Telnet blocked: a password is set, so the telnet interface listens on
all interfaces; confirm nothing else uses
TELNET_PORT. (unhealthy)inrun.sh status: the container is up but the telnet console stopped answering, so the game is wedged rather than gone.statusalready prints the probe's own reason and the last log lines; readpodman logs --tail 50 --timestamps 7dtd-serverand the game log underdata/userdata/Logs/, then./scripts/run.sh restart.- The container keeps restarting: each restart is a new boot id, so
podman logs --timestamps 7dtd-servershows the last boot'slevel=fatalline as its last entry; that line names the step that failed.