There are two ways to run a Dot server, and the difference is what you want to operate.
dot-server-deploy
TMC’s server tool. YAML configuration, games downloaded as signed packs, roles, moderation, votes, a browser client and a site listing. This page.
dotserve
A shell script that starts your own game’s dot-server with sensible
defaults and prints a join address. For a game you are writing.
This page assumes Godot 4.7.2 and a terminal — see Getting started if you do not have them yet.
Quick start
git clone https://github.com/modcommunity/dot-server-deploy.git
cd dot-server-deploy
./setup.sh # find a runtime, wire the addons, write ./server
./server # start it
Windows: setup.bat, then .\server.ps1, which takes the same options and
accepts -Port as readily as --port.
setup.sh never overwrites a config file that already exists, so running it
again after an update is safe: your edits are the server’s configuration.
./server |
Start it. |
./server check |
Boot, load the game, shut down. Exit 0 if it worked. |
./server config |
The resolved configuration. |
./server games |
What is in content/. |
./server install-games |
Fetch the games named by --games (or TMC_GAMES) from the content origin. ./server does this for you at startup. |
./server pack <id> |
Publish content/<id> as a signed dot-cloud pack. --all does every one. |
./server verify <id> |
Check a published pack’s manifest against keys/content.pub. |
./server index |
Write dist/games.json and the descriptors a server installs from. Run it after pack --all. |
./server export-web |
Build the browser client, plus the loader the site uploads. |
./server export-native |
Build the native client the desktop app installs — one archive per platform. |
./server stamp-loader |
Point an existing web loader at a new --base without rebuilding. |
./server --help |
Every option. |
./server --port 27015 --name "My server"
./server --games g2gfast,playground # install and offer only these
./server -- +sv_cheats 1 +changelevel lobby
A game is not part of this build. Each is a signed dot-cloud pack on a content origin, and --games (or TMC_GAMES) names the ones this server installs and offers — g2gfast, gamemann/[email protected], or someone/their-game@latest. At startup the server fetches each game’s game.yml out of its pack, checks it against the pack’s signature, and downloads the pack; every player’s client downloads the same pack when it joins. Adding a game needs no new server build and no new client build. With no list, the server offers whatever is already in content/, which is what a hand-run server wants.
Exit codes are meaningful, so a supervisor can tell a misconfiguration from a
crash: 2 usage, 3 no runtime, 4 no project, 5 bad config, 6 port in
use, 7 bad content.
Run `./server check` before you open a port
It boots a real server, loads the game, shuts down and exits non-zero if any of that failed — the difference between “the config is wrong” and “the config is wrong and I have been telling people to connect for ten minutes”.
Looking at it yourself first
./play.sh # a server and the browser client, on the loopback
./play.sh playground # start on a particular game
./play.sh down
It prints a link. play.sh rebuilds the web export when it is out of date, puts the page that reads ?server= (embed.html) into the build beside Godot’s generated one, and warns when a published pack in dist/ is older than the game repository it was built from — the one part of the tree that goes stale, and a stale pack mounts cleanly, runs, and is last week’s game. Re-run ./server pack <id> when it says so.
Loopback HTTP only
An HTTPS page may not open a ws:// socket. play.sh works on 127.0.0.1 and
nowhere else; a public deployment needs TLS in front of it.
Directory layout
cfg/ what an operator edits
content/ the games this server offers: a game.yml each, the game itself is a pack
data/ what the server writes, including the downloaded packs
dist/ packs you published yourself, and the index servers install from
keys/ the signing key, if this box publishes. Never copied anywhere else
host/ the boot: YAML -> DotServer
client/ the client shell. Contains no game; it mounts what the server names
web/ the browser build, and the page that takes ?server= from the URL
cfg/ is read and data/ is written. content/ is written too when the server installs games, because each game’s game.yml is pulled out of its signed pack and written there; a deployment that carries its own content and sets no --games can mount it read-only.
What each file in cfg/ and data/ is
cfg/
server.yml name, slots, tickrate, queries, the game to boot
net.yml bind address, port, bandwidth
rcon.yml remote console. Generated once, printed once
auth.yml the identity backend, or nothing and everybody is a guest
groups.yml groups, as sets of permissions
vote.yml voting for the next game: rtv, nominations, time limits
permissions.yml who is in which group
security.yml abuse rules and anti-cheat, from dot-server-security. Dry run by default
log.yml log level, channels and destinations
content.json the content origins, and the public keys a pack must be signed with
content/<id>/ per-game configuration
content/
global/ loaded by every game
lobby/game.yml the lobby. The default, and a pack like every other game
<id>/game.yml one per game: which pack, which scene, how many players
data/
from_yaml.cfg what your YAML became
admins.json bans.json audit.jsonl listing.jsonA directory under content/ with a game.yml in it is a game, and changelevel switches between them with players still connected. Each game.yml says kind: pack and names its content id; the build itself contains no game, not even the lobby.
`data/from_yaml.cfg` is what your YAML became
Read it first when a setting appears to be ignored. cfg/*.yml is a surface
over dot-server’s own vocabulary, not a second vocabulary — an operator writes
net_port, dot-server calls it port — and that file is the translation.
An unknown key is reported, never fatal: refusing to boot because a config mentions a setting from a newer version is worse than ignoring it.
Where your changes go
Edit cfg/server/, never the shipped files, so an upgrade keeps your settings. The layers, the install lists and the secrets rule are in the Operator guide.
The settings that decide whether anybody can join
- net_portnet.yml
The game port,
6064out of the box. This is what players connect to and what your firewall has to allow — over TCP and UDP: TCP carries the browsers’ WebSocket, UDP the desktop app’s ENet and the server queries.setup.shopens both.- net_bind_ipnet.yml
0.0.0.0out of the box;setup.shsets127.0.0.1when you put nginx in front, which is correct behind a reverse proxy and wrong for a server the internet should reach directly. Set it to0.0.0.0or to the interface you mean.- net_transportnet.yml
dual(default): browsers over WebSocket on TCPnet_port, the desktop app over ENet on UDP, in the same match.websocketputs everyone on WebSocket;enetserves native clients only.- net_enet_port / net_enet_bind_ipnet.yml
ENet’s UDP port,
0for the same number asnet_port(shared with A2S and DQP, which are told apart by their first bytes). The bind followsquery_bind_ipwhen empty, because behind nginx the UDP side has to face the internet itself.- net_public_ipnet.yml
Only needed behind NAT. It goes into the join line and the listing; nothing binds to it.
- query_bind_ipserver.yml
Where the query listener sits, separately from the game. A reverse proxy forwards a WebSocket and cannot forward UDP, so a query listener that inherited
net_bind_ipwould be listening where nothing can ask it — and a tracker cannot tell that from a server that is down.*puts the query port, and only the query port, on every interface. RCON does not follow it.- a2s_enabledserver.yml
Answers the twenty-year-old query protocol beside Dot’s own, on the same socket. The trackers, uptime monitors and server browsers that already exist speak A2S and nothing else — including this site’s scanner.
Both protocols come from dot-server-query, which the server tool installs for you. A build without it answers nothing on the query port at all, and a server nothing can ask is a server nothing can list.
- sv_name / sv_maxplayersserver.yml
What the listing and the server browser show, and how many seats there are.
Permissions
dot-server’s model is flags. groups.yml is the translation into roles, so
an operator writes a role and a player gets the flags:
groups:
admin:
permissions: [kick, ban, mute, changemap, chat, vote]
users:
gamemann: # by name, for somebody who plays here
group: owner
backbone:clx8f2k0kd: # by uid, for somebody who does not
group: admin
A key is matched against a player’s uid, their username and their display name, in that order, lower-cased.
An unrecognised flag grants nothing, silently
warn, announce and change were all written here in good faith and matched
nothing, so a group meant to run the server could kick, ban and mute and could
not change the map, use admin chat, or be recognised as staff. Nothing errored,
because an unknown flag is exactly what a game’s own flag looks like.
The flags that exist are listed at the top of groups.yml, and unknown ones are
named at boot. Read that line.
`permissions.yml` does nothing without authentication
Permissions are refused to any unauthenticated session, because a guest uid is a random per-device string and granting anything to one grants it to anyone. That is correct, and it looks exactly like the file being ignored.
Letting players choose what runs next
cfg/vote.yml puts dot-vote over the games in
content/:
!game_nominate !game_rtv !game_vote !game_timeleft !game_next
The prefix is deliberate. A game can run a vote of its own — the timer game votes for its next map — and that game keeps the bare !rtv and !nominate players already know. !rtv is always about the game in front of you; !game_rtv is always about which game is next.
Each game gets its own time limit in its own game.yml, because forty minutes
of surf and ten minutes of a lobby deathmatch are not the same number.
enabled: false turns the whole thing off.
vote.yml is the one file that is not a console surface — it is dot-vote’s
DotVoteRules applied straight onto the resource, so every setting is
documented on the property of the same name, and enums are written by name
(method: instant_runoff, not method: 2).
RCON
RCON is on and loopback-only out of the box: setup.sh writes a generated password into cfg/rcon.yml once and prints it once, rcon_allowed ships as 127.0.0.1, and an empty password turns the listener off.
--rcon-password is refused outright: argv is readable by every other process
on the machine and ends up in pasted bug reports, which is why configuration
refuses secrets from argv and the environment too. In the container RCON is
published on loopback only — reach it through an SSH tunnel, or widen the
mapping deliberately with an address allow-list in cfg/rcon.yml.
In a container
docker compose up -d
docker compose logs -f
The build context is the parent directory: every addon is its own repository
and there is no way to clone the tree at once, so they are siblings rather than
subdirectories. cfg/ and data/ are bind mounts, so the first run writes a
configuration you can edit and an RCON password that survives a rebuild.
content/ is mounted read-write, because installing a game writes its game.yml there; put :ro back on a deployment that ships its own content and sets no TMC_GAMES. The container runs as your own uid
(UID=$(id -u) GID=$(id -g) docker compose up -d) and drops every capability.
Getting it onto the site
Two halves, and they are independent.
The listing you create on the site — see
Getting listed and claiming. The scanner queries the
address you give it, which is why a2s_enabled and query_bind_ip matter.
Reporting is the server telling its listing what it is. It needs
data/listing.json with a server-scoped integration token:
{
"backbone_url": "https://moddingcommunity.com",
"integration_token": "…",
"report_interval_sec": 60
}
With no token nothing runs and the server is simply not listed, which is correct
for a LAN game and for every test. The token is a path, never a value:
integration_token is refused from the environment and from argv, and the file
is per-server, so several servers sharing one cfg/ each have their own
listing.
Why a server reports at all, when it could be queried
Three things break a query and only one is fixable from the server: a server behind a reverse proxy has its game port on loopback and a proxy cannot forward UDP; a scanner will not query a private address, and is right not to, so a LAN deployment is unscannable by design; and a query packet is lossy, rate-limited and truncates a player list.
A server that reports itself also knows two facts a packet cannot carry: how many of its players are bots, and which games it can run — which for a multi-game server is the interesting half, because the game it happens to be running this minute is not what it is.
The chat box on the site
DotChatRelay joins your server’s chat to its room on the website: what players
type reaches the page, what members type reaches the game, and a line beginning
with / or ! can run as a console command. It needs three things, in order.
Setting up the relay
1. A credential. The same data/listing.json as above. With no token there
is no backbone client, and the relay refuses to start rather than polling a URL
it cannot authenticate to.
2. The relay turned on.
DOT_CHAT_RELAY_ENABLED=1 # carry chat both ways
DOT_CHAT_RELAY_ALLOW_COMMANDS=1 # let `/something` run as a console command
DOT_CHAT_RELAY_COMMAND_SOURCE=2 # 2 is RCON; 3, the default, is CHATThe source is the decision. At CHAT a relayed command reaches only what a connected player could type — and several games deliberately withhold a map change from that, because a map change destroys every run in progress. At RCON it reaches what an operator at a remote console reaches, which is what “my site admins are administrators of this server” actually means. Either way the uid is resolved from the site author and the permission answer is still your own files.
3. One uid in cfg/permissions.yml. A site member’s uid is backbone: plus
a database id that appears on no page, so use it once and read the log: type
/map into the server’s chat box on the website and the refusal names the exact
key to add.
There is deliberately no way to grant it by the display name shown beside the message: a display name is a string the person can change on their own profile page, and a permission keyed on one is a permission anybody can take by renaming themselves.
Once the relay is up the server posts its command table to the site, and the
chat box offers those commands when a member types /. The list is built at the
relay’s own source, so what the menu shows is what that person could actually
run.
Which browser build your players load
The browser client is a shared engine build that the site frames your game in. A server tells the site which build matches the addons it actually runs by reporting the variable sv_web_build with its other replicated settings, and its players are given that build instead of the site-wide default. That keeps a server working when the shared build moves on before you have reinstalled.
You do not register the build by hand. The site only honours a build it has a record of, so a worker job registers a reported build once its entry page answers on the game content origin. Ids must have the timestamp shape the publish job emits (YYYYMMDD-HHMM, optionally with seconds), the site never follows a redirect while checking, and a miss is remembered for ten minutes. A server that reports an unknown or not-yet-uploaded id simply gets the default build until the next check succeeds.
Known limits
- The desktop app’s UDP path is unencrypted. Browsers join over WebSocket (TLS through your proxy); the desktop app joins over ENet on the game’s UDP port with no certificate. A sign-in that would send an access token or a password is moved to WebSocket automatically; a server ticket is safe in the clear. Open the game port over UDP as well as TCP, and remember a proxy cannot carry it.
- No TLS of its own. A page on HTTPS cannot open
ws://. Certificates and a reverse proxy are deployment, not code. cfg/permissions.ymldoes nothing without authentication — see above.- A game that declares pack dependencies needs a recent client. A client shell exported before dependency support joins without the extra packs. Every game ships as a pack, so this is the one case where an old client is not enough.
