Skip to content

About

Don't Starve Together Dedicated Server Image

Topics

Resources

Stars

13 stars

Watchers

1 watching

Forks

Repository files navigation

Don't Starve Together Dedicated Server

Steam Discord

English | 简体中文

A Rust SDK, Agent and CLI for Don't Starve Together, with Python bindings. Each room runs in one container: one Agent supervises every configured shard and exits with the games when the room closes. An external scheduler starts the container at each opening. Published binaries and Python wheels support Linux x86_64 and CPython 3.14. The control socket is for trusted callers on the same host.

Version 0.4.0 replaced the earlier Python implementation. Version 0.5.0 exits each container when its room closes and uses external scheduling for the next opening. See the migration guide for API changes, verification and known limitations. Internal readiness does not establish public network reachability.

Install the Python SDK with pip install dst-server==0.5.0 in a CPython 3.14 environment. The GitHub release also includes a standalone Linux x86_64 CLI.

Build and Create a Room

Install Rust 1.99, a C/C++ build toolchain, Cap'n Proto, and uv. Host deployment additionally requires Podman with Quadlet and systemd. From this checkout:

cargo build --locked --release -p dst-server --bin dst-server
export PATH="$PWD/target/release:$PATH"
uv sync --python 3.14
dst-server --help
uv run python -m dst_server --help

The native binary and the Python entry point call the same Rust CLI. Build the game image from this revision, supplying the exact game build that SteamCMD will install:

podman build --target game --build-arg GAME_VERSION=756039 \
  -t localhost/dst-server:rust .

The build checks the installed game version and packages the matching SDK Lua bundle. Use the matching published image when creating rooms, or select your locally built image.

The default deployment image is quay.io/wh2099/dst-server:latest. Set deployment.image or --image to quay.io/wh2099/dst-server:beta for beta. Generated Quadlets keep these tags and set AutoUpdate=registry for both published channels. Quadlets use Pull=never; pull the selected image before the first start. Run systemctl enable --now podman-auto-update.timer on the host to enable periodic checks. The timer updates containers and restarts them when a new image is available.

Create a Klei server token and store it in /run/secrets/dst_cluster_token. Run the following with the account that owns the host room directories and systemd services:

import asyncio
from pathlib import Path

from dst_server import Host
from dst_server.settings import Room, build_template


async def main():
    cluster = build_template(
        "pure_survival",
        number=299,
        token=Path("/run/secrets/dst_cluster_token").read_text().strip(),
    )
    room = Room(
        number=299,
        template="pure_survival",
        cluster=cluster,
        deployment={
            "image": "localhost/dst-server:rust",
            "volume_idmap": "uids=0-1000-1;gids=0-1000-1",
        },
    )
    await Host("/srv/dst", "/etc/containers/systemd").create(room)


asyncio.run(main())

This creates /srv/dst/299 and dst-299.container without starting the service. The illustrated ID mapping gives image UID/GID 1000 access to root-owned room files. Choose ownership or mapping for your host. Then start the service:

dst-server host start --room 299
dst-server host status --room 299
dst-server call list_players --socket /srv/dst/299/.dst-agent.sock
journalctl -u dst-299.service -n 100 -f

Use host --root PATH --quadlet-dir PATH for alternate directories, and host --user for the user systemd manager. Room numbers range from 000 to 299 independently of gameplay templates. The packaged fleet contains 121 rooms: 000–100 and 200–219. Room 100, LST-100-测试尝鲜, uses the official Beta game build with the pure endless configuration. Its forest uses the native ENDLESS preset, and its cave uses the matching overrides from the pure endless template.

dst-server template list
dst-server host fleet --room 000,030,209 \
  --token-file /run/secrets/dst_cluster_token --image localhost/dst-server:rust \
  --volume-idmap 'uids=0-1000-1;gids=0-1000-1'

CLI

Command Purpose
host Create, provision, edit, inspect and operate host services; batch by room, template or all rooms.
call Invoke a room or shard operation through its Agent socket.
describe Print method names, scope, argument schemas and default timeouts.
subscribe Read bounded logs, lifecycle or events batches and their discarded counts.
logs, archive Query or follow historical logs; import, export and upload native room saves.
console Execute trusted Lua on an explicit shard, from text, a file or interactive input.
config, template, inspect Validate configuration, inspect templates, discover native topology.
scripts, annotations, completion Build or verify script bundles, generate Lua annotations and shell completion.
agent Run the room supervisor, normally as the container entry point.

Commands print JSON; --json uses compact JSON and structured errors. A batch preserves individual results and fails its exit status when any target fails. JSON arguments accept inline text, @filename, or - for stdin.

dst-server --json host list
dst-server host show 299 --field /cluster/settings/max_players
dst-server config validate ShardSettings '{}' --resolved
dst-server host diagnose --room 299
dst-server describe
dst-server call save --socket /srv/dst/299/.dst-agent.sock
dst-server call get_player '{"userid":"KU_example"}' --socket /srv/dst/299/.dst-agent.sock
dst-server subscribe lifecycle --socket /srv/dst/299/.dst-agent.sock

call stop gracefully closes every shard, then exits the Agent and container with code 0. host stop --room 299 stops the container service, including its Agent. Either stop permits the independent external scheduler to start the service at its next trigger. call restart confirms the stop and requests container replacement; its reply does not confirm the next startup. Use host start or host restart to wait for all shards to become ready. Outside opening hours, startup exits successfully with the room closed. Recovery that remains exhausted is reported as an error. Stop the service before offline configuration changes:

dst-server host stop --room 299
dst-server host edit --room 299 --set '/cluster/settings/max_players=9'
dst-server host start --room 299

After container exit, host status reads the saved policy, current schedule, maintenance and recovery state. Changing world-generation settings preserves existing saves; regenerate creates new worlds.

Rust and Python SDKs

RPC uses native Cap'n Proto fields for requests, errors, results and subscription records. Dynamic game and MOD data uses a recursive value union; numbers, text, lists and objects are carried as binary fields. Rust's serde_json::Value and Python dictionaries represent SDK values in memory.

The native API exposes typed requests without exposing Cap'n Proto generated types:

use dst_server::{model::{Envelope, Request, Target}, rpc::Client};

async fn inspect_room() -> dst_server::model::Result<serde_json::Value> {
    let client = Client::connect("/srv/dst/299/.dst-agent.sock").await?;
    let result = client.call(Envelope::new(Target::Room, Request::Status {})?).await;
    client.close().await?;
    result
}

Python exposes asynchronous calls and explicit connection and subscription cleanup:

import asyncio
from dst_server import Client


async def main():
    async with await Client.connect("/srv/dst/299/.dst-agent.sock") as client:
        print(await client.call("status"))
        print(await client.call("save"))
        async with await client.subscribe("events") as events:
            print(await events.next(max_items=100))


asyncio.run(main())

Use describe() or dst-server describe --shard for the exact method contract. Operations cover world queries, saves, rollback, regeneration, pause and player control. They also cover migration, permissions, Mods and exports. Player operations locate userid across shards and verify its identity before execution. Pass shard="forest" when an operation needs an explicit shard. Entity references contain world session and runtime generation; old references become invalid after reload.

Each room permits one mutation at a time and returns busy for a conflicting operation. Accepted mutations belong to the Agent. Cancellation, disconnection and caller timeouts stop waiting without undoing the operation. Check status, or host status after container exit, before deciding whether to retry an unknown result. Save returns true when the native command accepts the request; disk persistence may finish later. Graceful shutdown sends one native c_shutdown() to each shard and lets the game perform its final save. The game may save more than once within its native shutdown path. Normal shutdown requires exit code zero, process reaping and output drainage, with no force or protocol fault. Failed shutdown retains each shard's exit status, forced termination flag and output drainage result. Signal termination uses the negative signal number. The default stop wait is 220 seconds, including its notice; changing the notice delay adjusts that budget. Mods that disable saving can accept save requests and stop cleanly without creating a snapshot. Migration returns the native acceptance boolean; query player location to follow its progress. Console success means the Lua code returned; an asynchronous effect started by that code may still be running.

SDK-generated unique IDs use canonical uppercase ULIDs from Rust's ulid crate. This covers launch nonces, process instances, events and logs, presence observations, requests and operations. Service instances use the same format. Each event keeps the same ID across subscribers, local output, OTLP export and retries. Process IDs survive world reloads and change when a new process starts. Presence observation IDs change when the process, world generation or continuous observation changes. Lua reports its local observation epoch, and Rust assigns the public ULID. Native game IDs, room numbers, system IDs and OpenTelemetry trace/span IDs follow their owning protocols.

Configuration and Files

/srv/dst/299/
├── cluster.ini
├── cluster_token.txt
├── adminlist.txt / blocklist.txt / whitelist.txt
├── .dst-server.json
├── .dst-room.lock
├── .dst-agent.sock
├── mods/
│   ├── dedicated_server_mods_setup.lua
│   ├── modsettings.lua
│   └── ugc/
├── forest/
│   ├── server.ini
│   ├── worldgenoverride.lua
│   ├── modoverrides.lua
│   └── save/
└── cave/
    └── server.ini, world overrides, Mods and saves

Native INI/Lua files define game configuration; the Quadlet defines deployment. .dst-server.json stores the template label, policy, activity, recovery state and shared Lua launch options. Before starting games, the Agent updates its driver field and writes generated options to each shard's dst_server_driver.json. Lua reads the shard-local launch file through the native storage API, which rejects paths outside the shard. An existing .dst-control.json is renamed under the room lock on first access. Published ports are read from the Quadlet; the SDK no longer maintains a separate port registry. It is not a second copy of game settings. Keep it across container replacement to retain recovery limits and maintenance progress. Never remove or replace .dst-room.lock while a room exists.

Every directory with server.ini is an enabled shard. Names are arbitrary, with exactly one master. Keys and IDs must be consistent, with conflict-free game, query and shard ports. Port allocation uses the actual shard set, an allocation lock and a persistent map; the previous four-shard limit is removed. Packaged fleet rooms use host UDP ports based on 30000 + room_number * 10. Master game/query offsets are 0/1, and secondary offsets are 2/3. A conflicting fixed port fails deployment. Custom deployments without explicit mappings use the default host allocation pool 30000–65535. All shards share the container network and room Mods; each process has separate control pipes and readers.

Python configuration factories validate through Rust:

from dst_server.settings import ClusterSettings, RoomStore, build_template

settings = ClusterSettings(max_players=9, cluster_name="Friday games")
cluster = build_template("pure_endless", number=299, settings=settings)
print(cluster.dump(defaults=True))
print(settings.render())

store = RoomStore("/srv/dst")
room = store.load(299)
updated = room.edit("/cluster/settings/cluster_description", "Friday games")
# After stopping the entire service:
# store.save(updated)

dump() redacts token, shared key and password; secrets=True explicitly returns them. Rendered native files and files() contain real credentials and must be handled accordingly. Defaults are applied on demand; omitted fields and explicit None remain distinct. Rust types define configuration fields, defaults and INI sections; Serde and garde validate input. encode_user_path defaults to false and is always written to server.ini; an explicit true remains enabled. The packaged presets include 12 templates and all 121 fleet rooms. Rust owns validation and file operations; Python provides bindings, conversion and asynchronous wrappers.

Offline writes use the shared room lock, reject symlinks and replace files individually. An I/O failure can leave some files updated; fix the error and reapply the configuration. General configuration saves preserve live admin, ban and whitelist files. Use permission operations for online changes. Structured Lua reads parse Lua 5.1 literals without execution. Dynamic configurations can be loaded by the game; this API cannot structurally edit them.

Opening Hours, Maintenance and Recovery

The default policy uses system local time (timezone: "local") and opens all day. Automatic Mod maintenance is enabled; idle world regeneration is disabled. Generated Quadlets set Timezone=local so the container inherits the host timezone. Set a policy's timezone to an IANA name such as Asia/Shanghai to override the schedule timezone. A policy can be sent to a running Agent:

dst-server call set_policy - --socket /srv/dst/299/.dst-agent.sock <<'JSON'
{
  "policy": {
    "timezone": "local",
    "schedule": [{"start": "18:00", "end": "00:00"}],
    "mod_auto_update": true,
    "idle_regeneration": false
  }
}
JSON

The Agent checks opening hours at startup and every 30 seconds while running, including windows across midnight. At closing time it stops all games and exits the container with code 0. Startup outside opening hours also exits with code 0 before starting games. Configure an independent host scheduler to activate the service at each opening; see the systemd timer example. Update that scheduler separately when changing policy times or timezone. Stopping a room leaves the external scheduler active; manage the scheduler separately when its next trigger should be suppressed. Pending Mod maintenance runs in the next container's startup before opening games. call update_mods queues maintenance and returns before the next container performs it. With restart: false, that container closes after the update. Explicit Mod maintenance may run outside opening hours, but games still follow the schedule. The default maintenance countdown is 60 seconds with announcements every 30 seconds. Failed downloads or validation receive two additional attempts, five minutes apart, then leave games closed. An explicit maintenance request or a later opening can retry.

A required shard crash or sustained control failure stops the whole room. Abnormal process exits, forced termination and IPC faults persist recovery state and exit with code 1 for container replacement. Intentional restart and Mod maintenance use code 8. The generated Quadlet treats it as successful and forces a restart through the same startup path. Update existing Quadlets with the required restart directives before using this lifecycle. Retryable failures allow two restarts of the current save, 30 seconds apart. The latest snapshot numbers across all required shards may differ by at most MAX_SNAPSHOT_GAP = 3. Native shard synchronization may change these numbers during a restart. Only confirmed save-loading failure can then try one previous complete room snapshot. That target must precede the failed snapshot and be no more than three snapshot numbers behind any shard's latest save. The limit counts snapshot numbers, including manual saves; it does not measure game days or require .meta clock data. Targets are fixed before mutation; interrupted recovery resumes those same targets. Recovery state omits empty fields and keeps current snapshot identities only inside a pending retry. Interrupted rollback rechecks every shard against the fixed target, without a separate progress list. Unknown state, missing targets, unsupported failure causes or failed recovery leave the room closed. Thirty minutes of stable operation restores the budget. Exhausted rooms get another opportunity at the next opening, or explicit start for an always-open room.

Idle regeneration runs only when enabled, during opening hours, with every shard ready and reliably empty. Its retention periods are 6, 24, 36, 72 and 168 hours according to world age. Interrupted observations or changed worlds restart the retention period; clean scheduled closure preserves it.

Logs, Exports and Utilities

The default game event profile is history; agent --profile also accepts critical and off. History keeps work completion and item activity, omitting individual tool swings and ordinary PvE hits. Combat details cover players and entities attributed to players; deaths and spell actions remain recorded. History records container transfers as changes to the selected slot; failed attempts and automatic inventory churn are omitted. Local portals, including wormholes and temporary map exits, emit dst.player.teleport_started when native activation succeeds. Events are bounded and best effort. Source gaps, subscriber overflow, output loss and export rejection have separate counters. RPC uses a shared Tokio broadcast cache per stream: the latest 128 records, at most 64 KiB each (8 MiB of payload per stream). Slow readers report overwritten records through dropped; oversized records also count as lost. Each batch returns at most 16 records (1 MiB), and the Agent accepts up to 128 subscriptions. A subscription is not a replay log or a durable task trigger.

OTLP uses gRPC for logs, metrics and traces. Signal-specific settings override common OTEL_EXPORTER_OTLP_* settings. Their prefixes are OTEL_EXPORTER_OTLP_LOGS_*, OTEL_EXPORTER_OTLP_METRICS_* and OTEL_EXPORTER_OTLP_TRACES_*. Configure endpoint, headers, TLS certificates, compression and timeout explicitly; timeout values are milliseconds. Set a signal's OTEL_*_EXPORTER=none, or OTEL_SDK_DISABLED=true for all signals, to disable export. Structured log transport preserves null, integers, floats, arrays and objects. Netdata's flattened query projection has separate type limitations.

dst_server.logs provides JournalLogs, JournalQuery, JournalStream, NetdataLogs and NetdataLogQuery. The CLI accepts those same query fields, including cursors, direction and filters:

dst-server logs journal --unit dst-299.service --query '{"limit":100,"since":"today"}'
dst-server logs journal --unit dst-299.service --follow
dst-server logs telemetry '{"since":1790899200,"until":1790985600,"limit":100}'

A follow stream emits one record at a time; interrupting a log query waits for its reader process to exit. dst_server.events lists supported event names with event_names() and validates records through validate_event(). Rust enums and payload types define the event contract, including required nulls and exact numeric values. dst_server.KleiClient provides bounded build, region, lobby and room requests with per-query batch failures. HTTP failures are distinct from successful empty results. Season, mode, intent and character prefab retain unknown strings from game updates or Mods. Additional response fields are also preserved.

Archive export reads persistent world/player saves and Mod progress while games keep running. It captures the selected files in memory, then creates a seekable 7z archive in RAM using Zstd level 22 by default. Extraction requires a tool that supports Zstd compression inside 7z archives. Each process runs one export compressor at a time; no staging directory or transfer file is written. Exports include room/shard settings, world generation, Mod configuration, native sessions and shardindex. Recipe progress, worldjump saves, and ReForged achievements and experience are also included. It excludes downloaded Mods/UGC, server_temp, client_temp, logs, backups, indexes/profile caches and SDK runtime files. Credentials and permission lists remain excluded, including when the source has no token file. Basic export preserves the source shard names and player path encoding. The optional client_compatible=True (--client-compatible) requires exactly two shards: one master and one secondary. It places them under Cluster_<number>/Master and Cluster_<number>/Caves. It enables encode_user_path in both server.ini and shardindex and converts unencoded player directories. All player snapshots, metadata and savelocation records are retained. Already encoded player directories retain their names. This option is off by default and cannot be combined with a custom room_id. Mod download lists, activation settings, overrides and the saved enabled_mods remain included. Players install the Mods themselves. Client hosting does not require the original server token; a dedicated online server needs its own token. Reading a running room does not force a save or guarantee all files come from the same instant. Uploads use the native object-store implementation and explicitly abort incomplete multipart uploads on failure or cancellation. Cleanup failures remain visible in the result and logs. Python exposes dst_server.archive.export_cluster and ClusterArchive.save/upload. Both SDK and CLI read the room directory directly, without requiring an Agent or systemd. Export returns a memory stream by default; saving and uploading are explicit operations on the same archive.

async with export_cluster(host, 299, client_compatible=True) as archive:
    contents = archive.stream.read()

Within that context, use await archive.save(path) for a new local file. Use await archive.upload(bucket=..., endpoint=...) for R2/S3.

dst-server archive export 299 --output /tmp/room-299.7z
dst-server archive export 299 --client-compatible --output /tmp/room-299-client.7z
dst-server archive upload 299 --client-compatible --bucket saves --endpoint "$AWS_ENDPOINT_URL_S3"

The output path must not already exist. import_cluster(source, destination, from_client=False) accepts a native directory, ZIP/7z path, or ClusterArchive. It returns a Configuration. The CLI equivalent is dst-server archive import SOURCE --output DESTINATION [--from-client]. Inputs contain one room directly or inside one enclosing directory. Import and export share file selection, configuration rendering, index sanitization and path validation. By default, import preserves native shard names and player encoding and excludes the same caches and credentials. It creates a fresh shared shard key. With from_client=True, the input contains Master and optionally Caves. They become forest and cave, including the shard names in configuration. Encoded player directories return to their case-sensitive KU_…_ paths, with encode_user_path=false in both INI and index. Shard indexes supply server settings and missing world/Mod configuration. The importer reads and validates in memory, writes the selected files once, and publishes a new destination directory. Existing paths are never overwritten. It does not register a service or start the game. Archives are limited to 100,000 entries and 2 GiB unpacked; native configuration files are limited to 1 MiB each. Legacy saveindex and client sessions using legacy paths must first be migrated by the game.

from dst_server.archive import import_cluster

configuration = await import_cluster(
    "client-save.zip", "/tmp/restored", from_client=True
)

See the migration guide for entry points and evidence.

dst-server scripts build /path/to/native/scripts.zip --output /tmp/scripts.zip
dst-server scripts verify /tmp/scripts.zip --source /path/to/native/scripts.zip
dst-server annotations components /path/to/scripts/components --output /tmp/components.lua
dst-server annotations modutil /path/to/scripts/modutil.lua --output /tmp/modutil.lua

Development and Validation

The workspace contains crates/dst-server and crates/dst-server-python. Python wrappers live under python/dst_server, with Lua resources under resources/lua. The container runs the Rust binary directly.

cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo nextest run --locked -p dst-server --all-targets
uv run ruff check python tests/rust tools
uv run ty check python
uv run --locked --only-group dev pytest tests/lua
uv build --python 3.14 --out-dir dist --no-sources

CI builds a wheel from the source distribution and installs it in an isolated environment. It then runs native binding and process tests. Lua contracts run under Lua 5.1 and LuaJIT, with each scenario selectable through pytest. See the testing guide for tool setup, focused commands and test design principles. Game build 756039 has an unresolved native shutdown failure observed with five connected shards. Gorge and Forge deliberately disable saving; accepted save requests do not establish persistence. Real game probes use disposable directories and containers; they must not target live rooms. Build manifests record SDK, Lua, protocol, native game and artifact identities. See the migration guide for verification and the deployment procedure.

About

Don't Starve Together Dedicated Server Image

Topics

Resources

Stars

13 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages