Skip to content

About

docker container for don't starve together dedicated server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Don't Starve Together dedicated server (Docker)

A single container that installs the DST dedicated server with SteamCMD and runs both the Master (overworld) and Caves shards side by side.

Running it

You need exactly two things: Docker with the Compose plugin, and a Klei server token.

Step 1 — get a server token

Open accounts.klei.com → Games → Servers, click Add New Server, and copy the token it generates. It looks like pds-g^KU_xxxxxxxx^xxxxxxxx....

Step 2 — pick one of the three ways to set up

A. One line — creates the directory, downloads the compose file, writes .env, and asks for your token:

curl -fsSL https://raw.githubusercontent.com/toanq/dst-server/master/scripts/init.sh | bash -s -- dst-server

Then cd dst-server && docker compose up -d. Re-running it is safe: an existing .env is left alone and any existing compose file is backed up first. Piping a script into your shell is a matter of trust, so read scripts/init.sh first if you would rather not — it is short and does nothing but download two files. wget -qO- works in place of curl -fsSL if that is what you have.

B. Published image, by hand — the same result as A, without the script:

mkdir dst-server && cd dst-server
curl -o docker-compose.yml https://raw.githubusercontent.com/toanq/dst-server/master/docker-compose.public.yml
curl -o .env https://raw.githubusercontent.com/toanq/dst-server/master/.env.template

# paste your token into .env (DST_SERVER_TOKEN=...)
docker compose up -d
docker compose logs -f

The scripts and first-run templates are baked into carcise/dst-server, so this file plus a .env is the whole setup.

C. From source — use this if you want to change the scripts or templates. docker-compose.yml mounts scripts/ and template/ from the host, so edits apply on the next restart with no rebuild:

git clone https://github.com/toanq/dst-server.git && cd dst-server
cp .env.template .env

# paste your token into .env (DST_SERVER_TOKEN=...)
docker compose up -d --build
docker compose logs -f

A and B land the published-image compose as docker-compose.yml, so plain docker compose commands work everywhere below. In a source checkout both files exist side by side and docker compose picks the source one — add -f docker-compose.public.yml when you want the published image instead.

Step 3 — wait for the first start

The first start downloads ~4.5 GB of game files and generates both worlds, so give it several minutes. You are up when the log shows:

[dst] server files ready
[dst] cluster ready
[Master] ... Sim paused

Then find the server in-game under Browse Games, filtered to your cluster name. If the log says E_INVALID_TOKEN, the token in .env is wrong or truncated — fix it and docker compose restart.

Updating

One line, from the deployment directory — refreshes the compose file, scripts/ and template/ from GitHub, then pulls the new image and restarts:

curl -fsSL https://raw.githubusercontent.com/toanq/dst-server/master/scripts/update.sh | bash -s -- --pull

Your .env, data/ and mod list are never touched; replaced files are kept as *.bak. Drop --pull to only update files, add --check to preview, or pass a tag (... | bash -s -- v1.2.0 --pull) to move to a specific release. With a checkout, ./scripts/update.sh --pull does the same.

Layout

docker-compose.yml         # build from source, scripts/templates bind-mounted
docker-compose.public.yml  # run the published image, nothing mounted
VERSION                 # image version (major.minor.patch)
Dockerfile              # steamcmd base + the 32/64-bit libs DST needs
.env / .env.template    # configuration, including the server token
scripts/
  init.sh               # host-side: bootstrap a server dir (the one-liner)
  release.sh            # host-side: build + push a major.minor.patch release
                        # everything below runs inside the container:
  entrypoint.sh         #   install -> init cluster -> web UI -> mods -> shards
  install-server.sh     #   steamcmd app_update 343050
  init-mods.sh          #   applies mods/dedicated_server_mods_setup.lua
  init-cluster.sh       #   copies template/ on first run, applies .env + ports
  start-webui.sh        #   runs the mod configuration web UI alongside the rest
  install-mods.sh       #   fetches workshop mods via steamcmd
  start-shards.sh       #   runs Master + Caves, handles shutdown
  console.sh            #   send Lua commands to a running shard
  lib.sh                #   shared paths and helpers
webui/                  # the mod configuration web UI (see webui/README.md)
  app.py                #   HTTP API, Python standard library only
  lua/eval.lua          #   sandbox that reads modinfo.lua and modoverrides.lua
  static/               #   the page itself, no build step
template/               # first-run files; server.ini ports re-read every start
  cluster.ini
  dedicated_server_mods_setup.lua
  Master/{server.ini,worldgenoverride.lua,modoverrides.lua}
  Caves/{server.ini,worldgenoverride.lua,modoverrides.lua}
data/                   # live cluster config + saves (host-mounted)
  DoNotStarveTogether/MyDediServer/...
mods/                   # your Workshop mod list (host-mounted, editable)

In docker-compose.yml the host scripts/, template/ and webui/ are mounted over the copies baked into the image, so local edits win. docker-compose.public.yml mounts neither and uses what is in the image.

Game files live in the dst-game named volume, so rebuilding the image does not re-download them. SteamCMD's own home is kept in dst-steam — a cold SteamCMD cache occasionally loses a race with its self-update and fails the first app_update with Missing configuration, so keeping it warm (plus STEAMCMD_ATTEMPTS retries) avoids a failed first start after every recreate.

Host networking

docker-compose.host.yml is an override that drops the port mapping and puts the shards straight on the host's interfaces. Docker's userland proxy sits in front of every published UDP port, which costs a game server latency and hides the real client address; this removes it.

docker compose -f docker-compose.yml -f docker-compose.host.yml up -d
# or, to stop repeating it:
echo 'COMPOSE_FILE=docker-compose.yml:docker-compose.host.yml' >> .env

It works on either base file and needs Compose v2.24+ (for the !reset tag it uses to drop ports: outright, so docker compose config shows what really happens rather than a mapping Compose would silently discard).

Two things change with it:

  • MASTER_PORT, CAVES_PORT, MASTER_STEAM_PORT and CAVES_STEAM_PORT stop doing anything — there is no mapping left for them to describe. The container-side ports become the host ports: UDP 10999/10998 for the shards and 27016/27017 for Steam. Move them in template/<shard>/server.ini, which init-cluster.sh re-applies on every start.

  • Your firewall starts mattering. A published port gets iptables rules in the DOCKER chain, which are evaluated before ufw's INPUT chain — so publishing punches straight through a default-deny and any ufw rule you wrote for it was decoration. Host networking adds no such rules, so the same packets now reach INPUT and get dropped. A server that was listed in bridge mode can vanish for that reason alone while its own log looks perfectly healthy:

    sudo ufw allow 10998:10999/udp
    sudo ufw allow 27016:27017/udp

    All four are UDP. 27016/27017 are what the server browser queries, so blocking only those leaves a server that is joinable by address but never in the list. A cloud provider's security group is a separate layer needing the same.

WEBUI_BIND and WEBUI_PORT keep working: with no mapping to bind, the override applies them to the web UI's listening socket instead, so the default stays localhost-only.

When a shard starts but no game does

start-shards.sh watches each shard's server_log.txt and complains if Online Server Started has not appeared within WATCHDOG_TIMEOUT seconds (default 120, 0 disables it), repeating every WATCHDOG_REPEAT seconds (default 1800) until it does. This failure mode is otherwise invisible — the process stays up, holds the Steam query port it opened seconds into boot, and docker ps says Up 3 days, while the game port was never opened, so nobody can join and the server is never listed.

Two log signatures matter, and they mean opposite things:

in server_log.txt meaning
[200] Account Failed (6): "E_INVALID_TOKEN" the token is bad — the game prints its own Your Server Will Not Start banner
[200] Account Communication Success (6) then nothing the token was accepted; something else stopped it

The second is worth knowing, because it reads like the opposite of what it is: a rejected token is refused loudly, so a log ending on Account Communication Success rules the token out. Replacing it will not help.

The cause is almost always a second server running the same DST_SERVER_TOKEN. One token runs one server; Klei accepts the second one's token, hands it no identity, and tells neither side why. Check docker ps -a on every machine, not just the one in front of you.

It is also not the firewall or the port mapping, however much it looks like one. The game port was never opened, so there is nothing to reach yet — the Steam query port you can see bound was opened seconds into boot, before the failure. That is why the process looks healthy, ss shows 27016/27017 but not 10999/10998, and the server never appears in the browser.

Configuration

.env is the front door. On every start init-cluster.sh:

  1. creates data/DoNotStarveTogether/<cluster>/ from template/ if it does not exist yet — an existing cluster is never overwritten;
  2. writes cluster_token.txt from DST_SERVER_TOKEN;
  3. rewrites the handful of cluster.ini keys that have a matching non-empty variable in .env (CLUSTER_NAME, MAX_PLAYERS, GAME_MODE, PVP, …);
  4. re-applies each shard's ports from template/<shard>/server.ini. Those are the container-side ports the compose file maps onto, and the game rewrites server.ini behind your back — it bumps master_server_port when a bind fails, and the bumped value sticks. The result is a server that boots normally and is never in the public browser, because its Steam heartbeat leaves on a port nothing is published on. Only the three port keys are touched; is_master, the shard id the game generates and anything else in the file are left alone.

The WEBUI_* block controls the mod configuration UI — Configuring mods from a browser.

Anything not covered by .env — world generation, mods, snapshot count — is edited directly in data/DoNotStarveTogether/<cluster>/ and survives restarts. Edit template/ instead if you want the change to apply to future clusters, or to move a shard's container-side ports: those are read back on every start, so template/<shard>/server.ini and the compose port mapping have to agree.

To wipe a world and start over: docker compose down, delete data/DoNotStarveTogether/<cluster>, then docker compose up -d.

Ports

Port Proto Purpose
10999 UDP Master shard — clients connect here
10998 UDP Caves shard — clients move here underground
27016 UDP Steam master server (Master)
27017 UDP Steam master server (Caves)
8080 TCP Mod configuration web UI (localhost only)

Forward 10999/udp and 10998/udp on your router for internet play. Host-side ports are configurable in .env. The web UI is published on 127.0.0.1 only — see Configuring mods from a browser.

Day-to-day

docker compose logs -f                                   # both shards, prefixed [Master]/[Caves]
docker compose exec dst /scripts/console.sh "c_announce('rebooting in 5')"
docker compose exec dst /scripts/console.sh "c_save()"
docker compose exec dst /scripts/console.sh --shard Caves "c_listallplayers()"
docker compose restart dst                               # graceful: saves first
docker compose down                                      # graceful: saves first

Each shard's stdin is held open on a FIFO, so console.sh reaches the live server console. On SIGTERM the supervisor sends c_shutdown(true) to both shards and waits (up to SHUTDOWN_TIMEOUT, default 90 s) for them to save — stop_grace_period in the compose file is set to 120 s to allow for that. If a shard dies on its own, the container exits so restart: unless-stopped brings the whole cluster back up together.

Mods

Two files, both required — one downloads the mod, the other turns it on.

  1. Download. Add the Workshop id to mods/dedicated_server_mods_setup.lua, created in your project folder on first start:

    ServerModSetup("378160973")            -- one mod
    ServerModCollectionSetup("379114180")  -- a whole collection
  2. Enable. Either open http://127.0.0.1:8080 and tick it — see Configuring mods from a browser — or add it to modoverrides.lua in both shards under data/DoNotStarveTogether/<cluster>/:

    return {
      ["workshop-378160973"] = { enabled = true },
    }
  3. docker compose restart — the startup log reports how many mods are declared. (Enabling a mod through the web UI needs no restart; downloading a new one does.)

Collections work too, and are expanded automatically:

ServerModCollectionSetup("3423422095")

Configuring mods from a browser

Enabling a mod by hand means writing modoverrides.lua for every shard, and getting its settings right means reading the mod's modinfo.lua to find out what the option names and legal values even are. The web UI does both.

http://127.0.0.1:8080

It runs inside the same container, started by entrypoint.sh when WEBUI_ENABLED=true (the default). It comes up before the Workshop download, so it is reachable during the slowest part of a cold start — mods still in flight simply do not appear until you refresh. It gives you:

  • every installed mod with its description, author, version and Workshop link;
  • a tick box per shard, with apply to every shard — on by default, since a mod enabled on Master but not Caves is a common way to end up with a caves world nobody can enter — and copy from… for the other direction;
  • each mod's real settings — the labels, help text and choices its author wrote — with non-default values marked and a one-click reset;
  • Hide client-only, on by default, which drops the mods whose modinfo.lua sets client_only_mod — they run on the player's own game and do nothing on a server, and a Workshop collection is usually full of them;
  • an editor for dedicated_server_mods_setup.lua behind Download list…, checked as Lua before it is written and kept as .bak, so adding a mod to the download list no longer means editing mods/ by hand;
  • Remove on a mod card, which deletes its entry rather than switching it off — the way out for a mod configured here but no longer installed;
  • Uninstall, which takes the mod off the server for real: its files, the SteamCMD copy behind them, its line in the download list and its settings on every shard. It reports what it freed, and warns when the mod came from a Workshop collection and will therefore return on the next start;
  • Edit file…, the raw modoverrides.lua for a shard as text, saved verbatim so hand-written comments and loops survive; checked as Lua first, same as the download list;
  • block client mods (client_mods_disabled) for the whole shard;
  • a live tail of server_log.txt, which is where a mod complains if it is unhappy;
  • Update…, which pulls a newer container image and recreates the stack, and can also restart it without pulling — which is how a mod just added to the download list actually gets downloaded. Off unless you set WEBUI_DOCKER=true and mount the Docker socket; the dialog otherwise prints the host command to run. Read webui/README.md before enabling it — the socket mount is effectively root on the host.

Saving writes modoverrides.lua for the chosen shards, keeping the previous file as modoverrides.lua.bak and preserving entries for mods it does not know about, so a hand-written file is never quietly thrown away.

Save & reload goes further and applies the change to the running cluster:

c_save()
StartNextInstance({reset_action=RESET_ACTION.LOAD_SLOT, save_slot=ShardGameIndex:GetSlot()})

sent down the console FIFO each shard already listens on. StartNextInstance ends in TheSim:Reset(), which tears down the Lua VM and re-runs main.lua in the same process — so ModManager:LoadMods() re-reads every modinfo.lua and modoverrides.lua, and the new mod set is live. Measured here: about seven seconds from pressing the button to the world being back, with the shard processes never restarting and the container untouched. Players see the loading screen for those few seconds, which is why the save is sent first — the restart resumes from the last snapshot.

Two things it deliberately cannot do: install a mod that has not been downloaded (add it to dedicated_server_mods_setup.lua and restart the container for that), and run arbitrary console commands — the only commands it ever sends are the three above.

c_reset() is the familiar spelling of that reload, but it only works on the master shard: it goes through a rollback request, and WorldRollbackFromSim returns early unless TheWorld.ismastershard. The explicit form works on every shard.

Reaching it, safely. The port is published on 127.0.0.1 — from another machine, tunnel to it:

ssh -L 8080:127.0.0.1:8080 you@your-server   # then open http://127.0.0.1:8080

To expose it directly, set WEBUI_BIND=0.0.0.0 and a WEBUI_PASSWORD (HTTP Basic; put TLS in front of it if it leaves your network). Anyone who can reach the port can change which mods run and restart the shards. Set WEBUI_ENABLED=false to not run it at all.

How mods are actually fetched

Not by the server. Its built-in Workshop downloader is unreliable in a SteamCMD install — it logs Staging library folder not found, then OnDownloadPublishedFile [16] (16 = k_EResultTimeout) and ODPF failed entirely: 16. One timeout aborts the entire remaining batch, so a large collection never finishes however often you restart: a 40-mod collection here stalled at 6 and stayed there across repeated attempts. Both shards also run that downloader concurrently, competing with each other.

So install-mods.sh does it instead: it expands collections through Steam's public API, fetches every mod with SteamCMD in a single batch, and unpacks each into mods/workshop-<id>. The shards then launch with -skip_update_server_mods -ugc_directory "", which runs mods in standalone mode.

Standalone matters. In UGC mode the server only recognises mods carrying a binary mod.manifest, and SteamCMD delivers many items as a *_legacy.bin zip that has none — so the server calls them missing, tries to download them itself, times out, and they never load. That failure is quiet: the mods sit on disk, modoverrides.lua looks right, and nothing loads. Standalone mode needs no manifest, and one copy serves both shards.

Measured on a 40-mod collection: 40/40 installed, 16/16 enabled mods registered on both shards, 368 config options applied, no Workshop errors.

MODS_UPDATE_ON_START=false skips re-checking mods that are already installed. Leave it on unless startup time matters — mods update on the Workshop, and clients must match the server.

mods/ is a directory mount rather than a mount of the file itself, because the game's copy lives inside the SteamCMD-managed install: app_update validate restores Klei's default over it, and a file bind-mount would let SteamCMD write straight through and overwrite your list. Instead your copy is the source of truth and is applied into the game directory on every start.

The seeded file and its directory are made world-writable so you can edit them without sudo — the container runs as root, and editors that save atomically need write permission on the directory, not just the file.

Notes

  • No token, no download. DST_SERVER_TOKEN is validated before SteamCMD runs, so a missing token fails in a second instead of after 4.5 GB. The value is also stripped of whitespace — a token pasted with a trailing newline is rejected by Klei and is thoroughly unpleasant to diagnose otherwise.
  • Both compose files declare container_name: dst-server, so they fight over the same container. Pick one; if you switch, docker compose -f <old file> down first, and pass -f consistently — otherwise you can end up starting the other file's container without noticing.
  • Set ENABLE_CAVES=false for an overworld-only cluster; caves can be enabled later and the shard will be created from the template on the next start.
  • UPDATE_ON_START=false skips the SteamCMD check for faster restarts. Klei pushes updates often and clients must match the server version, so leave it on unless you have a reason not to.
  • The container runs as root (the SteamCMD base image does), so files under data/ are root-owned on the host.

Publishing the image

Images are versioned major.minor.patch, kept in VERSION. Publish with:

./scripts/release.sh          # build + push the version in VERSION
./scripts/release.sh 1.2.0    # bump VERSION first, then build + push
./scripts/release.sh --no-push

Each release publishes four tags off one build:

Tag Moves? Use it for
1.2.3 never pinning in production
1.2 new patches staying on a minor line
1 new minors staying on a major line
latest every release trying it out

Bump patch for fixes, minor for new settings or scripts, major when an upgrade needs manual steps. The published image must carry scripts/ and template/ — the COPY steps in the Dockerfile handle that, which is what lets docker-compose.public.yml run without mounting anything.

License

MIT — see LICENSE. Don't Starve Together itself is Klei Entertainment's; this repo only automates downloading and running their freely distributed dedicated server and ships no game code or assets.

About

docker container for don't starve together dedicated server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages