Run your game server on a VPS you control, connect it to your database, and share a public play link.

Self-Hosting on a VPS

Run your game server on a Linux VPS you control. This is the fullest-control way to put a game online (prefer one-click? Managed Hosting is available in private beta).

> You may only need to host the game server. If you use the ED5 public play link, we serve the client for you and point it at your server — so the whole client build step below is optional. Read step 6 before you start.

What you need

  • A Linux VPS (Ubuntu 22.04 / Debian 12). Start around 2 vCPU / 4 GB RAM and scale up for more players.
  • Node 20 and pnpm 9 on the VPS.
  • A PostgreSQL database for production — your own Supabase project is fine. Do not use the embedded PGlite database online (see Production Database).
  • A domain with DNS you can point at the VPS.
  • Basic comfort with a Linux terminal. Every step below is copy-paste, but you will be using SSH.

1. Provision the server

Create the VPS, then install Node 20 and pnpm:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
npm i -g pnpm

2. Export the server

Open Build → Export Game in the studio, choose the Server target and the profile you're deploying (usually Production), then Build. You get a server-bundle/ folder containing exactly what a game server needs and nothing it doesn't:
  • the workspace manifests, already trimmed of the monorepo's development scripts
  • the server and every package it depends on, already built
  • packages/db/drizzle/ — the migration SQL, which the server applies on every startup
  • a .env.example prefilled from your build profile, and a README.md with these same deploy steps
Upload that folder to your VPS (/opt/ed5 in the examples below). That's the whole step.

The export deliberately leaves out node_modules/ (a Windows-resolved install can't run on Linux), the admin app (authoring tools never belong on a public server), your local database, and the ~137 MB of demo art packs — those only feed the first-boot starter world, which your published world replaces anyway. Want them anyway? Export from the CLI with --include-seed-assets, or copy packages/db/assets/ across later and point ED5_DB_ASSETS_DIR at it.

> If pre-flight blocks the export, believe it. The Server target refuses to build an engine with no compiled server or no migration SQL, because both produce a bundle that starts perfectly and then behaves as if your database were empty.

Doing it by hand instead

You don't need to, but the bundle isn't magic — it's a filtered copy of the engine tree in your Studio install (%APPDATA%\@ed5-mmo-studio\studio\engine\). If you copy it yourself, copy the root of that folder — the level containing package.json, pnpm-lock.yaml, pnpm-workspace.yaml, .npmrc and packages/ — and take packages/shared, packages/db (including drizzle/), packages/world-gen, packages/game-server and packages/plugin-sandbox. Skip node_modules/, apps/admin and data/. Add apps/client only if you're self-hosting the client too (see step 6).

Two traps the export exists to remove:

> Do not copy just packages/game-server. It is a workspace package, not a standalone application — on its own it will not start. It imports @ed5-mmo-studio/shared, @ed5-mmo-studio/db, @ed5-mmo-studio/world-gen and @ed5-mmo-studio/plugin-sandbox at runtime, and the database migrations live in a different package.

> packages/db/drizzle/ is not optional. The server applies its migrations from that folder on every startup. If the folder is missing, the server still boots — and silently applies nothing. It looks like a mysterious "the database is empty / tables don't exist" problem much later.

Copying by hand also brings the monorepo's root package.json along unchanged, whose prepare script runs husky on install — a devDependency that --prod doesn't install, so step 3 exits with an error. The exported bundle replaces those scripts with a single start.

3. Install dependencies

From the bundle root on the VPS:
cd /opt/ed5
pnpm install --prod --filter @ed5-mmo-studio/game-server...

If the lockfile complains, retry with pnpm install --prod --no-frozen-lockfile.

The packages ship already built, so there is no compile step for the server. The trailing ... matters — it pulls in the workspace dependencies too.

4. Configure environment

Create a .env file at the engine root (the same folder as package.json). The server walks up from its own location to find it.
VariablePurpose
NODE_ENV=productionRun in production mode
DB_MODE=remoteUse your Postgres (not the embedded PGlite database)
DATABASE_URLYour Postgres / Supabase connection string
GAME_SERVER_PORTGame server port (default 3001)
ADMIN_API_KEYShared secret that lets your editor push content to this server — you paste the same value into Studio (step 8)
CONFIG_ENCRYPTION_KEYEncrypts sensitive settings stored in the database

> Set CONFIG_ENCRYPTION_KEY once and keep it safe. It decrypts settings already stored in your database — change it later and those settings become unreadable. Back it up somewhere you won't lose it.

Then pick how players sign in — see Auth Setup for the full picture:

Auth modeSet these
Supabase (your own project)SUPABASE_URL + SUPABASE_ANON_KEY (optionally SUPABASE_JWT_SECRET)
Local accountsJWT_SECRET — username/password handled entirely by your server, no external service
Guests onlySet neither. Anyone can play, nothing is tied to an account

About the database

With DB_MODE=remote, a Supabase connection string is automatically routed through Supabase's transaction pooler (port 6543) with prepared statements disabled. That is the supported configuration — paste your normal Supabase connection string and let the server handle it. Don't force the direct 5432 endpoint.

Migrations apply automatically on startup from packages/db/drizzle/. You do not need to run a migration command by hand. Watch the first boot all the way through before assuming it worked — and if you see no migration activity at all, re-read the warning in step 2.

About media

Your server serves every image, sheet and sound to players itself, out of its database. If the server has its own database, Publish sends it the bytes it is missing. If the server uses the same database as Studio (see One database or two? below), the media is already there and nothing is sent. Either way nothing is written to Supabase Storage — an empty Storage bucket in your Supabase project is expected, and the Media Manager's footer reads Remote: sent on Publish rather than a sync count. Cloud storage sync only exists for projects hosted on Studio Web.

One database or two?

Both layouts are supported, and Publish works out which one you have:
LayoutHow you get itWhat Publish does
Shared — Studio and the server read the same PostgresStudio's Settings → Database and the server's DATABASE_URL point at the same databaseYour edits are already in the database the server reads, so there is nothing to send. Publish checks that, then tells the server to reload the world so it picks them up.
Separate — the server has its own databaseThe server's DATABASE_URL is a different database (or it runs the embedded one)Publish sends whatever the server does not already have — definitions, media, maps, terrain — then tells it to reload.

> With a shared database, Publish is the reload. The server keeps the copy of your world it read when it started, so an edit shows up in game after the next Publish (or a server restart) — not the instant you save it. The exception is a map object's or tileset's collision: those are pushed to a shared-database server as you save them.

5. Run it

Start the server from the engine root — path resolution depends on it:
cd /opt/ed5
npm i -g pm2
pm2 start "node packages/game-server/dist/index.js" --name game-server
pm2 save && pm2 startup

> The game server has no homepage. Opening its root URL in a browser returns a 404 — that is correct and by design. To check that it's alive, use /health or /status. The /status response includes a bootErrors list, which is the first place to look when something is wrong.

6. Do you need to host the client?

Usually not. You have two options:

Option A — use the ED5 play link (recommended). Register your server in your account dashboard (step 8) and enable the public play link. We serve the client and point it at your server's wss:// address. Nothing more to build, deploy, or keep updated.

Option B — self-host the client too. Only if you want the client on your own domain. Build it before deploying, because the URLs are baked in at build time:

NEXT_PUBLIC_GAME_SERVER_WS_URL=wss://game.yourdomain.com \
  pnpm --filter @ed5-mmo-studio/client build

That produces a standalone bundle under apps/client/.next/standalone/ — copy .next/static and public alongside the generated server.js, then run it on port 3000.

> NEXT_PUBLIC_ values are frozen at build time. Setting them after you build has no effect, and the client will keep trying to reach whatever address it was built with. The client bundled inside your Studio install was built with local defaults — rebuild it with your own URLs. Changing your domain later means rebuilding.

7. HTTPS + WebSocket

Put a reverse proxy (nginx, Caddy, or Cloudflare) in front and terminate TLS:
  • the game server on a subdomain → 127.0.0.1:3001
  • the client, if you're hosting it, on your main domain → 127.0.0.1:3000
> Forward the Upgrade and Connection headers on the game-server route. Without them, everything looks fine — the health check passes, the page loads — and players simply never connect. Caddy does this automatically; nginx needs it configured explicitly.

Use Let's Encrypt or Caddy automatic TLS so the wss:// endpoint is secure, then firewall ports 3000 and 3001 so only the proxy can reach them.

8. Go live

With the server reachable over HTTPS/WSS, open your ED5 account dashboard, register the server under Connect your own VPS (its public URL + wss:// URL), then enable the public play link for your project and share it. See Publishing Your Game.

Pointing the desktop Studio at it

In the desktop app the same address goes in Server Control → Connection → Use my own VPS:

1. Paste the server address, e.g. wss://your-server.com:3001, into Game server URL. 2. Paste the server's ADMIN_API_KEY (from its .env) into Admin API key. 3. Press Test. It asks the server whether it is up and whether it accepts the key, and tells you what is wrong if not. 4. Press Connect, then Restart editor now.

FieldWhat it does
Game server URLWhere Publish sends your content and where Playtest connects
Admin API keyProves to your server that the content is from you. It must match the server's ADMIN_API_KEY exactly

> Publish answers "HTTP 401 Unauthorized"? The key is missing or wrong — the server is up and refusing the content. Paste the key and press Test. If Test still says rejected, check whether an ADMIN_API_KEY is also saved in the server's database settings (Settings → Server Settings): a value saved there wins over the .env file, so the server is expecting that one.

You havePaste
A domain behind TLS (step 7 done)wss://your-server.com:3001, or just your-server.com:3001 — a bare hostname is treated as wss://
Only the VPS IP address, no domain yetws://203.0.113.7:3001, or just 203.0.113.7:3001 — a bare IP is treated as ws://, because there is no certificate for an IP address

> Pasting a raw IP with wss:// in front is the most common reason "the server is running but I can't connect": the connection fails at the TLS handshake before it ever reaches your game server. Use ws:// until the domain and certificate are in place.

> Restart the editor afterwards. Studio hands the server address and key to the editor when the editor starts, so a freshly connected server is not picked up until then — press Restart editor now under the Connect button. Until you restart, publishes still go to the local server. You can confirm which one is live from the Server URL shown in Settings.

Clearing the URL and connecting again with it blank puts Studio back on its own embedded server.

What each Publish step means

Open Server → Publish and press Publish. The steps run in order, and each one reports what it did:
StepWhat it doesWhat you will usually see
Optimize mediaMoves old inline images out of the database rows (hosted projects only)skipped on a desktop project
Game definitionsAsks the server which rows it already has, and sends only the ones that differDefinitions synced — seconds when little changed
Media manifestSame check for media; sends only new or changed filesMedia manifest already current (N), or N sent, M already current
Maps & rendering / Terrain chunksSends maps and terrain to a server with its own databaseShared DB — no push needed when the server shares Studio's database
Apply on live serverStarts a world reload on the server and waits for it, showing the seconds as they passPublished & live

> A step timed out or showed "AbortError"? On engine versions before this fix, Publish pushed every definition and media row through the server even when the server already had them, and a large project could outlast the step's time limit. The server kept working after the editor gave up, which is why trying again a few minutes later seemed to work — and why the next step then timed out against a server that was still busy. Update Studio and your server, then Publish once; if a step still stalls, restart the server from Server → Restart before publishing again rather than stacking attempts.

Prefer Docker?

The engine includes the same container definition our managed servers run. It handles the build, the production install, and shipping the migrations for you. Build it with the engine root as the build context:
cd /opt/ed5
docker build -f packages/game-server/Dockerfile -t ed5-game .
docker run -d --restart=always --env-file .env -p 3001:3001 ed5-game

The . at the end is important — the build needs the whole workspace, not just the server folder.

Keep admin private

The admin app (authoring tools, port 3002) and your database should never be publicly exposed. Author your world in Studio on your own machine and publish to the live server — you don't need admin running on the VPS at all.

Troubleshooting

SymptomCause
Cannot find module '@ed5-mmo-studio/shared'You copied only packages/game-server by hand. Export the Server target instead (step 2)
husky: not found / install exits 1A hand-copied root package.json, whose prepare script needs a devDependency --prod skips. The exported bundle has no such script
Server starts, but tables are missing or the world is emptypackages/db/drizzle/ didn't make it onto the VPS, so no migrations ran
Crash on startup mentioning a .node file or an invalid ELF headerYou copied Windows node_modules. Delete it and reinstall on the VPS (step 3)
Starter world has no art, log says "no packages/db/assets directory"Expected — the demo packs aren't bundled. Harmless once you publish your own world; otherwise see step 2
Root URL returns 404Expected — the game server has no homepage. Check /health or /status
Client loads, but never connects to the gameEither the proxy isn't forwarding Upgrade/Connection, or the client was built with the wrong NEXT_PUBLIC_GAME_SERVER_WS_URL
Players connect but can't sign inNo auth mode configured — the server fell back to guests-only (step 4)
Settings you saved earlier stopped workingCONFIG_ENCRYPTION_KEY changed. Restore the original value
Publish: Media manifest or Apply on live server ends in AbortErrorAn older engine pushing rows the server already had. Update Studio and the server; see What each Publish step means above
Game definitions takes many minutesSame cause — or an earlier, timed-out Publish is still running on the server. Restart the server, then Publish once
You changed an object's collision and nothing changed in gameThe server had not re-read its definitions. Publish (it now reloads the world every time), or restart the server
The same object blocks in one place and not in anotherCheck the copy that does not block: if you resized it or it sits in a child map of a larger world, update the server — both used to lose part of their collision. If it was placed with the layer dropdown on L3/L4, it is on a decoration layer, and those never block — place it on Auto, L1 or L2