A high-performance UDP relay server for Command & Conquer games on CnCNet, supporting V2/V3 tunnel protocols, STUN-based P2P NAT traversal, DDoS protection and a real-time web dashboard.
- Ports
- Docker
- Linux (systemd)
- Windows
- Matchmaking Server
- Configuration Reference
- Country Lookup
- Web Dashboard
Open these on your firewall before starting the server.
| Port | Protocol | Purpose |
|---|---|---|
| 50001 | UDP | V3 Tunnel |
| 50000 | TCP+UDP | V2 Tunnel |
| 8054 | UDP | STUN Server |
| 3478 | UDP | STUN Server |
| 1337 | TCP | Web Dashboard |
| 50002 | UDP | V3 Tunnel — matchmaking (see below) |
| 1338 | TCP | Web Dashboard — matchmaking |
The recommended setup. Requires Docker with the Compose plugin.
1. Create a working directory and download the config template:
mkdir -p /opt/cncnet && cd /opt/cncnet
curl -O https://github.com/ghraw/Rowtag/cncnet-server/master/appsettings.json
curl -O https://github.com/ghraw/Rowtag/cncnet-server/master/docker-compose.yml2. Edit the config — set at minimum Server.Name and Maintenance.Password:
nano appsettings.json3. Start:
docker compose up -dUseful commands:
# View logs
docker compose logs -f
# Restart after config change
docker compose restart
# Update to latest image
docker compose pull && docker compose up -d
# Stop
docker compose downFirewall (UFW):
ufw allow 50000/tcp
ufw allow 50000/udp
ufw allow 50001/udp
ufw allow 3478/udp
ufw allow 8054/udp
ufw allow 1337/tcpNote: If using UFW with Docker, ensure
/etc/default/ufwhasDEFAULT_FORWARD_POLICY="ACCEPT"and restart Docker after any UFW reload:systemctl restart dockerUFW does not restrict published Docker ports. Docker inserts its own rules ahead of UFW, so the ports above are reachable from anywhere the moment the container starts, and a rule like
ufw deny 1337/tcporufw allow from <ip> to any port 1337has no effect on them. To actually limit access — the dashboard on 1337 above all — write the rule into theDOCKER-USERchain instead:iptables -I DOCKER-USER -p tcp --dport 1337 -s <your-ip> -j ACCEPT iptables -I DOCKER-USER -p tcp --dport 1337 -j DROPThese rules are lost on reboot and when Docker restarts, so persist them with a small systemd unit. Always verify from a host that is not on the allow list, otherwise you are testing nothing.
Requires .NET Runtime 10.
1. Install .NET runtime:
sudo apt-get update && sudo apt-get install -y dotnet-runtime-10.02. Create a dedicated user and install the binary:
sudo useradd -m -r cncnet-server
# Download and extract the latest release (adjust filename for your architecture)
sudo unzip cncnet-server-*-linux-x64.zip -d /opt/cncnet-server
sudo chown -R cncnet-server:cncnet-server /opt/cncnet-server
sudo chmod +x /opt/cncnet-server/cncnet-server3. Edit the config:
sudo nano /opt/cncnet-server/appsettings.json4. Create the systemd service — save as /etc/systemd/system/cncnet-server.service:
[Unit]
Description=CnCNet Tunnel Server
After=network.target
[Service]
Type=simple
User=cncnet-server
WorkingDirectory=/opt/cncnet-server
ExecStart=/opt/cncnet-server/cncnet-server
Restart=always
RestartSec=5
KillSignal=SIGINT
[Install]
WantedBy=multi-user.target5. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now cncnet-server
# Follow logs
sudo journalctl -u cncnet-server -f6. Open firewall ports:
sudo ufw allow 50000/tcp
sudo ufw allow 50000/udp
sudo ufw allow 50001/udp
sudo ufw allow 3478/udp
sudo ufw allow 8054/udp
sudo ufw allow 1337/tcpUpdating:
sudo systemctl stop cncnet-server
sudo unzip -o cncnet-server-*-linux-x64.zip -d /opt/cncnet-server
sudo systemctl start cncnet-serverRequires .NET Runtime 10.
1. Extract the release archive:
Expand-Archive cncnet-server-*-win-x64.zip -DestinationPath C:\cncnet-server2. Edit the config:
notepad C:\cncnet-server\appsettings.json3. Install and start as a Windows Service:
New-Service -Name CnCNetServer `
-BinaryPathName '"C:\cncnet-server\cncnet-server.exe"' `
-StartupType Automatic `
-DisplayName "CnCNet Tunnel Server"
Start-Service CnCNetServerUseful commands:
# View status
Get-Service CnCNetServer
# Stop / start
Stop-Service CnCNetServer
Start-Service CnCNetServer
# Remove service
Remove-Service CnCNetServerUpdating:
Stop-Service CnCNetServer
Expand-Archive -Force cncnet-server-*-win-x64.zip -DestinationPath C:\cncnet-server
Start-Service CnCNetServerFirewall:
New-NetFirewallRule -DisplayName "CnCNet" -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort 50000,1337
New-NetFirewallRule -DisplayName "CnCNet UDP" -Direction Inbound -Action Allow `
-Protocol UDP -LocalPort 50000,50001,3478,8054A matchmaking server is where two clients meet to swap tunnel lists and agree which relay tunnels to test, before either registers on a relay. That exchange is a few small packets lasting seconds, so one matchmaking server holds far more clients than a relay — which is the point: it stops every client having to register on every tunnel just to find a good one.
It does not carry game traffic. It relays only the negotiation exchange and drops everything else. It announces itself to the master list as version 4, so clients released before matchmaking existed ignore it entirely and can never pick it to host a game.
Matchmaking is a mode of the V3 tunnel, and a process has one V3 listener — so a server that does both roles runs the binary twice, from two directories, with two configs.
docker-compose.matchmaking.yml in this repository is the second instance, ready to run. Put it
in its own directory next to your game tunnel:
mkdir -p /opt/cncnet-matchmaking && cd /opt/cncnet-matchmaking
curl -O https://github.com/ghraw/Rowtag/cncnet-server/master/docker-compose.matchmaking.yml
curl -o .env https://github.com/ghraw/Rowtag/cncnet-server/master/.env.matchmaking.example
nano .env && chmod 600 .env
docker compose -f docker-compose.matchmaking.yml up -dThe .env carries the server name and both passwords, so nothing secret sits in the compose file.
GEO_DB points at the country database the game tunnel already uses — the two instances share one
file. Everything else is already set: V3 on 50002, V2 and STUN off so they cannot collide with the
first instance, the dashboard on 1338, and mem_limit/cpu_shares low enough that the game tunnel
wins when both want the machine.
Open UDP 50002, and TCP 1338 only if you want the second dashboard reachable — mind the note in the firewall section, published Docker ports ignore UFW. Then confirm the role took:
docker logs cncnet-matchmaking | grep "matchmaking mode"
# V3 Tunnel started on UDP port 50002 in matchmaking mode (max 2000 clients, 30s timeout, 32 per IP)If that line says anything else, the process came up as a plain relay and the master list will advertise it as one.
Not using Docker? The same thing by hand, in three steps. Copy your existing install to a second directory:
cp -r /opt/cncnet-server /opt/cncnet-server-matchmakingCreate /opt/cncnet-server-matchmaking/appsettings.local.json:
{
"Server": { "Name": "Your Server Name (Matchmaking)" },
"TunnelV3": { "Enabled": true, "Port": 50002,
"Matchmaking": { "Enabled": true, "MaxClients": 2000, "ClientTimeout": 25, "IpLimit": 32 } },
"TunnelV2": { "Enabled": false },
"PeerToPeer": { "Enabled": false },
"MasterServer": { "Enabled": true },
"WebMonitor": { "Enabled": true, "Port": 1338 },
"Logging": { "LogDirectory": "logs-matchmaking" },
"Maintenance": { "Password": "your-dashboard-password" }
}Four of those exist to avoid colliding with the first instance, and skipping any will bite you:
| Setting | Why |
|---|---|
PeerToPeer: false |
The first instance already holds STUN 8054/3478; binding them again kills startup. |
WebMonitor.Port: 1338 |
Same, for the dashboard on 1337. |
Logging.LogDirectory |
Two processes rolling the same log files fight over locks — and it fails quietly. |
TunnelV2: false |
No point running a second V2. |
sed 's/cncnet-server/cncnet-server-matchmaking/g' /etc/systemd/system/cncnet-server.service \
> /etc/systemd/system/cncnet-server-matchmaking.service
systemctl daemon-reload
systemctl enable --now cncnet-server-matchmakingOpen UDP 50002 (and TCP 1338 if you want the dashboard), then confirm it came up in the right role:
journalctl -u cncnet-server-matchmaking -n 20 | grep "V3 Tunnel started"
# V3 Tunnel started on UDP port 50002 in matchmaking mode (max 2000 clients, 25s timeout, 32 per IP)Within a minute it should appear in https://cncnet.org/master-list with a trailing version field
of 4. If it doesn't, check the log for heartbeat failed.
Only a handful of servers should run matchmaking. Every client contacts all of them on every lobby join, so the set is meant to stay small and stable.
All settings are read from appsettings.json in the working directory.
Priority order (highest wins): CLI arguments > Environment variables (CNCNET_*) > appsettings.local.json > appsettings.json
Security tip: Never put passwords in
appsettings.json. Useappsettings.local.json(excluded from version control) or environment variables instead.
{
"Server": {
"Name": "My CnCNet Server",
"MaxClients": 200,
"ClientTimeout": 60
},
"TunnelV3": {
"Enabled": true,
"Port": 50001,
"IpLimit": 8,
"DDoSProtectionEnabled": true
},
"TunnelV2": {
"Enabled": true,
"Port": 50000,
"IpLimit": 4,
"DDoSProtectionEnabled": true
},
"PeerToPeer": {
"Enabled": true,
"StunPort1": 8054,
"StunPort2": 3478
},
"MasterServer": {
"Enabled": true,
"Url": "https://cncnet.org/master-announce",
"Password": "",
"AnnounceIntervalSeconds": 60
},
"Maintenance": {
"Password": ""
},
"Security": {
"IpBlacklistDurationHours": 24,
"MaxPingsPerIp": 20,
"MaxPingsGlobal": 5000,
"ExternalBlacklistUrls": [
"https://www.spamhaus.org/drop/drop.txt",
"https://www.spamhaus.org/drop/edrop.txt",
"https://github.com/ghraw/firehol/blocklist-ipsets/master/firehol_level1.netset",
"https://www.binarydefense.com/banlist.txt"
]
},
"WebMonitor": {
"Enabled": true,
"Port": 1337
},
"Logging": {
"LogDirectory": "logs",
"RetentionDays": 15,
"RollingIntervalDays": 2,
"MinimumLevel": "Information"
}
}Key settings:
| Setting | Description |
|---|---|
Server.Name |
Server name shown on the master server list |
Server.MaxClients |
Maximum simultaneous tunnel clients |
Server.ClientTimeout |
Seconds before an idle client is dropped |
TunnelV3.IpLimit |
Max connections per IP on V3 (1–40) |
TunnelV3.RelayPacketCopies |
Copies sent of each relayed packet (1–3). 1 is normal. Above 1 trades upstream bandwidth for loss resilience, and delivers duplicate datagrams to clients — see the note below |
TunnelV3.Matchmaking.Enabled |
Run this V3 listener as a matchmaking server instead of a game relay |
TunnelV3.Matchmaking.MaxClients |
Client limit in matchmaking mode, replacing Server.MaxClients |
TunnelV3.Matchmaking.ClientTimeout |
Idle timeout in matchmaking mode, replacing Server.ClientTimeout |
TunnelV3.Matchmaking.IpLimit |
Per-IP limit in matchmaking mode, replacing TunnelV3.IpLimit |
TunnelV3.Matchmaking.MaxRelayPacketBytes |
Largest packet a matchmaking server will relay |
TunnelV2.IpLimit |
Max connections per IP on V2 (1–40) |
MasterServer.Password |
Password to register on the public master server |
Maintenance.Password |
Password to access the web dashboard |
Security.IpBlacklistDurationHours |
How long an auto-banned IP stays blocked |
RelayPacketCopies above 1 is a blunt instrument, and worth understanding before you reach for it:
- It only protects the server-to-receiver leg. A packet lost on its way to this server is gone before duplication happens.
- Copies go out back to back, so it survives a random single drop but not a burst — and congestion loses consecutive packets, which is exactly the case it misses.
- It multiplies outbound game bandwidth by that factor, and clients receive duplicate datagrams. Client negotiation traffic tolerates duplicates by design; confirm the game itself does before enabling it in anger.
It can also be changed live from the dashboard without restarting.
The dashboard can show the country of each connected client. The lookup runs against a local database file and only during the status poll, over the client set the tunnel already holds — there is no per-packet cost, and the raw address is never stored.
The database is not shipped with this repository. Download the free DB-IP Country Lite database (or a MaxMind GeoLite2 Country database) and place it where the server can read it:
# Docker: put it next to your docker-compose.yml and mount it
volumes:
- ./dbip-country-lite.mmdb:/app/dbip-country-lite.mmdb:ro
# Binary install: place it next to the executable
/opt/cncnet-server/dbip-country-lite.mmdbWithout the file the server starts normally and simply reports ?? as the country. If you
build the Docker image yourself, dropping the file into the build context is enough — it is
picked up automatically and the build works with or without it.
DB-IP Country Lite is licensed CC-BY 4.0 and requires attribution to DB-IP wherever the data is displayed.
Accessible at http://<your-server>:1337 (port configurable via WebMonitor.Port).
Set Maintenance.Password in your config to require a login. Leave empty to disable authentication.
Features:
| Feature | Description |
|---|---|
| Tunnel status | Connected clients, unique IPs, maintenance mode toggle |
| Security controls | Enable/disable DDoS protection and V3 packet validation |
| Configuration | Adjust IP limits and blacklist duration at runtime |
| Blocked IPs | View and manually unblock entries from the local blacklist |
| Log viewer | Last 50 log messages with level highlighting |
GPL-3.0 — see LICENSE for details.
- CnCNet Website
- DB-IP Country Lite — country data, CC-BY 4.0
made with love by Rowtag