# JD Online — Deployment & Live Test Runbook

**Status: verified working end-to-end on 2026-08-18** against
`game-dev.onlinetestingserver.com` (`108.178.43.90`), a cPanel/WHM box with root
and Docker. A live 1v1 match between two Multiplayer Play Mode clients on a
Windows machine completed over the public internet: authentication, profile
load, matchmaking, server allocation, ticket redemption and real-time KCP
gameplay.

This document has two readers. **Section 3 is the server admin's.**
**Section 4 is the Unity developer's.** Sections 1, 2, 5 and 6 are shared.

---

## 1. What this stack is

Three separate pieces, and confusing them is the most common source of lost time:

| Piece | What it is | Runs where |
|---|---|---|
| **Nakama + CockroachDB** | auth, matchmaking, server registry, tickets | Docker, on the server |
| **Laravel + MySQL** | accounts, wallet, shop, leaderboards, friends, progression | Docker, on the server |
| **Headless Mirror game server** | the gameplay simulation — a Unity build running with no graphics | Docker, on the server |

`docker compose ps` should therefore list **six** containers: `jd-cockroachdb`,
`jd-nakama`, `jd-mysql`, `jd-laravel`, `jd-laravel-scheduler` and
`jd-gameserver`.

**Nakama never runs gameplay.** Its Lua modules pair players, pick a game server
from a registry, and hand each player a single-use ticket. All gameplay traffic
goes directly from the Unity client to the Mirror server over **UDP/KCP**, never
through Nakama.

The consequence: bringing up only Nakama gives you a lobby with nothing behind
it. Matches get found and then cancelled with `"no game server available"` —
which reads exactly like a client bug and is not one. Both pieces must be up.

---

## 2. Verified configuration

These values produced the successful live test. They are the reference point
when something stops working.

### Unity client — `Assets/Settings/OnlineConfig.asset`

| Field | Value | Note |
|---|---|---|
| `host` | `game-dev.onlinetestingserver.com` | the domain, not the IP |
| `port` | `7350` | **not 2083** — that is the cPanel login page |
| `useSsl` | `0` | no TLS terminator in front of Nakama yet — see section 6 |
| `serverKey` | must equal `NAKAMA_SERVER_KEY` in the server's `.env` | public identifier, not a secret |

### Server — `server/.env`

Created from `server/deploy/env.server.example`. Secrets are generated **on the
server** with `openssl rand -hex 24`, and never travel over chat or email.

| Key | Value used | Why it matters |
|---|---|---|
| `COMPOSE_PROFILES` | `gameserver` | activates the third compose service. Without it, `docker compose up -d` silently starts only Nakama + CockroachDB |
| `GS_PUBLIC_HOST` | `108.178.43.90` | the address clients dial for UDP. Public IP — never `localhost`, never a container IP |
| `GS_PORT` | `25565` | must match the Mirror KcpTransport port in the Unity project |
| `GS_NAKAMA_URL` | `http://nakama:7350` | Docker service name — resolves because the game server shares Nakama's compose network |
| `ADMIN_BIND` | `127.0.0.1` | keeps operator ports off the internet — see the warning below |
| `LARAVEL_BIND` | `127.0.0.1` | **a different key from `ADMIN_BIND`** — governs the API port only. See "Exposing the API" in section 3 |
| `LARAVEL_PORT` | `8090` | host port for the Laravel API |
| `LARAVEL_URL` | `http://laravel:8080` | how Nakama's Lua reaches Laravel — compose service name, not a public URL |
| `LARAVEL_S2S_SECRET` | generated on the server | second factor on `/s2s/*`, the Laravel-side twin of `GS_SHARED_SECRET` |
| `LARAVEL_APP_KEY` | `artisan key:generate --show` | **not** `openssl` — Laravel generates this one |
| `MYSQL_PORT` | `3309` | host side only; this box already runs its own MySQL on 3306 |
| `MYSQL_PASSWORD`, `MYSQL_ROOT_PASSWORD` | generated on the server | |
| `ECONOMY_ENABLED` | `0` until Laravel is healthy | with it on, the economy fails **closed** — an unreachable Laravel cancels matches |
| `NAKAMA_SERVER_KEY` | matches the client | |
| `NAKAMA_HTTP_KEY` | generated on the server | server-to-server only, never in a client build |
| `GS_SHARED_SECRET` | generated on the server | second factor on the `rpc_gs_*` endpoints |
| `NAKAMA_CONSOLE_PASSWORD` | generated on the server | compose refuses to start without it, by design |

> **CockroachDB runs `--insecure` — no authentication at all.** `ADMIN_BIND` must
> stay `127.0.0.1`. Setting it to `0.0.0.0` on a public box hands the entire
> database — accounts, MMR, match history — to anyone who connects to port 26257.
> Reach the admin surfaces through an SSH tunnel instead:
>
> ```
> ssh -L 7351:127.0.0.1:7351 -L 8080:127.0.0.1:8080 user@host
> ```

> **There are two loopback binds in `.env`, and they are not interchangeable.**
> Never find-and-replace `127.0.0.1` → `0.0.0.0` across the file.
>
> | Key | Guards | Safe to open? |
> |---|---|---|
> | `ADMIN_BIND` | CockroachDB SQL 26257 + UI 8080, Nakama gRPC 7349 + console 7351, MySQL 3309 | **Never.** 26257 is unauthenticated root SQL over the whole accounts/MMR database |
> | `LARAVEL_BIND` | the API port 8090 only | Only knowingly — `/api` **and** `/s2s` share that port |
>
> Opening `LARAVEL_BIND` is a legitimate interim choice; opening `ADMIN_BIND`
> is a total compromise. See "Exposing the API" below before changing either.

### Firewall (CSF, on the cPanel box)

| Port | Protocol | Who needs it |
|---|---|---|
| 7350 | TCP | every client — Nakama HTTP + WebSocket |
| 25565 | **UDP** | every client — Mirror KCP gameplay |
| 443 | TCP | every client, once `/api` is proxied — cPanel's Apache already owns it |
| 8090 | TCP | **only** if you publish the API directly instead of proxying it — see below |
| 26257, 8080, 7349, 7351, 3309 | — | **must stay closed** to the outside; loopback only |

Add 25565 to `UDP_IN` and 7350 to `TCP_IN` in `/etc/csf/csf.conf`, then `csf -r`.
Also confirm `DOCKER = "1"` in that file — CSF reloads otherwise flush Docker's
iptables chains. That kills every published port **and severs traffic between
the containers themselves**, until `systemctl restart docker`. The second half
is the one that reads as a database outage; see below.

> **CSF does not gate Docker's published ports.** Port publishing writes DNAT
> rules that are evaluated *before* the `INPUT` chain CSF filters, so a container
> bound to `0.0.0.0` is reachable from the internet whether or not the port is in
> `TCP_IN`. The bind address in `.env` — not the firewall — is what actually
> decides who can reach a service.

### When containers cannot reach each other

A flushed chain does not announce itself. It looks like the database died:

```
jd-nakama | Failed to check if db exists  db=nakama
jd-nakama |   dial error: timeout: dial tcp 172.29.0.3:26257: connect: connection timed out
```

Nakama then restart-loops on roughly a four-minute cycle, which is just the dial
timeout repeating.

**One word tells you which layer is broken**, and it is worth checking before
anything else:

| What you see | What it means |
|---|---|
| DNS failure (`no such host`) | the container is not on the network at all |
| `connection refused` | the container is stopped, or nothing is listening on that port |
| **`connection timed out`** | **packets are being dropped — iptables, not the database** |

In the log above DNS *succeeded* (`cockroachdb` resolved to `172.29.0.3`), so the
container is registered and running. Only the TCP connect fails. That is the
flushed-chain signature.

> **`docker compose ps` will show CockroachDB as `healthy` the whole time, and
> that proves nothing.** Its healthcheck is `curl -f http://localhost:8080/health`
> executed *inside* the container, so it cannot observe networking between
> containers. `depends_on: condition: service_healthy` inherits the same blind
> spot: Compose sees a healthy database, starts Nakama, and Nakama dies.

Two commands separate "the database is broken" from "the network is broken":

```bash
# Is the database itself alive? (runs inside its own container)
docker compose exec cockroachdb /cockroach/cockroach sql --insecure -e 'select 1'

# Can Nakama's network namespace actually reach it?
docker run --rm --network container:jd-nakama busybox nc -zv cockroachdb 26257
```

The first succeeding while the second times out is conclusive. The fix is then:

```bash
systemctl restart docker
cd ~/jd/server && docker compose up -d
```

If a Docker restart does **not** bring it back, the CSF theory is wrong — check
`df -h` next, since a full disk on the CockroachDB volume wedges the node.

---

## 3. Server admin

### Layout on the box

Both pieces sit under one parent directory, because the Dockerfile's build
context spans them:

```
~/jd/
├── server/              # this folder: compose stack, Lua modules, Dockerfile
├── Builds/LinuxServer/  # the Unity headless build, supplied by the Unity dev
└── .dockerignore        # keeps everything else out of the build context
```

### First-time deploy

```bash
cd ~/jd/server
cp deploy/env.server.example .env
```

Edit `.env` and replace every value marked `<generate>` using
`openssl rand -hex 24`. Then:

```bash
docker compose up -d
```

That one command starts all six services — CockroachDB, Nakama, MySQL, Laravel,
the Laravel scheduler and the game server — on a single network. Compose builds
the game server and Laravel images itself on first run; there is no separate
`docker build` or `docker run` step. Laravel runs its own migrations on boot.

Seed the shop catalogue once, after the first successful start:

```bash
docker compose exec laravel php artisan db:seed --class=ShopItemSeeder
```

### Exposing the API

Laravel serves **two route groups on one port**, and they have opposite
audiences:

| Path | Who calls it | Reachable from the internet? |
|---|---|---|
| `/api/*` | the game client, with a per-user bearer token | **yes — it has to be** |
| `/s2s/*` | Nakama only, over the compose network, with `X-JD-S2S` | **no** |

`/s2s/wallet/reward` mints coins and `/s2s/matches/{id}/payout` settles matches.
Exposing them puts the economy behind a single shared secret over plaintext HTTP.

**Option A — reverse-proxy `/api` (recommended).** `LARAVEL_BIND` stays
`127.0.0.1`, so `/s2s` is unreachable *by construction* rather than by secret,
and the client gets TLS for free. In the domain's Apache vhost (cPanel:
*Include Editor* → *Post VirtualHost Include* for the SSL vhost):

```apache
ProxyPreserveHost On
ProxyPass        /api http://127.0.0.1:8090/api
ProxyPassReverse /api http://127.0.0.1:8090/api
```

Deliberately `/api` and not `/` — proxying the document root would expose
`/s2s` too, which is the whole thing this avoids. Then the client's
`laravelBaseUrl` is `https://<domain>/api`, with no port.

**Option B — publish 8090 directly (interim only).**

```diff
- LARAVEL_BIND=127.0.0.1
+ LARAVEL_BIND=0.0.0.0
```

then `docker compose up -d` (a `restart` will **not** re-map ports) and add 8090
to `TCP_IN`. This works, and is a reasonable way to unblock API testing on a dev
box — but it publishes `/s2s` to the internet, so treat it as temporary and
rotate `LARAVEL_S2S_SECRET` before anything real depends on it.

Either way the symptom of getting this wrong is unmistakable: Postman and the
game client both report **`ECONNREFUSED` on port 8090**. That is a TCP-level
refusal raised before any HTTP request is sent, so it can never be caused by a
missing or wrong secret — a bad secret returns `401`/`403` with a JSON body.

### Verify, in this order

Each step isolates a different layer. Don't skip ahead — a failure at step 2
makes step 4 meaningless.

**1. All six containers up**

```bash
docker compose ps
```

Want `jd-cockroachdb`, `jd-nakama`, `jd-mysql` and `jd-laravel` healthy, with
`jd-laravel-scheduler` and `jd-gameserver` running. If `jd-gameserver` is missing
from the list entirely, `COMPOSE_PROFILES=gameserver` is absent from `.env`. If
compose refuses to start at all naming a variable, `.env` is missing a required
key — regenerate it from `deploy/env.server.example`, which carries every one.

**2. Game server registered**

```bash
docker logs jd-gameserver --tail 50
```

Want two lines: `listening on <ip>:25565` and `registered with Nakama`. This is
the most informative single check — it proves the container reached Nakama over
the compose network with valid keys.

**3. Pool healthy**

```bash
cd ~/jd/server && source .env && curl -s "http://localhost:7350/v2/rpc/rpc_gs_list?http_key=$NAKAMA_HTTP_KEY" -H 'Content-Type: application/json' -d "\"{\\\"secret\\\":\\\"$GS_SHARED_SECRET\\\"}\""
```

Want one server with `alive: true`, `in_use: false`, and a small `age_sec`. This
also proves the heartbeat loop is running, not just the initial registration.

**4. Laravel is up and reached its database**

```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/up      # want 200
```

A 200 here with a refused connection from *outside* the box is not a fault — it
is the default posture, and means the only remaining question is how you intend
to expose `/api` (see above). Anything other than 200 locally means the Laravel
container never reached MySQL; `docker compose logs laravel` names the reason.

**5. Admin ports are NOT public**

From any machine that is not the server, confirm ports `8080`, `26257`, `7351`
and `3309` on the public IP are unreachable. From the box itself they answer.
That difference is what keeps the unauthenticated database private.

```bash
# from your own machine, NOT the server
for p in 8080 26257 7351 3309; do
  curl -s -m 5 -o /dev/null -w "$p -> %{http_code}\n" "http://<public-ip>:$p/" || echo "$p -> refused (correct)"
done
```

### Routine operations

```bash
docker compose ps                     # status
docker compose logs -f gameserver     # follow game server output
docker compose logs -f nakama         # follow matchmaking and allocation
docker compose restart nakama         # after editing Lua modules or MATCH_SCENE
docker compose logs -f laravel        # follow API requests and errors
docker compose exec laravel php artisan migrate --force    # usually automatic on boot
docker compose up -d --build          # after a new Builds/LinuxServer is uploaded
docker compose down                   # stop everything (data survives in ./data)
```

Anything changed under `nakama/data/modules/`, or the `MATCH_SCENE` value, needs
`docker compose restart nakama` to reload the Lua runtime.

Deploying a new game build: replace `~/jd/Builds/LinuxServer/`, then
`docker compose up -d --build`. The `--build` is required — the previous binary
is baked into the existing image.

### Deploying new API code or retuned economy config

The Laravel code is **baked into its image**, not bind-mounted, for the same
reason the game binary is. So editing anything under `backend/` on the box —
including `backend/config/*.php` — changes nothing until the image is rebuilt:

```bash
cd ~/jd
docker compose build laravel
docker compose up -d laravel
docker compose logs -f laravel        # watch it come up and migrate
```

Migrations run on boot, so a release that adds tables needs no separate step.
`docker compose exec laravel php artisan migrate --force` is only there for when
you want to see what ran, or to force it after a failed start.

Three of these have shipped so far and all add tables:

| Release | Adds | Config to retune |
|---|---|---|
| Season pass (retired by v2; its tables stay as history and nothing reads them) | `season_passes`, `season_pass_claims` | none: `season_pass.php` is gone |
| Monthly challenge + daily card (retired by v2, likewise) | `challenge_runs`, `daily_cards` | none: those keys are gone from `monthly_challenge.php` |
| Monthly challenge v2 (replaces the season pass, the solo climbs and the daily card) | `challenge_completions`, `challenge_rewards`, `monthly_premium_unlocks`, `iap_purchases` | `backend/config/monthly_challenge.php`, the store keys in README "Selling the premium unlock", and `ECONOMY_ENABLED=1` (completions verify against charged matches). `CHALLENGE_TIME_TRAVEL` / `CHALLENGE_VERIFY_MATCH` are dev faucets - leave them `false` / `true` |

Check the tables arrived, and that the routes are being served:

```bash
docker compose exec laravel php artisan migrate:status | tail -6
docker compose exec laravel php artisan route:list --path=challenges
docker compose exec laravel php artisan route:list --path=monthly-pass
```

Retuning either config is a rebuild, not a client release — the client only ever
renders what the API returns. `server/README.md` has the full table of knobs and
what each one does.

**Every key in the challenge `schedule` must be an area the shipped client
knows.** The schedule lives in `monthly_challenge.php`; the area's start and
finish volumes live in the client (`Assets/Scripts/Online/ChallengeAreas.cs`).
A key the client doesn't know still shows on the Challenges screen, but no
match can ever start it, and nothing validates it for you. New areas therefore
go out in a client release before they go into the schedule.

---

## 4. Unity developer

### Building the Linux server

Requires **Linux Dedicated Server Build Support** in Unity Hub. The Dedicated
Server subtarget is what defines `UNITY_SERVER`; a plain Linux player build
compiles out `GameServerBootstrap`, `NakamaServerApi` and `MatchLifecycle`
entirely, and will never register with Nakama.

**Close the Unity Editor first.** In-Editor `BuildPlayer` deadlocks reproducibly
on this project, and running batchmode against an open Editor corrupts the asset
database.

```
"C:\Program Files\Unity\Hub\Editor\6000.4.12f1\Editor\Unity.exe" -quit -batchmode -nographics -projectPath "F:\Osama\Git\JustDifferent-JD" -executeMethod HeadlessServerBuild.BuildLinuxServerNoRestore -logFile -
```

Expect to run it **twice** if the Editor was on another platform — the first run
only starts the target switch and reimports every asset. That is not a failure;
the log says so explicitly.

Correct output, ~304 MB in `Builds/LinuxServer/`:

```
JDGameServer          <- ~4.8 KB ELF launcher stub. This small size is correct.
JDGameServer_Data/    <- the actual content
UnityPlayer.so        <- ~39 MB, the engine
```

The executable **must** be named `JDGameServer` — the Dockerfile and
`entrypoint.sh` both reference it by name. A build named anything else
(`LinuxJD.x86_64`, say) came from File → Build Settings rather than the
`HeadlessServerBuild` script, and almost certainly lacks the server code.
Rebuild rather than renaming: Unity also requires the `_Data` folder name to
match the executable.

Sanity check before handing it over — all three should return non-zero:

```bash
grep -c "GameServerBootstrap" Builds/LinuxServer/JDGameServer_Data/Managed/Assembly-CSharp.dll
grep -c "MatchLifecycle" Builds/LinuxServer/JDGameServer_Data/Managed/Assembly-CSharp.dll
grep -c "MirrorTicketAuthenticator" Builds/LinuxServer/JDGameServer_Data/Managed/Assembly-CSharp.dll
```

### Packaging the handover

Bundles go in `handover/`, named `<what>-<date>-<short sha>.tar.gz` so the box
and the repo can be lined up later. The README beside them is committed; the
archives themselves are gitignored, because they are build output.

```bash
# The game build - hundreds of MB, only when Builds/LinuxServer has changed.
tar -czf "handover/jd-gameserver-linux-$(date +%Y%m%d)-$(git rev-parse --short HEAD).tar.gz" Builds/LinuxServer

# The stack: compose, Nakama modules, the Laravel source its image is built from,
# and the Postman collection. Built from what git tracks, which is what keeps
# secrets and vendor/ out of it rather than a list of excludes that can go stale.
git ls-files backend server > /tmp/jd-files
git ls-files --others --exclude-standard backend server >> /tmp/jd-files
git ls-files --deleted backend server > /tmp/jd-gone
sort -u /tmp/jd-files | grep -vxF -f /tmp/jd-gone > /tmp/jd-stack-files
tar -czf "handover/jd-server-stack-$(date +%Y%m%d)-$(git rev-parse --short HEAD).tar.gz" -T /tmp/jd-stack-files
```

Both archives carry their paths, so extracting them into the same directory on
the box produces the `~/jd/` layout from section 3 with no manual shuffling.

Taking the file list from git is deliberate: `server/.env`, `server/data/`,
`backend/vendor/` and a filled-in Postman environment are all gitignored, so
none of them can be swept in by accident. Check the result before sending it:

```bash
tar -tzf handover/jd-server-stack-*.tar.gz | grep -E '(^|/)\.env$|/vendor/|^server/data/' || echo 'clean'
```

**Never include `server/.env`.** It holds the dev machine's LAN values and its
secrets. The admin generates their own from `deploy/env.server.example`.

### Line endings — this cost real time

`.gitattributes` pins `*.sh`, `*.lua`, the Dockerfile and the compose file to LF,
but files checked out *before* `core.autocrlf false` was set can still carry
CRLF. A CRLF `entrypoint.sh` makes the container crash-loop with:

```
/usr/bin/env: 'bash\r': No such file or directory
```

Set the git config once per Windows machine:

```bash
git config --global core.autocrlf false && git config --global core.longpaths true
```

and check before packaging:

```bash
file server/gameserver/entrypoint.sh server/gameserver/Dockerfile
```

The Dockerfile now strips CRLF from the entrypoint at build time as a second
line of defense, so this specific failure should not recur.

### Testing with Multiplayer Play Mode

1. Confirm `OnlineConfig.asset` matches the table in section 2 — particularly
   `port: 7350`.
2. Have the admin confirm verify-step 3 shows `alive: true` before testing. A
   cancelled match almost always means an empty pool, not a client bug.
3. Open `Home`, enter Play mode, queue two virtual players for 1v1.
4. Success looks like: both clients load the match scene, spawn, and **see each
   other move**. Movement is the proof that UDP 25565 works end-to-end — it is
   the only way to test KCP, since UDP cannot be probed the way a TCP port can.

MPPM virtual players share one PlayerPrefs store. Any new persisted per-player
key must go through `NakamaConnection.Scoped()`, or two virtual players restore
the same session and the matchmaker sees a single player.

---

## 5. Failures actually hit, and how to recognise them

| Symptom | Cause | Fix |
|---|---|---|
| `/usr/bin/env: 'bash\r': No such file or directory`, container restarts forever | CRLF line endings in `entrypoint.sh` | strip CRLF, then `docker compose up -d --build` |
| Match found, then `"no game server available"`, client returns to Home | pool empty — game server not registered | verify ladder steps 1–3; usually `COMPOSE_PROFILES` missing or the container crash-looping |
| `jd-gameserver` missing from `docker compose ps` | `COMPOSE_PROFILES=gameserver` not in `.env` | add it, `docker compose up -d` |
| Client hangs on connect or authenticate | wrong `host`/`port` in `OnlineConfig` (2083 is cPanel, not Nakama), or mismatched `serverKey` | use `7350`; match `serverKey` to `NAKAMA_SERVER_KEY` |
| Match starts, scene loads, players frozen, then timeout | UDP 25565 blocked, or published as TCP | CSF `UDP_IN`; the port mapping must end in `/udp` |
| Server registers but nobody can connect | `GS_PUBLIC_HOST` is a container IP or `localhost` | set it to the public IP |
| Game server cannot reach Nakama | `GS_NAKAMA_URL` wrong for the network mode | `http://nakama:7350` when run as a compose service |
| Compose refuses to start, naming a variable | a required key missing from `.env` | intentional fail-fast — the message names the key |
| Containers healthy but nothing reachable **from outside** after a firewall change | CSF flushed Docker's NAT rules, killing published ports | `systemctl restart docker`; set `DOCKER = "1"` in `csf.conf` |
| `jd-nakama` restart-loops on `migrate up` with `dial tcp <ip>:26257: connect: connection timed out`, while `docker compose ps` still shows CockroachDB **healthy** | Same flush, but between CONTAINERS rather than from outside. `timeout` plus a *successful* DNS lookup is the signature — a stopped container would be `refused` | `systemctl restart docker && docker compose up -d`; see "When containers cannot reach each other" |
| `ECONNREFUSED` on 8090 from Postman or the client, while 7350 answers fine | `LARAVEL_BIND` is loopback and `/api` is not proxied — the default, correct posture | proxy `/api`, or set `LARAVEL_BIND=0.0.0.0`; see "Exposing the API". **Not** a secret or auth problem — those return 401/403, never a refused connection |
| Compose fail-fasts on `MYSQL_PASSWORD` / `LARAVEL_APP_KEY`, nothing starts | `.env` predates the Laravel stack | regenerate from `deploy/env.server.example`; `LARAVEL_APP_KEY` comes from `artisan key:generate --show`, not `openssl` |
| Profile, shop, leaderboard or friends screens empty while login and matchmaking work | client reaches Nakama (7350, public) but not Laravel (8090) | same cause and fix as the `ECONNREFUSED` row above |
| Match found, then cancelled, with the economy on | `ECONOMY_ENABLED=1` while Laravel is unreachable — it fails **closed** by design | set `ECONOMY_ENABLED=0` until verify-step 4 passes |

---

## 6. Not done yet — before this is production

The live test proves the architecture works. It does not make the deployment
production-ready. Roughly in priority order:

1. **No TLS.** `useSsl: 0`, and traffic to 7350 is plaintext, so device-ID
   authentication is sniffable. Fix: issue a certificate for the domain via
   cPanel AutoSSL, then proxy to `127.0.0.1:7350` from cPanel's own Apache vhost
   — Apache already owns 443, so do not bind a second web server there. The proxy
   **must** forward WebSockets (`mod_proxy_wstunnel`), or authentication will
   work while matchmaking silently dies. Then set the client to `port: 443`,
   `useSsl: 1`, host unchanged.
2. **Rotate every key.** The current values are development-grade — including
   `LARAVEL_S2S_SECRET`, which is the only thing standing in front of the
   coin-minting and match-settlement routes.
3. **`/s2s` must not be internet-reachable.** If `LARAVEL_BIND=0.0.0.0` was set
   to unblock API testing, walk it back to `127.0.0.1` and proxy `/api` from
   Apache instead — see "Exposing the API" in section 3.
3. **CockroachDB is single-node `--insecure` with no backups.** Acceptable for
   testing, not for real accounts. `server/data/` is the only copy of everything.
4. **Keep 7351 loopback-only** and use a real console password.
5. **Clients dial the Mirror server directly.** This works here; it will not
   survive strict NAT or cellular without a relay. Mirror's Edgegap integration
   is already vendored at `Assets/Mirror/Hosting/Edgegap` if that becomes
   necessary.
6. **Never tested on a real phone.** All testing so far is Editor + MPPM. An
   IL2CPP Android build validates the `Assets/link.xml` stripping guard, which
   keeps IL2CPP from stripping Nakama — a failure that appears only on device.

---

## Reference

| Thing | Where |
|---|---|
| Developer-facing project context | `CLAUDE.md` (repo root) |
| Backend admin doc | `server/README.md` |
| Server `.env` template (deployment) | `server/deploy/env.server.example` |
| Server `.env` template (LAN development) | `server/.env.example` |
| Laravel API source | `backend/` |
| API collection for manual testing | `server/postman/` |
| Host survey script (read-only) | `server/deploy/preflight.sh` |
| Windows PowerShell helpers | `server/windows/` |
| Build entry points | `Assets/Editor/HeadlessServerBuild.cs` |
| Mode and scene configuration | `server/nakama/data/modules/config.lua` |
