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 pnpm2. 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 aserver-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.exampleprefilled from your build profile, and aREADME.mdwith these same deploy steps
/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.
| Variable | Purpose |
|---|---|
NODE_ENV=production | Run in production mode |
DB_MODE=remote | Use your Postgres (not the embedded PGlite database) |
DATABASE_URL | Your Postgres / Supabase connection string |
GAME_SERVER_PORT | Game server port (default 3001) |
ADMIN_API_KEY | Shared secret that lets your editor push content to this server — you paste the same value into Studio (step 8) |
CONFIG_ENCRYPTION_KEY | Encrypts 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 mode | Set these |
|---|---|
| Supabase (your own project) | SUPABASE_URL + SUPABASE_ANON_KEY (optionally SUPABASE_JWT_SECRET) |
| Local accounts | JWT_SECRET — username/password handled entirely by your server, no external service |
| Guests only | Set neither. Anyone can play, nothing is tied to an account |
About the database
WithDB_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:| Layout | How you get it | What Publish does |
|---|---|---|
| Shared — Studio and the server read the same Postgres | Studio's Settings → Database and the server's DATABASE_URL point at the same database | Your 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 database | The 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 buildThat 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
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.
| Field | What it does |
|---|---|
| Game server URL | Where Publish sends your content and where Playtest connects |
| Admin API key | Proves 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 have | Paste |
|---|---|
| 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 yet | ws://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:| Step | What it does | What you will usually see |
|---|---|---|
| Optimize media | Moves old inline images out of the database rows (hosted projects only) | skipped on a desktop project |
| Game definitions | Asks the server which rows it already has, and sends only the ones that differ | Definitions synced — seconds when little changed |
| Media manifest | Same check for media; sends only new or changed files | Media manifest already current (N), or N sent, M already current |
| Maps & rendering / Terrain chunks | Sends maps and terrain to a server with its own database | Shared DB — no push needed when the server shares Studio's database |
| Apply on live server | Starts a world reload on the server and waits for it, showing the seconds as they pass | Published & 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-gameThe . 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
| Symptom | Cause |
|---|---|
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 1 | A 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 empty | packages/db/drizzle/ didn't make it onto the VPS, so no migrations ran |
Crash on startup mentioning a .node file or an invalid ELF header | You 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 404 | Expected — the game server has no homepage. Check /health or /status |
| Client loads, but never connects to the game | Either 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 in | No auth mode configured — the server fell back to guests-only (step 4) |
| Settings you saved earlier stopped working | CONFIG_ENCRYPTION_KEY changed. Restore the original value |
| Publish: Media manifest or Apply on live server ends in AbortError | An older engine pushing rows the server already had. Update Studio and the server; see What each Publish step means above |
| Game definitions takes many minutes | Same 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 game | The 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 another | Check 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 |