Deploying A ULinkGame Server To Multiple Debian 13 Machines
This guide shows a practical deployment shape for a generated ULinkGame server on Debian 13 machines.
The target topology is deliberately simple:
- one or more Gateway machines that accept client connections
- one or more State machines that run authoritative game state
- a private network between server machines
- a public load balancer or reverse proxy in front of Gateway
- systemd services to keep each process running
The commands assume:
- Debian 13 on every server
systemdnftablesfor host firewall rulesnginxon the public reverse-proxy machine when using WebSocket transport- self-contained .NET publish output, so production hosts do not need the .NET runtime installed
ULinkGame does not try to own your whole operations platform. You still choose your cloud, firewall rules, database, secrets store, logs, metrics, and rollout process. The framework gives you a C# server shape that can be split cleanly across machines.
Before You Deploy
Start from a project that already runs locally:
ulinkgame-tool new --name MyGame --client-engine unity --transport websocket --serializer json
cd MyGame
dotnet run --project Server/State/State.csproj
dotnet run --project Server/Server/Server.csproj
Use your generated project’s actual state project path if you renamed it.
For a multi-machine deployment, decide these values first:
- public Gateway host name, such as
game.example.com - public Gateway port, usually
443 - internal Gateway endpoint, such as
10.0.1.10:20000 - internal State endpoint, such as
10.0.2.10:21000 - database connection strings, if your project uses persistence
- per-environment secrets, certificates, and API keys
Do not put production secrets in the repository. Put them in your deployment platform, systemd environment files with locked-down permissions, or a dedicated secret manager.
Recommended Machine Layout
A small production-like layout can start with three machines:
load-balancer-1
public 443 -> gateway-1:20000 and gateway-2:20000
gateway-1
runs Server/Server
listens on 0.0.0.0:20000 inside the private network
gateway-2
runs Server/Server
listens on 0.0.0.0:20000 inside the private network
state-1
runs Server/State
accepts only private network traffic
Keep the state process off the public internet. Only Gateway should be reachable from players.
For the first production deployment, keep the routing model boring:
- Gateway nodes are public ingress.
- State nodes are private.
- Databases are private.
- Health checks are explicit.
- Rollouts replace one node at a time.
Publish The Server
Build on CI or on a build machine that has the .NET SDK installed:
dotnet publish Server/Server/Server.csproj -c Release -r linux-x64 --self-contained true -o artifacts/linux-x64/gateway
dotnet publish Server/State/State.csproj -c Release -r linux-x64 --self-contained true -o artifacts/linux-x64/state
This is the recommended Debian 13 path for a first deployment because the published directory contains the runtime it needs. If your operations team already manages .NET runtime versions on every host, you can use framework-dependent output instead:
dotnet publish Server/Server/Server.csproj -c Release -o artifacts/linux-x64/gateway
dotnet publish Server/State/State.csproj -c Release -o artifacts/linux-x64/state
With framework-dependent output, install the matching .NET runtime on each Debian 13 server before starting systemd services.
Prepare Debian 13 Hosts
On each Debian 13 machine, install basic packages:
sudo apt update
sudo apt install -y ca-certificates curl rsync nftables
On the reverse-proxy machine, also install Nginx:
sudo apt install -y nginx
Enable the firewall service:
sudo systemctl enable --now nftables
Create a dedicated user and directories on every application host:
sudo useradd --system --create-home --shell /usr/sbin/nologin ulinkgame
sudo mkdir -p /opt/mygame/gateway /opt/mygame/state /etc/mygame
sudo chown -R ulinkgame:ulinkgame /opt/mygame
sudo chmod 750 /etc/mygame
Copy the published files:
rsync -av artifacts/linux-x64/gateway/ gateway-1:/opt/mygame/gateway/
rsync -av artifacts/linux-x64/gateway/ gateway-2:/opt/mygame/gateway/
rsync -av artifacts/linux-x64/state/ state-1:/opt/mygame/state/
For self-contained builds, make the entry files executable if needed:
chmod +x /opt/mygame/gateway/Gateway
chmod +x /opt/mygame/state/State
Configure Gateway
Create /etc/mygame/gateway.env on each Gateway host:
DOTNET_ENVIRONMENT=Production
Endpoint__Transport=websocket
Endpoint__Host=0.0.0.0
Endpoint__Port=20000
Endpoint__Path=/ws
If your project has internal cluster settings, configure them with environment variables too:
Cluster__NodeId=gateway-1
Cluster__NodeEpoch=1
Cluster__InternalEndpoint=tcp://10.0.1.10:21000
Cluster__RouteDirectoryEndpoint=tcp://10.0.2.10:21001
Cluster__RouteLeaseSeconds=30
Cluster__SendTimeoutMilliseconds=2000
Use a unique Cluster__NodeId per machine. Do not copy gateway-1 to every host.
Create /etc/systemd/system/mygame-gateway.service:
[Unit]
Description=MyGame Gateway
After=network-online.target
Wants=network-online.target
[Service]
User=ulinkgame
Group=ulinkgame
WorkingDirectory=/opt/mygame/gateway
EnvironmentFile=/etc/mygame/gateway.env
ExecStart=/opt/mygame/gateway/Gateway
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=mygame-gateway
[Install]
WantedBy=multi-user.target
For a framework-dependent build, use dotnet and the DLL instead:
ExecStart=/usr/bin/dotnet /opt/mygame/gateway/Server.dll
Start it:
sudo systemctl daemon-reload
sudo systemctl enable --now mygame-gateway
sudo systemctl status mygame-gateway
Configure The State Process
Create /etc/mygame/state.env on the state host:
DOTNET_ENVIRONMENT=Production
State__Host=0.0.0.0
State__Port=21000
Use your project’s actual configuration keys. The important rule is that the state process listens only on private network addresses.
Create /etc/systemd/system/mygame-state.service:
[Unit]
Description=MyGame State
After=network-online.target
Wants=network-online.target
[Service]
User=ulinkgame
Group=ulinkgame
WorkingDirectory=/opt/mygame/state
EnvironmentFile=/etc/mygame/state.env
ExecStart=/opt/mygame/state/State
Restart=always
RestartSec=5
KillSignal=SIGINT
SyslogIdentifier=mygame-state
[Install]
WantedBy=multi-user.target
If your project publishes a differently named state entrypoint, replace State with that file. For a framework-dependent build, use /usr/bin/dotnet /opt/mygame/state/State.dll.
Start it:
sudo systemctl daemon-reload
sudo systemctl enable --now mygame-state
sudo systemctl status mygame-state
Put A Reverse Proxy In Front Of Gateway
For WebSocket transport, terminate TLS at Nginx or your cloud load balancer and forward to Gateway. If you use Let’s Encrypt on the Debian 13 reverse-proxy machine, install Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot certonly --nginx -d game.example.com
Then create /etc/nginx/sites-available/mygame.conf:
upstream mygame_gateway {
server 10.0.1.10:20000;
server 10.0.1.11:20000;
}
server {
listen 443 ssl http2;
server_name game.example.com;
ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem;
location /ws {
proxy_pass http://mygame_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 300s;
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/mygame.conf /etc/nginx/sites-enabled/mygame.conf
sudo nginx -t
sudo systemctl reload nginx
For TCP or KCP transport, use a layer-4 load balancer instead of HTTP reverse proxy rules. Keep the public listener stable and route only to Gateway machines.
Firewall Rules
Use firewall rules that match the process boundary:
- public internet can reach only the load balancer or Gateway public port
- Gateway machines can reach State machines on private ports
- State machines can reach databases and route-directory dependencies
- databases are never publicly reachable
- SSH is restricted to your operations network
On Debian 13, use nftables. A Gateway host that accepts traffic from a private 10.0.0.0/16 network and SSH from an operations network might use /etc/nftables.conf like this:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
policy drop;
iif "lo" accept
ct state established,related accept
ip saddr 10.0.0.0/16 tcp dport { 20000, 21000 } accept
ip saddr 203.0.113.0/24 tcp dport 22 accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
}
chain forward {
type filter hook forward priority 0;
policy drop;
}
chain output {
type filter hook output priority 0;
policy accept;
}
}
Apply it:
sudo nft -f /etc/nftables.conf
sudo systemctl restart nftables
sudo nft list ruleset
Replace 203.0.113.0/24 with your real operations network. If Nginx runs on the same Gateway host, also allow public 443 and keep 20000 private:
tcp dport 443 accept
Health Checks And Logs
If the generated project includes cluster deployment scaffolding, Gateway supports:
cd /opt/mygame/gateway
./Gateway --health-check
For a framework-dependent build, use dotnet Server.dll --health-check instead.
Use it from systemd, CI smoke tests, or your load balancer health probe when appropriate. A basic manual check is:
journalctl -u mygame-gateway -f
journalctl -u mygame-state -f
systemctl status mygame-gateway
systemctl status mygame-state
For production, add structured logs, metrics, and alerts around:
- Gateway process restarts
- failed client connection attempts
- reconnect and state-lost results
- reliable push replay count and pending count
- state process latency
- database latency
- cluster route lookup failures, if cluster mode is enabled
Optional Compose Rehearsal
For a local cluster deployment rehearsal, generate with:
ulinkgame-tool new --name MyGame --deploy-profile compose
That profile can generate:
Server/Dockerfiledocker-compose.cluster.yml.env.cluster.exampleops/CLUSTER_OPERATIONS.md
Use this to validate packaging and environment-variable wiring before moving to real Debian 13 hosts. Do not treat the generated compose file as a complete production platform. It intentionally avoids production secrets and durable infrastructure choices.
Rolling Out A New Version
A conservative rollout looks like this:
- Publish a new version into a versioned directory such as
/opt/mygame/releases/2026-05-26-1030/gateway. - Stop one Gateway node.
- Replace its current symlink or copied files.
- Start the Gateway node.
- Run health checks and a client smoke test.
- Repeat for the next Gateway node.
- Roll state nodes only after you know how active sessions and authoritative state should drain or recover.
Do not restart all Gateway and State nodes at the same time unless your game flow can tolerate every session reconnecting or starting fresh.
For stateful services, prefer explicit draining:
- stop accepting new ownership or new rooms
- finish bounded in-flight work
- flush required state
- stop the process
- start the new version
Client Configuration
Clients should connect to the public endpoint, not directly to a machine:
wss://game.example.com/ws
Do not ship private IP addresses in the client. Keep the client endpoint stable so you can replace Gateway machines without publishing a new game build.
Common Failures
If the client cannot connect:
- Confirm Gateway is running with
systemctl status mygame-gateway. - Check
journalctl -u mygame-gateway. - Confirm the load balancer forwards WebSocket upgrade headers.
- Confirm the public path matches
Endpoint__Path. - Confirm firewall rules allow traffic to Gateway.
If Gateway starts but cannot reach state:
- Confirm the state process is running.
- Check private network routing between hosts.
- Confirm internal endpoint environment variables are different per host.
- Confirm no production secret or connection string is missing.
- Check route-directory or cluster dependency health if cluster mode is enabled.
If reconnect behaves badly after deployment:
- Confirm clients reconnect to the same public endpoint.
- Confirm session state is not being lost unexpectedly during restarts.
- Confirm reliable push acknowledgement storage matches your expected durability model.
- Use explicit
StateLostorStateRefreshRequiredhandling instead of pretending every reconnect can resume.
Summary
The deployment model is:
- Publish Gateway and State as separate Debian 13
linux-x64artifacts. - Run each process under systemd with environment-based configuration.
- Put only Gateway behind a public load balancer or reverse proxy.
- Keep State, databases, and cluster dependencies on private networks.
- Use health checks, logs, and one-node-at-a-time rollouts.
Start with the simplest two-machine deployment, then add more Gateway or State nodes only when load, isolation, or operations needs justify it.