Deliverable #4 · 2026-08-02

The server instance — a droplet setup guide

epic: server-instance delivered
The server setup runbook

This page is the deliverable — the complete, exact-commands runbook for standing up the game server on a fresh DigitalOcean droplet.

server-instance has been parked since it opened — correctly, since it was blocked on server-architecture landing a runtime choice. That dependency hasn’t formally closed yet, but enough of the shape is now settled — DigitalOcean, Godot headless per world instance, a director process, Postgres, Caddy — that it’s worth writing down as a concrete runbook rather than leaving it as an open question. This is that runbook: everything needed to go from “no droplet exists” to “a WebSocket connection reaches a running Godot process through TLS,” in order, with exact commands.

One piece is deliberately left as a named gap: the director service’s own language/runtime isn’t decided. Wherever that matters below, it’s flagged plainly rather than guessed at.

Before you start

1. Droplet creation

Region: Sydney (syd1). DigitalOcean has no Australian region closer than Sydney, and Sydney is the only Australian region it offers at all — for Kris, based in Brisbane, it’s the lowest-latency option by a wide margin over the next-nearest regions (Singapore, or the US West Coast). Pick it in the region dropdown when creating the droplet, or pass --region syd1 if using doctl/the API.

Image: Ubuntu 24.04 LTS (ubuntu-24-04-x64). Ubuntu 26.04 LTS is also now offered by DigitalOcean (slug ubuntu-26-04-x64), but its first point release (26.04.1, which is when Canonical considers an LTS release-hardened and opens upgrade paths from the prior LTS) is only landing this same month. 24.04 has two years of production use behind it — the safer default for a game server you want to stop thinking about. Revisit this choice if standing the box up well after 26.04.1 has shipped and settled.

Size: Basic Droplet, Regular (shared CPU), 2 GB RAM / 1 vCPU / 50 GB SSD. This is the tier specified for this setup — Godot running headless needs more headroom than a static Go/Rust binary would, mainly for the scene tree and physics step even with no rendering. At the time of writing this is DigitalOcean’s $12/month tier; check the current droplet pricing page before committing, since prices do move.

SSH key: add your public key (cat ~/.ssh/id_ed25519.pub) under “Authentication” during creation — pick SSH key, not password. This is what makes §2’s “password auth off” step painless: you’ll already have working key-based access before you disable the alternative.

Create it, then note the droplet’s public IPv4 address — every step below refers to it as <DROPLET_IP>.

ssh root@<DROPLET_IP>

You should land at a root shell on a fresh Ubuntu 24.04 box. If this hangs or is refused, check the droplet’s status in the DO control panel before troubleshooting further — it can take a minute to finish booting after creation.

2. First-login hardening

All commands below run as root over the SSH session from §1, unless noted.

2.1 — Update packages, create a non-root sudo user:

apt update && apt upgrade -y
adduser lor
usermod -aG sudo lor

adduser will prompt for a password — set one; it’s a local fallback, not how you’ll actually log in (SSH keys, below).

2.2 — Copy your SSH key to the new user:

rsync --archive --chown=lor:lor ~/.ssh /home/lor

From your own machine, in a new terminal, confirm the new user works before touching anything else:

ssh lor@<DROPLET_IP>

Don’t proceed past this point until that command logs you in without a password prompt.

2.3 — Lock down SSH (back on the droplet, as lor, via sudo):

sudo nano /etc/ssh/sshd_config

Set (or confirm) these values:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
sudo systemctl restart ssh

Before closing your root session, open a fresh terminal and confirm ssh lor@<DROPLET_IP> still works, and that ssh root@<DROPLET_IP> is now refused. Only close the original root session once both are confirmed — this is the one step in the whole guide where an out-of-order mistake locks you out.

2.4 — Firewall (ufw):

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Confirm:

sudo ufw status

Expected output: Status: active, with OpenSSH, 80/tcp, and 443/tcp listed as ALLOW. Everything else — including Postgres’s 5432 and any world-instance port — stays closed to the outside world by design; Caddy is the only public door (§5), and it talks to Postgres and the game processes over localhost.

2.5 — Unattended security upgrades:

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

Choose “Yes” at the prompt. This applies security patches automatically without needing a login to do it.

2.6 — fail2ban (bans IPs after repeated failed SSH attempts):

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

Expected output includes a Status for the jail: sshd block with Currently banned: 0 on a fresh install.

3. Installing Godot headless

Verified, not assumed: as of Godot 4.7.1 (the same version already used for this repo’s Godot POCs — see land-of-lor/pocs/README.md), the official release provides Godot_v4.7.1-stable_linux.x86_64.zip and Godot_v4.7.1-stable_linux.arm64.zip — so an arm64 Linux build genuinely exists, this isn’t a case of assuming Godot only ships x86_64. There is no separate “headless” download to choose between, either — Godot 4 folded that into the standard editor binary via a --headless flag (--display-driver headless --audio-driver Dummy), so the same binary you’d use on a desktop runs a server with no display or audio driver attached.

Recommendation: x86_64, not arm64, despite the arm64 build existing. Reasons specific to this project, not a generic default:

Install it (as lor, on the droplet):

cd /tmp
wget https://github.com/godotengine/godot/releases/download/4.7.1-stable/Godot_v4.7.1-stable_linux.x86_64.zip
unzip Godot_v4.7.1-stable_linux.x86_64.zip
sudo mv Godot_v4.7.1-stable_linux.x86_64 /usr/local/bin/godot4
sudo chmod +x /usr/local/bin/godot4

Verify:

godot4 --headless --version

Expected output: a version string starting 4.7.1.stable.... If you instead get a linker/GLIBC error, double-check the droplet is genuinely the x86_64 image from §1 — the zip above will not run on an arm64 box.

This installs the engine binary only — enough to run a project directly from its source files headless (godot4 --headless --path /path/to/project), which is what §6’s systemd units do. You do not need Godot’s separate export-templates package for this; those are for producing a standalone exported binary, which isn’t necessary when you’re running the project through the engine itself on the box that owns it.

4. PostgreSQL

4.1 — Install (Ubuntu 24.04 ships a current PostgreSQL in its own default repository — no need to add Postgres’s upstream apt repo for this scale of project):

sudo apt install -y postgresql
sudo systemctl enable --now postgresql

4.2 — Create a database and a role for the game (do not use the postgres superuser role for the game process):

sudo -u postgres psql

At the psql prompt:

CREATE ROLE lor_game WITH LOGIN PASSWORD 'change-this-password';
CREATE DATABASE lor_game OWNER lor_game;
\q

Generate a real password rather than typing one by hand, e.g. openssl rand -base64 24, and keep it out of anything that reaches git — it belongs in an environment file or systemd unit’s Environment=/EnvironmentFile=, not a committed config (§6 shows where it plugs in).

4.3 — Confirm the connection works:

psql -h 127.0.0.1 -U lor_game -d lor_game -W

Enter the password when prompted. Expected output: a lor_game=> prompt with no errors. \conninfo at that prompt should report You are connected to database "lor_game" as user "lor_game" ... on host "127.0.0.1". \q to exit.

Postgres is listening on 127.0.0.1 only by default on a fresh install (check listen_addresses in /etc/postgresql/*/main/postgresql.conf if this fails) — it never needs to be reachable from outside the droplet, and §2.4’s firewall doesn’t open 5432, so this is defense in depth, not the only thing standing between Postgres and the internet.

5. Caddy — reverse proxy and TLS

5.1 — Install (Caddy’s own apt repository, since Ubuntu’s default one carries an old version):

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

5.2a — If you have a domain pointed at the droplet: add an A record for it (e.g. game.yourdomain.com) pointing at <DROPLET_IP> at your DNS provider first, and give it a few minutes to propagate. Then:

sudo nano /etc/caddy/Caddyfile
game.yourdomain.com {
	# Director service: world registry, membership, portal routing.
	# Runtime not yet decided (see §6) — assumed listening on localhost:8081 below.
	handle /director/* {
		reverse_proxy localhost:8081
	}

	# World instances. One Godot process per world, each on its own local port —
	# 9000 + instance number is this guide's convention; adjust to match whatever
	# the director actually assigns.
	handle /world/1/* {
		reverse_proxy localhost:9001
	}
	handle /world/2/* {
		reverse_proxy localhost:9002
	}

	# Everything else — a placeholder until there's an actual landing page to serve.
	handle {
		respond "Lor game server is up." 200
	}
}

That’s the whole WebSocket story — Caddy’s reverse_proxy detects the Connection: Upgrade / Upgrade: websocket handshake automatically and proxies it transparently; there is no separate WebSocket directive or extra config needed, and no special case for WebSocketMultiplayerPeer clients versus plain HTTP. Caddy also issues and renews the TLS certificate for game.yourdomain.com automatically the first time it starts, with no separate certbot/Let’s Encrypt step.

sudo systemctl reload caddy

Then from your own machine: curl -I https://game.yourdomain.com should return HTTP/2 200.

5.2b — If you do not yet have a domain: point Caddy at the droplet’s bare IP instead:

http://<DROPLET_IP> {
	handle /director/* {
		reverse_proxy localhost:8081
	}
	handle /world/1/* {
		reverse_proxy localhost:9001
	}
	handle {
		respond "Lor game server is up." 200
	}
}

What this loses, said plainly: automatic HTTPS is not available. Caddy’s automatic-certificate machinery (Let’s Encrypt/ZeroSSL) issues certificates for hostnames, not bare IPs — there is no ACME path to a browser-trusted cert for <DROPLET_IP> itself. Two honest options while IP-only:

Either is fine for a development-stage droplet a small number of trusted people connect to; neither is a substitute for getting a domain once real players are involved. This is the honest cost of skipping the domain step, not a permanent limitation of the setup.

6. systemd units

Two service definitions: one for the director, one templated for world instances so multiple can run side by side.

6.1 — Environment file (holds the Postgres password from §4.2 and anything else secret, kept out of the unit files themselves and out of git):

sudo mkdir -p /etc/lor
sudo nano /etc/lor/game.env
DATABASE_URL=postgresql://lor_game:change-this-password@127.0.0.1:5432/lor_game
sudo chmod 600 /etc/lor/game.env
sudo chown lor:lor /etc/lor/game.env

6.2 — Director service/etc/systemd/system/lor-director.service:

[Unit]
Description=Lor director — world registry, membership, portal routing
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=lor
EnvironmentFile=/etc/lor/game.env
# PLACEHOLDER — director runtime/language not yet decided (server-architecture).
# Swap this ExecStart for whatever that turns out to be, e.g.:
#   ExecStart=/usr/bin/node /opt/lor/director/dist/index.js
#   ExecStart=/opt/lor/director/director   (a compiled Go/Rust binary)
ExecStart=/opt/lor/director/run.sh
WorkingDirectory=/opt/lor/director
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=lor-director

[Install]
WantedBy=multi-user.target

6.3 — World instance service (templated)/etc/systemd/system/lor-world@.service. The @ makes this a systemd templatelor-world@1, lor-world@2, etc. are independent instances sharing one definition, with %i substituting the instance number:

[Unit]
Description=Lor world instance %i
After=network.target lor-director.service
PartOf=lor-director.service

[Service]
Type=simple
User=lor
EnvironmentFile=/etc/lor/game.env
Environment=WORLD_PORT=%i
ExecStart=/usr/local/bin/godot4 --headless --path /opt/lor/game-server
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=lor-world-%i

6.4 — Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now lor-director.service
sudo systemctl enable --now lor-world@1.service

On-demand start, not always-on — the intent, stubbed for now: running every possible world instance permanently doesn’t match a 2 GB droplet or a small early player base. The real design is the director starting a world process only when someone actually needs it (entering a zone, opening a portal) and stopping it after some idle period. That requires the director to be able to run systemctl start lor-world@<N> itself, which in turn needs either a small sudoers rule scoped to exactly that command, or (cleaner, once the director’s runtime is chosen) systemd’s own socket-activation/D-Bus API instead of shelling out. Neither is built here — this section’s job is only to make sure a world instance can be started and stopped as its own unit (sudo systemctl start/stop lor-world@2) by hand today, on a foundation that doesn’t need restructuring once the director grows the ability to do it itself.

7. Deployment and update workflow

Simplest thing that works at this project’s current stage — a manual pull-and-restart, not a CI pipeline:

sudo mkdir -p /opt/lor
sudo chown lor:lor /opt/lor
cd /opt/lor
git clone <land-of-lor-repo-url> game-server

To ship a change:

cd /opt/lor/game-server
git pull --ff-only
sudo systemctl restart lor-director.service
sudo systemctl restart 'lor-world@*.service'

If git pull --ff-only refuses (local changes on the box diverged from the repo), stop and reconcile by hand — same non-negotiable rule the site’s own deploy-watch.sh follows (land-of-lor/../deploy-watch.sh): never force, reset, or rebase automatically on a divergence. This workflow is a deliberate placeholder, matching server-instance/overview.md’s own scope (“appropriate scope here is ‘a small number of players can connect and it works’”) — a poll-and-rebuild watcher like the site’s, or a real CI deploy, is worth building once updates are frequent enough that a manual git pull is the actual bottleneck, not before.

8. Verification checklist

Run through these in order — each one confirms the layer below it is actually working, ending at the same WebSocket path a real client will use.

  1. ssh lor@<DROPLET_IP> succeeds; ssh root@<DROPLET_IP> is refused. (§2)
  2. sudo ufw status shows only OpenSSH/80/443 allowed. (§2.4)
  3. godot4 --headless --version prints 4.7.1.stable.... (§3)
  4. sudo systemctl status postgresql shows active (running). (§4)
  5. psql -h 127.0.0.1 -U lor_game -d lor_game -W connects and \conninfo confirms the database and user. (§4.3)
  6. sudo systemctl status caddy shows active (running); curl -I https://game.yourdomain.com (or http://<DROPLET_IP>) returns 200. (§5)
  7. sudo systemctl status lor-director and sudo systemctl status lor-world@1 both show active (running). (§6)
  8. The end-to-end check: from your own machine, open a WebSocket to the proxied path and confirm it upgrades successfully — e.g. with websocat (websocat wss://game.yourdomain.com/world/1/) or the browser console (new WebSocket("wss://game.yourdomain.com/world/1/"), watch for readyState reaching 1). A successful upgrade here means every layer — Caddy’s TLS and proxying, the world process, and the network path — is genuinely working together, not just independently healthy.

9. Operating notes

What’s still open

Sources & artifacts

← related epic: server-instance

Also available as raw markdown.