Workload · Running your game's backend

Your game's Nakama server, on a machine that is all yours

You can host Nakama, the open-source backend that gives your game sign-ins, matchmaking, live multiplayer and leaderboards, on one dedicated server, with Nakama and PostgreSQL in two Docker containers and players arriving over HTTPS.

The short version
  1. Player accounts, matchmaking and live play from one machine
  2. All seven defaults Nakama flags as insecure, replaced
  3. An admin console that only your SSH tunnel can reach
  4. A nightly backup, plus the steps to bring it back

What Nakama does for your game

Nakama is the server half of a multiplayer game that you do not have to write, with sign-in, matchmaking, live matches, chat, saves and leaderboards built in. Your own rules plug in as modules written in JavaScript, Lua or Go.

Be clear about what this setup is

The free, Apache 2.0 edition is one node. Heroic Labs' configuration page describes production Nakama as a cluster built to stay up, on Nakama Enterprise or its own managed hosting. Its Linux guide backs CockroachDB 20.0 or newer officially and treats PostgreSQL 9.6+ as unofficial, for development, and its Docker Compose page sets up a local development instance.

We picked PostgreSQL anyway, because on one machine pg_dump keeps backups simple. The CockroachDB compose file in Nakama's repository starts the database with --insecure and publishes it to every interface, so it is also only for development. A database that outgrows the machine can move to its own PostgreSQL server.

Will one server carry your players?

Probably. Heroic Labs' benchmark gave the free edition one CPU and 3 GB of memory and counted 20,277 simultaneous connections, with the database on its own 8-vCPU, 30 GB instance. Those players only waited; load-test your own client.

Start with a one- or two-processor machine on SSD from our dedicated server list. Rack servers, blades, the m510 and the m710x include a console of your own; other cartridges have power control alone. Skip the X-Gene ARM model, which installs nothing past Ubuntu 18.04.

Which doors stay open

  • 7350 carries your game's API calls and live socket, kept on 127.0.0.1 and published by Caddy as HTTPS on 443.
  • 7351, the admin console, is reached through an SSH tunnel.
  • 7349 and 7348 (gRPC) stay unpublished.

A Docker port written without an address opens on all of the machine's IPv4 and IPv6 addresses and skips ufw's rules. Pinned to 127.0.0.1 it stays inside from Docker Engine 28 onwards, so block 1 checks your version.

Up and running in an evening

On a clean Ubuntu 24.04 install, become root with sudo -i and, in that one shell, add Docker from its own apt repository with the three commands below, halting if one prints an error.

  1. L=/etc/apt/sources.list.d/docker.list; K=/etc/apt/keyrings/docker.asc; U=https://download.docker.com/linux/ubuntu; apt-get update && apt-get -y install openssl ufw curl ca-certificates
  2. install -d -m 0755 /etc/apt/keyrings && curl -fsSL -o $K $U/gpg && chmod a+r $K && echo "deb [signed-by=$K arch=amd64] $U noble stable" | tee $L
  3. apt-get update && apt-get -y install containerd.io docker-compose-plugin docker-buildx-plugin docker-ce-cli docker-ce && systemctl enable --now docker
  4. Paste block 1. It switches ufw on with SSH alone (the OpenSSH profile covers port 22 only, so allow your own port if you moved SSH), sets ops as the console user and makes a random value for every other secret. Too old a Docker, or secrets already there, and it writes none.
  5. Paste block 2. Once the secrets look right, it writes compose.yaml and waits for both containers to report healthy.
# as root (sudo -i), once Docker is installed
D=/opt/nakama
v=$(docker version -f '{{.Server.Version}}' 2>/dev/null)
if sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH &&
   ufw --force enable && [ "${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 - ufw error, Docker 28+ missing, or $D holds secrets; no secrets written"
fi

Block 1 broke off? Before block 2 has ever run, cd /opt/nakama && rm -f data/config.yml .env gives you a second go; after that, leave .env alone. Root or Docker access reads config.yml, and docker inspect shows the database password; console accounts allowed into Configuration get the session and HTTP keys unredacted (console_config.go); every backup holds it 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

What we altered from the compose file on Heroic Labs' Docker Compose page, all of it.

  • The database runs postgres:16-alpine (not 16.8-alpine), reads its password from .env (not localdb), publishes no ports, a volume named pgdata, restart unless-stopped.
  • Nakama runs heroiclabs/nakama:3.41.0 from Docker Hub, as the upgrade guide pulls it, not 3.37.0 from registry.heroiclabs.com; container_name, expose and links are gone.
  • The migration and the server both start with --config /nakama/data/config.yml, so token expiry, log level (now INFO, was DEBUG), node name and database address sit in that file beside the keys and the console login. The startup shell loses the x of -ecx, which would log each command.
  • Ports 7350 and 7351 sit on 127.0.0.1 and 7349 is left unpublished; the mount is ./data instead of the whole folder; restart is unless-stopped rather than always; NAKAMA_TELEMETRY=0 joins the environment.

Why that variable? Unless it is 0, main.go has Nakama report each start and stop to an outside analytics service. Next, from your own computer run ssh -L 7351:127.0.0.1:7351 -N you@game-host, visit 127.0.0.1:7351 and log in as ops; sudo sed -n "/^console/,/signing_key/p" /opt/nakama/data/config.yml shows its password. Teammates get their own accounts under Settings, Users.

Last, a secure address. Create the DNS record for your hostname and wait until it points at this machine's IP. Caddy, packaged in universe on Ubuntu 24.04 but not 22.04, fetches the certificate and hands everything to 7350. In the same root shell, put your hostname in NAME and run NAME=play.example.com; [ $NAME != play.example.com ] && getent ahosts $NAME >/dev/null && apt-get update && apt-get -y install caddy && ufw allow 80,443/tcp && echo installed || echo "STOP - NAME, DNS or install", and only once it prints installed, [ ${NAME:-play.example.com} != play.example.com ] && printf '%s {\n reverse_proxy 127.0.0.1:7350\n}\n' $NAME | tee /etc/caddy/Caddyfile >/dev/null && systemctl reload caddy && echo done || echo "STOP - set NAME here, or journalctl -u caddy". Each Caddy reload drops live WebSocket connections, so change it while few players are online.

Keeping every player's progress safe

Block 3 adds a nightly job that dumps and re-reads the database, packs /opt/nakama, counts its three key files, and only then clears sets older than two weeks. Copy /var/backups/nakama to another machine as well. You manage the server; we keep the hardware and the network running.

# 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"

To bring one back, use one root shell. Each step leaves a marker that the next one uses up, so nothing runs past a STOP. A replacement server first needs Docker 28+, then F=/etc/default/ufw; sed -i '/^IPV6=/c IPV6=yes' $F && ufw allow 22/tcp && ufw --force enable, then umask 077; mkdir -p /var/backups/nakama, then the two files copied in.

  1. Name things, with PICK swapped for the stamp in the file names. N=/opt/nakama; K=/var/backups/nakama; W=/root/nakama-restore; BK=/usr/local/sbin/nakama-backup.sh; S=PICK; nk() { cd $N && docker compose "$@"; }
  2. if [ "$S" = PICK ] || [ -e $W ]; then echo "STOP - set S, or clear $W"; else mkdir -m 700 $W && cp $K/files-$S.tar.gz $K/db-$S.dump $W/ && echo "pair copied" || echo "STOP - no pair $S; clear $W"; fi
  3. n=$(tar -tzf $W/files-$S.tar.gz | grep -cxE 'nakama/(data/config\.yml|compose\.yaml|\.env)'); [ "$n" = 3 ] && touch $W/.pair-ok && echo "3 files ok" || echo "STOP - $n of 3 files; clear $W"
  4. rm $W/.pair-ok && docker run -i --rm postgres:16-alpine pg_restore --list < $W/db-$S.dump >/dev/null && touch $W/.verified && echo "dump reads" || echo "STOP - files check missing, dump damaged, or no Docker; clear $W"
  5. Same server only, save what is live and move it aside, writing down the stamp it prints. if [ ! -e $W/.verified ] || [ -e $N.previous ]; then echo "STOP - checks not passed, or $N.previous exists"; else bash $BK && nk stop nakama && cd / && mv $N $N.previous && echo "aside ok" || echo STOP; fi
  6. Unpack and bring PostgreSQL up, with .env in place first. if [ -e $N ] || ! rm $W/.verified; then echo "STOP - $N still there, or checks not passed"; else tar -C /opt -xzf $W/files-$S.tar.gz && nk up --wait -d postgres && touch $W/.extracted && echo "db up" || echo "STOP - go to undo"; fi
  7. rm $W/.extracted && nk exec -T postgres dropdb --if-exists -U postgres nakama && nk exec -T postgres createdb -O postgres -U postgres nakama && touch $W/.blank && echo "db empty" || echo "STOP - go to undo"
  8. Load the data and start Nakama, which updates the schema on the way up. rm $W/.blank && nk exec -T postgres pg_restore --no-owner -U postgres -d nakama < $W/db-$S.dump && nk up --wait -d && curl -sf -o /dev/null http://127.0.0.1:7350/ && echo restored || echo "STOP - go to undo"
  9. Undo. Put the stamp you wrote down (on a replacement server, the one you picked) into SAFE and run S2=SAFE; [ -s $K/db-$S2.dump ] && [ -s $K/files-$S2.tar.gz ] && [ ! -e $N.failed ] && touch $K/.verified && W=$K S=$S2 && echo "safety pair ok" || echo "STOP - wrong SAFE stamp, or clear $N.failed".
  10. Then [ -e $K/.verified ] && rm -f $K/.extracted $K/.blank && docker compose --project-name nakama stop nakama && cd / && { [ ! -e $N ] || mv $N $N.failed; } && echo "redo the unpack" || echo STOP and redo steps 6 to 8.
  11. Once players can sign in again, clear the leftovers with cd / && rm -rf /root/nakama-restore /opt/nakama.failed /opt/nakama.previous; rm -f $K/.verified $K/.extracted $K/.blank. On a replacement server, set up block 3 and Caddy again too.

When step 5 refuses while Nakama is still running (no verified pair, an old .previous folder, or the backup erroring), your server is untouched. Had the stop gone through but the move failed, start it again with docker compose --project-name nakama start nakama and do nothing more.

Moving to a newer Nakama

Work in one root shell. Read the notes of each release later than yours, then put the Docker tag in NEW, written 3.41.0 rather than v3.41.0. Start with rm -f /root/nakama-up-*; NEW=X.Y.Z; BK=/usr/local/sbin/nakama-backup.sh; [ $NEW != X.Y.Z ] && docker pull heroiclabs/nakama:$NEW && bash $BK && touch /root/nakama-up-$NEW && echo pulled || echo "STOP - unknown tag or backup error" to pull and back up, then rm /root/nakama-up-$NEW && cd /opt/nakama && sed -i "s|nakama:3[.0-9]*|nakama:$NEW|" compose.yaml && grep -q "nakama:$NEW" compose.yaml && docker compose up --wait -d && echo done || echo STOP to switch; the startup migration updates the database. If the first stops, nothing has changed; if the second does, bring back the backup it just made. Rebuild Go plugins first; leave postgres:16 as is.

Good to know before you launch

Apache 2.0, in our general reading and not as legal advice, lets you pass Nakama on, modified or not, if you include the licence, keep every copyright, patent, trademark and attribution notice it carries, pass on any NOTICE file, and flag the files you modified. Your game code stays yours. Section 3 adds that if you file patent litigation, a cross-claim or counterclaim included, alleging Nakama or a contribution infringes a patent, the patent licences it granted you end on that filing date.

Why a whole machine? Every core and disk is yours, and with no transfer allowance a streamer's crowd costs nothing extra. Choose the city nearest most players from our five locations. A ready server is yours within 30 minutes of approval; one built to your spec takes hours, not minutes.

Questions people ask

Do I need CockroachDB to run Nakama?
Officially, yes. Heroic's Linux guide backs it and treats PostgreSQL as development-only; we use PostgreSQL 16 for simple pg_dump backups, as explained above.
Which keys can my players see?
Only the server key. Heroic's authentication guide says to swap defaultkey for your own value and build it into the client. Keep private the session and refresh-token keys, the HTTP key for RPC calls, and the console's password and signing key; config.go warns about any left at its default and halts if the two session keys match.
How do I upload my server code?
As root, put the built JavaScript bundle, Lua files or Go plugin in /opt/nakama/data/modules and run docker compose --file /opt/nakama/compose.yaml restart nakama. Modules are read only at startup.

Give your game a backend of its own

Choose a dedicated server, set up Nakama tonight and keep the whole machine for your players.

Get a server for your game

Read next