What Nakama is, and what one node means
Nakama is Heroic Labs' game backend, one Go binary for accounts, sessions, realtime chat and multiplayer, matchmaking, leaderboards and storage. Your logic loads from data/modules as JavaScript, Lua or Go modules. Clustering is a Nakama Enterprise feature, so the open-source build is one node.
Know the vendor's position. The configuration page says Nakama is built to run in production as a highly available cluster, on Enterprise or Heroic Labs' managed hosting. The Linux install page officially supports CockroachDB 20.0+ and calls PostgreSQL 9.6+ unofficial, for development only, and the Docker Compose page targets a local development instance. We chose PostgreSQL for pg_dump backups on one box. The repository's CockroachDB compose file runs --insecure with ports open on every interface, a development file as well.
Four ports, one of them public
| Port | Carries | Here |
|---|---|---|
| 7350 | Client HTTP API, plus the realtime socket the client libraries open on the same port | 127.0.0.1, served on 443 by Caddy |
| 7349 | gRPC API | Not published |
| 7351 | Nakama Console, the admin web UI | 127.0.0.1, via SSH tunnel |
| 7348 | gRPC behind the console | Not published |
Docker publishes an unqualified mapping on 0.0.0.0 and [::], and published ports bypass ufw; a 127.0.0.1 binding stays local on Docker Engine 28 or newer, which block 1 checks. For TLS, config.go advises a proxy over Nakama's own certificate settings.
Sizing the machine
Heroic Labs publishes no minimum hardware. On its Benchmarks page the open-source build, given 1 CPU and 3 GB of RAM, held 20,277 concurrent sockets against a separate 8 vCPU, 30 GB database; those sockets idled rather than played.
Our rule of thumb is a single- or dual-socket rack server with SSDs from the dedicated server range; every racked single-, dual- and quad-socket server has an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC). Pick an x86 line, as these commands install amd64 packages and the range includes ARM machines.
Setting it up on Ubuntu 24.04
As root (sudo -i), add Docker Engine from Docker's apt repository with these four commands, in order, stopping if any prints an error.
apt-get update && apt-get install -y ca-certificates curl ufw openssl && install -m 0755 -d /etc/apt/keyringscurl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.ascecho "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" > /etc/apt/sources.list.d/docker.listapt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && systemctl enable --now docker- Turn on ufw with SSH only. The OpenSSH profile opens port 22 alone; allow your own port instead if sshd listens elsewhere.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable - Run block 1. It names the console user ops and generates every other secret. It writes nothing if Docker predates 28 or secrets exist.
- Run block 2. It confirms block 1's secrets are there, creates compose.yaml and brings both containers up.
# as root (sudo -i), after Docker and ufw are in place
D=/opt/nakama
v=$(docker version -f '{{.Server.Version}}' 2>/dev/null)
if [ "${v%%.*}" -ge 28 ] 2>/dev/null && [ ! -e $D/.env ] &&
[ ! -e $D/data/config.yml ]; then
install -d -m 700 $D $D/data $D/data/modules &&
P=$(openssl rand -hex 32) && S=$(openssl rand -hex 32) &&
E=$(openssl rand -hex 32) && R=$(openssl rand -hex 32) &&
H=$(openssl rand -hex 32) && G=$(openssl rand -hex 32) &&
C=$(openssl rand -hex 24) &&
[ ${#P}${#S}${#E}${#R}${#H}${#G}${#C} = 64646464646448 ] &&
install -m 600 /dev/null $D/.env &&
echo "POSTGRES_PASSWORD=$P" > $D/.env &&
install -m 600 /dev/null $D/data/config.yml &&
cat > $D/data/config.yml <<EOF && echo "STEP 1 ok" || echo "STOP - see the recovery note"
name: nakama1
database:
address:
- "postgres:$P@postgres:5432/nakama"
socket:
server_key: "$S"
session:
token_expiry_sec: 7200
encryption_key: "$E"
refresh_encryption_key: "$R"
runtime:
http_key: "$H"
console:
username: "ops"
password: "$C"
signing_key: "$G"
logger:
level: INFO
EOF
else
echo "STOP - Docker 28+ missing, or $D already holds secrets; nothing written"
fi
If block 1 stopped before STEP 1 ok and block 2 never ran, rm -f /opt/nakama/.env /opt/nakama/data/config.yml and rerun it; never after PostgreSQL has started. Root and docker-group users can read the keys in config.yml and the password via docker inspect; console users who open Configuration see the session and HTTP keys, which console_config.go leaves unredacted; every backup holds them all.
# as root, once block 1 said STEP 1 ok
D=/opt/nakama; F=$D/data/config.yml
if grep -q '^POSTGRES_PASSWORD=[0-9a-f]\{64\}$' $D/.env &&
grep -q 'server_key: "[0-9a-f]\{64\}"' $F &&
grep -q 'signing_key: "[0-9a-f]\{64\}"' $F; then
cat > $D/compose.yaml <<'EOF' &&
services:
postgres:
image: postgres:16-alpine
env_file: .env
environment:
POSTGRES_DB: nakama
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "nakama"]
interval: 3s
timeout: 3s
retries: 5
nakama:
image: heroiclabs/nakama:3.41.0
entrypoint:
- "/bin/sh"
- "-ec"
- >
/nakama/nakama migrate up --config /nakama/data/config.yml &&
exec /nakama/nakama --config /nakama/data/config.yml
environment:
NAKAMA_TELEMETRY: "0"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./data:/nakama/data
ports:
- "127.0.0.1:7350:7350"
- "127.0.0.1:7351:7351"
restart: unless-stopped
healthcheck:
test: ["CMD", "/nakama/nakama", "healthcheck"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
EOF
docker compose -f $D/compose.yaml up -d --wait --wait-timeout 300 &&
curl -fsS http://127.0.0.1:7350/ >/dev/null && echo "STEP 2 ok" ||
echo "STOP - see above, then docker compose -f $D/compose.yaml logs"
else echo "STOP - block 1 unfinished; nothing started"; fi
Every change from the docker-compose.yml on the Docker Compose page, in short.
- PostgreSQL uses
postgres:16-alpine(was 16.8-alpine), a password from .env (was localdb), no ports (was 5432 and 8080), volume pgdata (was data), restart unless-stopped. - Nakama uses
heroiclabs/nakama:3.41.0, the Docker Hub name the Upgrading page pulls (was registry.heroiclabs.com, 3.37.0). No container_name or expose on either service, no links. - Both
migrate upand the server now read--config /nakama/data/config.yml. Name, database address, token expiry and log level (DEBUG to INFO) moved there from flags, beside the keys and console login.-ecreplaces-ecx, so nothing is traced into the log. - 7350 and 7351 bind to 127.0.0.1, 7349 is dropped, ./data replaces ./, restart unless-stopped replaces always, and
NAKAMA_TELEMETRY=0is new.
Unless that variable is 0, main.go has Nakama post startup and shutdown events to a third-party analytics API (se/se.go). Tunnel the console with ssh -N -L 7351:127.0.0.1:7351 you@your-server, open 127.0.0.1:7351 and sign in as ops; sudo grep -A3 '^console:' /opt/nakama/data/config.yml shows the password. Colleagues get their own logins under Settings, Users.
Last, give the machine an A record and put Caddy, packaged in Ubuntu 24.04's universe (not 22.04), in front of 7350; it proxies WebSockets as they are. When the name resolves to this server's address, set H in the same root shell and run H=game.example.com; [ "$H" != game.example.com ] && getent hosts $H && ufw allow 80,443/tcp && apt-get update && apt-get install -y caddy && echo ok || echo "STOP - H unset or not resolving, or the install failed above", and only once it prints ok, [ "${H:-game.example.com}" != game.example.com ] && printf '%s {\n\treverse_proxy 127.0.0.1:7350\n}\n' $H > /etc/caddy/Caddyfile && systemctl reload caddy && echo ok || echo "STOP - set H in this shell, or see journalctl -u caddy". A Caddy reload closes open WebSockets, so reload when few people are playing.
Backups, restore and upgrades
Block 3 dumps the database and lists the dump, archives /opt/nakama and counts .env, compose.yaml and data/config.yml in it, then prunes copies past 14 days. Copy /var/backups/nakama off the machine yourself, with rsync and no --delete.
# as root - write the job, schedule it nightly, run it once
install -d -m 700 /var/backups/nakama &&
cat > /usr/local/sbin/nakama-backup.sh <<'EOF' &&
set -o pipefail; umask 077
D=/opt/nakama; B=/var/backups/nakama; t=$(date +%F-%H%M%S)
dc() { docker compose -f $D/compose.yaml "$@"; }
m='nakama/(\.env|compose\.yaml|data/config\.yml)'
if dc exec -T postgres pg_dump -U postgres -Fc nakama > $B/db-$t.dump.part &&
dc exec -T postgres pg_restore -l < $B/db-$t.dump.part > /dev/null &&
tar -czf $B/files-$t.tar.gz.part -C /opt nakama &&
[ "$(tar -tzf $B/files-$t.tar.gz.part | grep -cxE "$m")" = 3 ]
then
mv $B/db-$t.dump.part $B/db-$t.dump &&
mv $B/files-$t.tar.gz.part $B/files-$t.tar.gz &&
find $B -name 'db-*.dump' -mtime +14 -delete &&
find $B -name 'files-*.tar.gz' -mtime +14 -delete && echo "backup ok $t"
else
rm -f $B/db-$t.dump.part $B/files-$t.tar.gz.part
echo "backup FAILED, see the lines above"; exit 1
fi
EOF
echo '30 3 * * * root bash /usr/local/sbin/nakama-backup.sh > /var/log/nakama-backup.log 2>&1' > /etc/cron.d/nakama-backup &&
bash /usr/local/sbin/nakama-backup.sh || echo "STOP - fix the error above, then run this block again"
Restore in one root shell, in order. Each step leaves a marker the next one consumes, so a step after a STOP refuses. A rebuilt machine first needs Docker 28+, ufw on as above, install -d -m 700 /var/backups/nakama, and the pair copied into it.
- Set STAMP to the date-time in the file names.
B=/var/backups/nakama; D=/opt/nakama; R=/root/nakama-restore; t=STAMP; dc() { docker compose -f $D/compose.yaml "$@"; } [ "$t" != STAMP ] && [ ! -e $R ] && install -d -m 700 $R && cp $B/db-$t.dump $B/files-$t.tar.gz $R/ && echo copied || echo "STOP - fix t, or rm -rf $R first"[ "$(tar -tzf $R/files-$t.tar.gz | grep -cxE 'nakama/(\.env|compose\.yaml|data/config\.yml)')" = 3 ] && touch $R/.files-ok && echo "files ok" || echo "STOP - rm -rf $R, pick another pair"rm $R/.files-ok && docker run --rm -i postgres:16-alpine pg_restore -l < $R/db-$t.dump >/dev/null && touch $R/.checked && echo "dump ok" || echo "STOP - step 3 not passed, bad dump or no Docker; rm -rf $R"- Same machine only, take a safety backup and move the live folder aside, noting the stamp on its backup ok line.
[ -e $R/.checked ] && [ ! -e $D.previous ] && bash /usr/local/sbin/nakama-backup.sh && docker compose -p nakama stop nakama && mv $D $D.previous && echo aside || echo "STOP - read the note below" - Unpack and start PostgreSQL; on a rebuilt machine .env lands before it first runs.
[ ! -e $D ] && rm $R/.checked && tar -xzf $R/files-$t.tar.gz -C /opt && dc up -d --wait postgres && touch $R/.unpacked && echo ok || echo "STOP - /opt/nakama still there means nothing changed (run step 5 first); else go to step 9" rm $R/.unpacked && dc exec -T postgres dropdb -U postgres --if-exists nakama && dc exec -T postgres createdb -U postgres nakama && touch $R/.emptydb && echo ok || echo "STOP - go to step 9"- Load and start Nakama, which migrates on the way up.
rm $R/.emptydb && dc exec -T postgres pg_restore -U postgres -d nakama --no-owner < $R/db-$t.dump && dc up -d --wait && curl -fsS http://127.0.0.1:7350/ >/dev/null && echo "restore ok" || echo "STOP - go to step 9" - Rollback. Put the safety stamp (on a rebuilt machine, the one you picked) in place of SAFETY and run
s=SAFETY; [ -s $B/db-$s.dump ] && [ -s $B/files-$s.tar.gz ] && [ ! -e $D.failed ] && touch $B/.checked && R=$B t=$s && echo "pair ok" || echo "STOP - wrong safety stamp, or move $D.failed away". - Then
[ -e $B/.checked ] && rm -f $B/.unpacked $B/.emptydb && docker compose -p nakama stop nakama && { [ ! -e $D ] || mv $D $D.failed; } && echo "now redo 6 to 8" || echo "STOP - see the error above", and repeat steps 6 to 8. - Once the game works,
rm -rf $D.previous $D.failed /root/nakama-restore; rm -f $B/.checked $B/.unpacked $B/.emptydb. A rebuilt machine also needs block 3 and the Caddy step again.
A STOP from step 5 before Nakama was stopped (checks not passed, a leftover .previous, a failed backup) means nothing was touched. If the move failed after the stop, run docker compose -p nakama start nakama and go no further.
To upgrade, in one root shell, go through the notes of every release between yours and the target and put the Docker tag (3.41.0, not v3.41.0) in place of NEW. Run rm -f /root/nakama-up-*; N=NEW; [ "$N" != NEW ] && docker pull heroiclabs/nakama:$N && bash /usr/local/sbin/nakama-backup.sh && touch /root/nakama-up-$N && echo ready || echo "STOP - no such tag, or the backup failed", then rm /root/nakama-up-$N && cd /opt/nakama && sed -i "s#nakama:3[0-9.]*#nakama:$N#" compose.yaml && grep -q "nakama:$N" compose.yaml && docker compose up -d --wait && echo upgraded || echo STOP. A STOP from the first changes nothing; after a STOP from the second, restore the backup just taken. Rebuild Go plugins first, and stay on PostgreSQL 16.
Monitoring and the licence
- Prometheus export stays off until
metrics.prometheus_portis set, and it holds sensitive server information. Publish it on 127.0.0.1 and let a Prometheus and Grafana box scrape it through an SSH tunnel or a private link. - Apache 2.0 is a permissive licence (our reading, offered as general information rather than legal advice). Distributing Nakama, modified or unmodified, means including the licence, keeping its copyright, patent, trademark and attribution notices and any NOTICE file, and giving each modified file a notice that you changed it. Your own modules and game stay unpublished if you wish. Under section 3, the patent licences granted to you end on the date you file patent litigation, including a cross-claim or counterclaim, alleging that Nakama or a contribution to it infringes a patent.
Where one machine stops being enough
Surviving a node failure takes Enterprise clustering. A database that outgrows the box can move to a PostgreSQL dedicated server, in any of the five server locations.
A whole machine hands Nakama every core for match ticks and PostgreSQL its own disks, with root. With the port unmetered, a crowded launch weekend never becomes a transfer charge. On a racked line, payment approval to handover is about 30 minutes; a built-to-order line takes 4 to 24 hours.
Questions
Is the open-source build enough for a launched game?
It runs every feature on one node, but Heroic Labs points production at its clustered Enterprise edition or managed hosting, so failover here is your own plan of tested backups and a spare machine.
Does the server key have to stay secret?
No. The authentication docs say to change socket.server_key from defaultkey and embed it in your client, so anyone holding a build can read it. The secrets are the two session keys, runtime.http_key for server-to-server calls, and the console password and signing key.
How do I deploy my own server code?
As root, copy the built files into /opt/nakama/data/modules (index.js, .lua files or a Go .so plugin), then run docker compose -f /opt/nakama/compose.yaml restart nakama. Modules load at startup, and a Go plugin must match the server's version and dependencies.