Gaming Azure

Colyseus on Ubuntu 24.04 on Azure User Guide

| Product: Colyseus on Ubuntu 24.04 LTS on Azure

Overview

This image runs Colyseus 0.17.10, the open source authoritative multiplayer game server framework for Node.js, on Ubuntu 24.04 LTS. Colyseus gives you the server half of a realtime multiplayer game: rooms that hold a match or a lobby, schema based state synchronisation that replicates room state to every connected client over WebSocket and sends only the deltas, matchmaking, a server side simulation loop, and an admin Monitor panel for watching live rooms and clients.

Colyseus is authoritative by design: clients send messages, and only the server mutates state. That is what stops a modified client from rewriting the game world, and it is the property this image is built to demonstrate.

The image ships a complete, running Colyseus server with a working example room, run by an unprivileged colyseus system account under a systemd service. It runs on Node.js 24 LTS and needs no external database - Colyseus's default local driver keeps the room registry and room state in memory on the single VM, so there is nothing else to provision.

Two public ports are served, both by nginx, which is the only front door. The Node process itself binds 127.0.0.1:2568 and is never directly reachable. Port 80 carries game traffic and the admin Monitor panel; port 2567 carries game traffic only, because 2567 is the port Colyseus client SDKs default to and it is convenient to be able to use the default endpoint.

What is included:

  • Colyseus 0.17.10 with @colyseus/monitor 0.17.8 and the @colyseus/sdk 0.17.43 client, at exact pinned versions
  • Node.js 24 LTS from NodeSource, and the TypeScript toolchain, so you can write and build your own rooms on the VM
  • A complete example room at /opt/colyseus/src/rooms/GameRoom.ts showing schema state, authoritative message handling, a simulation loop and authentication
  • The admin Monitor panel on :80/monitor, gated by HTTP Basic Auth with a credential unique to your VM
  • Game traffic on :80 and on :2567 (the client SDK default port), with WebSocket upgrade
  • A join token unique to your VM: matchmaking refuses any client that does not present it. No default login and no default token ship in the image
  • colyseus.service and nginx.service as systemd units, enabled and active
  • An unauthenticated /healthz endpoint, proxied to the Node process, for Azure Load Balancer health probes
  • 24/7 cloudimg support

Prerequisites

An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point; size up as concurrent player counts grow. NSG inbound: allow 22/tcp from your management network, 80/tcp for the Monitor panel and game traffic, and 2567/tcp if you want your clients to use the Colyseus SDK default port. Colyseus serves plain HTTP and WebSocket here; for production, terminate TLS in front of it with your own domain so clients can use wss://.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Colyseus by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Then Review + create then Create. Add an inbound rule for TCP 2567 afterwards if you want the SDK default port.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name colyseus \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --vnet-name <your-vnet> --subnet <your-subnet> \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name colyseus --port 80 --priority 1010
az vm open-port --resource-group <your-rg> --name colyseus --port 2567 --priority 1020

Step 3 - Connect to your VM

ssh azureuser@<vm-public-ip>

Step 4 - Confirm the services are running

Colyseus is run by systemd as the unprivileged colyseus user. nginx is the only front door: it listens on :80 and :2567 and reverse-proxies to the Node process on 127.0.0.1:2568, including the WebSocket upgrade that carries room state. Confirm the services are active and note that the application port is loopback only:

systemctl is-active colyseus nginx colyseus-firstboot
sudo ss -tlnp | grep -E ':80 |:2567 |:2568 '

Expected output:

active
active
active
LISTEN 0      511          0.0.0.0:2567      0.0.0.0:*    users:(("nginx",pid=6351,fd=7),...)
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*    users:(("nginx",pid=6351,fd=5),...)
LISTEN 0      511        127.0.0.1:2568      0.0.0.0:*    users:(("MainThread",pid=6333,fd=22))
LISTEN 0      511             [::]:2567         [::]:*    users:(("nginx",pid=6351,fd=8),...)
LISTEN 0      511             [::]:80           [::]:*    users:(("nginx",pid=6351,fd=6),...)

colyseus, nginx and colyseus-firstboot active, with nginx holding the public ports 80 and 2567 while the Colyseus application listens only on loopback 2568

Step 5 - Retrieve your join token and Monitor credential

On the first boot of every VM, colyseus-firstboot.service generates two secrets that are unique to that VM: a room join token and a Monitor panel username and password. It writes them, with the URLs you need, into a root-only file. Read it with:

sudo cat /root/colyseus-credentials.txt

There is no default or shared credential in the image. Colyseus has no authentication of its own, and @colyseus/monitor is an unauthenticated admin panel upstream, so this image adds both gates and mints them per VM. The Monitor username is randomised (it is not admin), and the shipped image contains an empty join token and an empty password file, which means a captured image cannot be joined or signed in to at all until first boot has run. Store both secrets somewhere safe.

The per-VM Colyseus credentials file showing the Monitor URL, the randomised Monitor username, and a masked Monitor password and join token

Step 6 - Confirm the versions and health endpoints

nginx proxies an unauthenticated /healthz to the Node process, so a 200 means the application itself is answering, not merely that nginx is up. Confirm the runtime and framework versions and that both public ports respond:

node --version
node -p "require('/opt/colyseus/node_modules/colyseus/package.json').version"
node -p "require('/opt/colyseus/node_modules/@colyseus/sdk/package.json').version"
curl -sI http://127.0.0.1/healthz | head -1
curl -s -o /dev/null -w 'Monitor unauthenticated (:80): %{http_code}\n' http://127.0.0.1/monitor/
curl -s -o /dev/null -w 'Game port healthz (:2567): %{http_code}\n' http://127.0.0.1:2567/healthz

Expected output:

v24.18.0
0.17.10
0.17.43
HTTP/1.1 200 OK
Monitor unauthenticated (:80): 401
Game port healthz (:2567): 200

The 401 is the point: the Monitor panel is closed to anyone without your VM's credential.

Node 24.18.0, Colyseus 0.17.10 and the 0.17.43 client SDK, the healthz endpoint returning 200 on both ports, and the Monitor returning 401 without a credential

Step 7 - The Monitor front door is authenticated

The whole /monitor path sits behind nginx HTTP Basic Auth using a bcrypt password file that first boot wrote for this VM alone. Prove all three cases from the VM:

MU=$(sudo grep '^COLYSEUS_MONITOR_USERNAME=' /root/colyseus-credentials.txt | cut -d= -f2-)
MP=$(sudo grep '^COLYSEUS_MONITOR_PASSWORD=' /root/colyseus-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'no credential:      %{http_code}\n' http://127.0.0.1/monitor/
curl -s -o /dev/null -w 'wrong password:     %{http_code}\n' -u "${MU}:wrong-password" http://127.0.0.1/monitor/
curl -s -o /dev/null -w 'this VM credential: %{http_code}\n' -u "${MU}:${MP}" http://127.0.0.1/monitor/

Expected output:

no credential:      401
wrong password:     401
this VM credential: 200

The Monitor panel is also never served on port 2567 - that listener carries game traffic only and returns 404 for /monitor.

Step 8 - Matchmaking is gated by your join token

Game clients do not use the Monitor credential. They present the join token in their join options, and the room's onAuth check rejects anything else, so a stranger who finds your public IP cannot create or join rooms on your server:

JT=$(sudo grep '^COLYSEUS_JOIN_TOKEN=' /root/colyseus-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'join with no token:       %{http_code}\n' \
  -X POST -H 'Content-Type: application/json' --data '{}' \
  http://127.0.0.1/matchmake/joinOrCreate/game
curl -s -X POST -H 'Content-Type: application/json' --data "{\"token\":\"${JT}\"}" \
  http://127.0.0.1/matchmake/joinOrCreate/game

Expected output - the first call is refused, the second returns a seat reservation:

join with no token:       401
{"name":"game","sessionId":"pcJIjJb74","roomId":"96f0xgaJH","processId":"s0bThgqpa"}

Step 9 - Prove authoritative state synchronisation

This is what a multiplayer game server is for, so the image ships a check that proves it end to end. It connects two real clients with the @colyseus/sdk client library through the nginx front door, then asserts that the server owns the state:

JT=$(sudo grep '^COLYSEUS_JOIN_TOKEN=' /root/colyseus-credentials.txt | cut -d= -f2-)
cd /opt/colyseus && sudo COLYSEUS_JOIN_TOKEN="${JT}" node tools/state-sync-check.mjs

Expected output:

no-token join rejected: invalid join token
placeholder-token join rejected: invalid join token
two clients joined room 96f0xgaJH as xqof_Ta41 + wiz0cEe4P
state replicated to peer: players.size = 2
onJoin applied join options: name = alice
authoritative move applied server-side and replicated to peer: x=42 y=99
server simulation loop advancing: tick 2 -> 7
STATE_SYNC_OK

Read that middle section carefully, because each line is a distinct guarantee. Both clients appear in the second client's copy of the state, so state really is replicated. The move message is sent by client one but the server applies it, and client two sees the result - clients never write to each other. And the simulation tick keeps advancing on its own, which is the server-side game loop running.

The state sync check connecting two clients, replicating state to the peer, applying an authoritative move server side and advancing the simulation tick, ending in STATE_SYNC_OK

Step 10 - Sign in to the Monitor panel

Browse to http://<vm-public-ip>/monitor/. Your browser will prompt for a username and password; enter the Monitor credential from Step 5. Until you do, the panel gives you nothing - an unauthenticated request is refused by nginx before it ever reaches Colyseus:

A browser showing nginx returning 401 Authorization Required for the Colyseus Monitor panel when no credential is supplied

For production, put the panel behind your own hostname with TLS termination (your own reverse proxy or Azure Application Gateway) so the credential is not sent over plain HTTP, and restrict :80 to your management network in the NSG.

Step 11 - Watch live rooms

Once authenticated you land on the Monitor's room list: every room currently alive on the server, with its room ID, the name it was defined under (game here), how many clients are connected, its client limit, whether it is locked, and how long it has been running. Across the top sit the process counters - connections, room count, CPU and memory - so you can see the server's load at a glance. This is the view you keep open while a game is live.

The Colyseus Monitor room list showing 2 connections and 1 live game room with 2 of 32 clients and its elapsed time, above the CPU and memory counters

Step 12 - Inspect a room's clients

Choose INSPECT on a room to open it. The room view summarises whether the room is locked, how many of its client slots are filled and how large its state is, then lists every connected client by session ID. You can send a message to an individual client from here, broadcast to the whole room, or dispose of the room entirely.

The Colyseus Monitor room view showing the room unlocked with 2 of 32 clients and the CLIENTS tab listing each connected session ID

Step 13 - Read the room's authoritative state

Switch to the STATE tab. This is the room's state exactly as the server holds it - the players map keyed by session ID, each with the name from its join options and the x/y the server applied from that client's move messages, plus the tick counter that the simulation loop is advancing. When a client claims something the server disagrees with, this is where you find out who is right.

The Colyseus Monitor STATE tab showing the authoritative players map with alice at x 15 y 7 and bob at x 30 y 14, and the simulation tick counter

Step 14 - Connect your own game client

Your clients connect with a Colyseus client SDK and must pass the join token in the join options. Note that the 0.17 client library is the @colyseus/sdk package; the older colyseus.js package is for servers up to 0.16 and is not wire compatible with this server.

import { Client } from "@colyseus/sdk";

const client = new Client("ws://<vm-public-ip>");        // or ws://<vm-public-ip>:2567
const room = await client.joinOrCreate("game", {
  token: "<COLYSEUS_JOIN_TOKEN>",
  name:  "alice",
});

room.onStateChange((state) => {
  console.log("players:", state.players.size, "tick:", state.tick);
});

room.send("move", { x: 10, y: 20 });                     // the SERVER applies this

SDKs are available for Unity, Unreal, Godot, Defold, Haxe, Cocos and the browser; see the Colyseus client documentation. For production, front the server with TLS and use wss://.

Step 15 - Write your own room

The example room is the starting point, not the product - the point of Colyseus is that you write your own game logic. Rooms live in /opt/colyseus/src/rooms/, are registered in /opt/colyseus/src/index.ts, and the TypeScript toolchain is already installed on the VM. The cycle is edit, build, restart:

sudo -e /opt/colyseus/src/rooms/GameRoom.ts     # or add a new room file
sudo -e /opt/colyseus/src/index.ts              # gameServer.define("my_room", MyRoom);
cd /opt/colyseus && sudo npm run build
sudo systemctl restart colyseus

Two things to carry over from the example room. Keep the static onAuth() check, or your server becomes joinable by anyone who finds its address. And note that in Colyseus 0.17 the room generic is an options object - Room<{ state: GameState }> - not the pre-0.17 Room<GameState>, which no longer compiles. See the Colyseus room documentation.

Maintenance

  • Configuration: the runtime environment lives in /etc/colyseus/colyseus.env (owned root:colyseus, mode 0640) and holds the join token and the loopback bind address. Change a value there and run sudo systemctl restart colyseus.
  • Your game code: /opt/colyseus/src is the source of truth, /opt/colyseus/build is the compiled output. Keep your rooms in version control outside the VM.
  • Logs: the server logs to the journal. sudo journalctl -u colyseus -f follows it.
  • Security updates: unattended security upgrades are enabled, so the OS keeps itself patched. Reboot when a new kernel is installed.
  • Upgrading Colyseus: the dependency set is pinned exactly in /opt/colyseus/package.json. Bump the versions there, run sudo npm install && sudo npm run build, then restart the service. Check the upstream migration notes first, since Colyseus makes breaking changes across minor versions.
  • TLS: for production, front port 80 and 2567 with your own domain and a TLS-terminating reverse proxy or Azure Application Gateway, and have clients use wss://.
  • Rotate secrets: to force a fresh join token and Monitor credential, remove /var/lib/cloudimg/colyseus-firstboot.done and /var/lib/colyseus/.bootstrap-ready, then reboot; first boot regenerates both cleanly.

Support

cloudimg provides 24/7/365 expert technical support for this image. Contact support@cloudimg.co.uk. Colyseus is licensed under the MIT License. Colyseus Cloud is a separate commercial hosted service from the Colyseus project and is not part of this image. This image is provided by cloudimg; additional charges apply for build, maintenance and 24/7 support.