Skip to content

Hosting Godot dedicated servers

Gameye runs Godot 4 dedicated servers as Linux containers. Each match is one server process in its own Gameye session, on a dedicated server in the region you choose. Your project needs no Gameye plugin or SDK.

  1. Export a dedicated server build of your project for Linux.
  2. Package it as a Linux container and push it to Docker Hub or GitHub Container Registry.
  3. Create a Gameye application for the image, with the game port registered as UDP (7777 in this guide).
  4. When your matchmaker forms a match, your backend calls POST /session and sends the players the session’s IP and port.
  5. The server exits when the match ends. That ends the session.
  • A Gameye account. Sign up at trial.gameye.com.
  • An API token for your backend, created in the Admin Panel under Users → API tokens.
  • Godot 4 with the export templates for your exact version installed (Editor → Manage Export Templates).
  • Docker.

Use Godot’s high-level multiplayer with an authoritative server. The server listens on a fixed port. In bridge mode, the default, Gameye maps that port to a free port on the node.

# server_bootstrap.gd: add as an autoload
extends Node
const PORT := 7777
const MAX_CLIENTS := 16
func _ready() -> void:
if not OS.has_feature("dedicated_server"):
return
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(PORT, MAX_CLIENTS)
if err != OK:
push_error("bind failed: %s" % err)
get_tree().quit(1)
return
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(func(id): print("JOIN %d" % id))
multiplayer.peer_disconnected.connect(func(id): print("LEAVE %d" % id))
print("READY port=%d" % PORT)
  • Treat every @rpc("any_peer") call as untrusted input, and never let a client set an outcome directly.
  • print() goes to stdout, which is what the logs endpoint returns.
  • Set Engine.max_fps to your tick rate. Otherwise the headless main loop spins a CPU core flat out.
  • For browser clients, use WebSocketMultiplayerPeer on a TCP port instead of ENet. Browsers on https pages need a secure WebSocket, so read the TLS section of the Colyseus and Node.js guide.
  1. In Project → Export, add a Linux preset for the server.
  2. On the Resources tab, set Export Mode to Export as dedicated server. This strips textures and audio from the export and turns on the dedicated_server feature tag that the bootstrap checks.
  3. Export from the command line, so the same step runs in CI:
Terminal window
godot --headless --export-release "LinuxServer" build/game-server.x86_64

Keep server code away from the stripped resources, or give the server its own entry scene. Run the build once before you containerize it:

Terminal window
./build/game-server.x86_64 --headless

It should print READY port=7777 and keep running.

When Gameye stops a session, for example after a DELETE /session/{id} or when its ttl runs out, the container gets SIGTERM. Godot 4 doesn’t pass SIGTERM to your scripts. In a drain test on Godot 4.7.2, Godot as the container’s main process ignored it until Docker killed it, and as a child process it died without running any script.

The fix has two parts. A small shell script in the container (next section) turns SIGTERM into a stop file. An autoload checks for that file and runs one shutdown path, which the natural match end uses too:

# match_lifecycle.gd: add as an autoload
extends Node
const STOP_FILE := "/tmp/gameye-stop" # start.sh creates it on SIGTERM
var _stopping := false
func _ready() -> void:
get_tree().set_auto_accept_quit(false)
var poll := Timer.new()
poll.wait_time = 0.25
poll.timeout.connect(_check_stop_file)
add_child(poll)
poll.start()
func _check_stop_file() -> void:
if FileAccess.file_exists(STOP_FILE):
shutdown()
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST: # a normal quit request, not SIGTERM
shutdown()
func shutdown() -> void:
if _stopping:
return
_stopping = true
print("SHUTDOWN")
get_tree().create_timer(10.0).timeout.connect(get_tree().quit.bind(0))
await report_results() # tell your backend how the match ended
get_tree().quit(0)
func report_results() -> void:
pass

Call MatchLifecycle.shutdown() when the match ends. The process exits with code 0, the session ends and billing stops. Signal handling can change between Godot versions, so test the stop path again after an engine upgrade.

The entrypoint is the shell script that runs Godot and turns SIGTERM into the stop file. Build the image for linux/amd64.

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 10001 game \
&& useradd --system --uid 10001 --gid game --create-home --home-dir /home/game --shell /usr/sbin/nologin game
WORKDIR /server
COPY --chown=game:game build/game-server.x86_64 build/game-server.pck /server/
COPY <<'EOF' /server/start.sh
#!/bin/sh
trap 'touch /tmp/gameye-stop' TERM INT
rm -f /tmp/gameye-stop
stdbuf -oL -eL /server/game-server.x86_64 --headless "$@" &
child=$!
wait "$child"; rc=$?
while kill -0 "$child" 2>/dev/null; do wait "$child"; rc=$?; done
exit "$rc"
EOF
RUN chmod +x /server/game-server.x86_64 /server/start.sh
USER game
ENTRYPOINT ["/server/start.sh"]

Keep ENTRYPOINT in this exec form so SIGTERM reaches the script. The game user has a home folder because Godot keeps its user data (user://) there and logs errors at startup without one. stdbuf -oL makes Godot write each print() line as it happens. Gameye runs containers with a terminal, so the logs endpoint is live either way, but with plain docker run or in CI, Godot holds its output in a buffer and docker logs shows nothing until it fills. The inline COPY <<'EOF' needs BuildKit, which is the default builder since Docker 23. If you export with Embed PCK, drop game-server.pck from the COPY line.

Terminal window
docker build --platform linux/amd64 -t mygame/godot-server:build-001 .
docker run --rm -p 7777:7777/udp mygame/godot-server:build-001
docker push mygame/godot-server:build-001

Use a new tag for every build. Nodes that already have a tag don’t pull it again. See Tagging rules.

Clients ping the regions that can run your image, and your matchmaker picks one:

Terminal window
curl https://api.production-gameye.gameye.net/available-location/godot-server \
-H "Authorization: Bearer $GAMEYE_API_TOKEN"

When the match is ready, your backend starts a session:

Terminal window
curl -X POST https://api.production-gameye.gameye.net/session \
-H "Authorization: Bearer $GAMEYE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"image": "godot-server",
"location": "eu-west-1",
"version": "build-001",
"args": ["--", "--map=arena"],
"env": { "MATCH_ID": "abc-123" },
"ttl": "1h"
}'

Self-serve accounts use api.sandbox-gameye.gameye.net instead of the production host. Godot keeps the arguments after -- for your game: read them with OS.get_cmdline_user_args(), and read env with OS.get_environment(). Don’t put secrets in env, because anyone in your organization who can read sessions can see it.

The response holds the node IP and the port mapping:

{
"id": "a3b2c1d0-...",
"host": "203.0.113.42",
"ports": [{ "type": "udp", "container": 7777, "host": 49152 }]
}

Players connect to host on ports[].host, not to 7777:

var peer := ENetMultiplayerPeer.new()
peer.create_client(host, port) # e.g. "203.0.113.42", 49152
multiplayer.multiplayer_peer = peer

Gameye isn’t in the network path during the match. See Custom matchmaker for region selection and player tracking.

gameye-skills has a Godot server skill with these templates, and the gameye-mcp server can check your Dockerfile and start sessions for you. See AI agents.