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 22 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 22 and pnpm:
curl -fsSL https://deb.nodesource.com/setup_22.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 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.exampleprefilled from your build profile, and aREADME.mdwith 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-genand@ed5-mmo-studio/plugin-sandboxat 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 |
WS_ALLOWED_ORIGINS | Optional. Comma-separated web addresses your game client is played from (e.g. https://play.mygame.gg, *.mygame.gg, http://localhost:*). When set, browsers on any other site can't connect to your server; the desktop launcher and native clients are always allowed. Leave it unset to allow every site (the server then logs each site that connects, so you can build the list) |
ED5_ALLOW_TRUSTED_PLUGINS | Optional. Set to 1 only if you put your own server plugin files in packages/game-server/plugins/. Those files run with full access to this machine, so the server ignores them unless this is set. Leave it out if you only use plugins published from the editor. See Plugin SDK → Overview |
Set
CONFIG_ENCRYPTION_KEYonce 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#
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:
| 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 startupThe 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
/healthor/status. The/statusresponse includes abootErrorslist, 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
Forward the
UpgradeandConnectionheaders 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:
- Paste the server address, e.g.
wss://your-server.com:3001, into Game server URL. - Paste the server's
ADMIN_API_KEY(from its.env) into Admin API key. - Press Test. It asks the server whether it is up and whether it accepts the key, and tells you what is wrong if not.
- 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_KEYis also saved in the server's database settings (Settings → Server Settings): a value saved there wins over the.envfile, 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. Usews://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 |
|---|---|---|
| Preflight & snapshot version | Checks your content and saves it as a numbered version — see Publish Versions & Rollback | Version N snapshotted |
| Optimize media | Moves old inline images out of the database rows — to cloud storage on a hosted project, to the project's own media folder on a desktop project | All media optimized once nothing is left to move; skipped only when a desktop project uses a Docker or remote database instead of its built-in one |
| Media manifest | Asks the server which media rows it already has; sends only new or changed files, and uploads any media files the server is missing | Media manifest already current (N), or N sent, M already current, plus N file(s) sent when assets had to be re-seated |
| Game definitions | Asks the server which rows it already has, and sends only the ones that differ. A table that fails stops the publish | Definitions synced — seconds when little changed |
| Maps & rendering / Terrain chunks | Sends maps and terrain to a server with its own database, then asks the server whether every map's ground arrived | Chunks N / N, or Shared DB — no push needed when the server shares Studio's database. A warning names any map that is painted in your project but has no ground on the server |
| Apply on live server | Starts a world reload on the server and waits for it, showing the seconds as they pass | Published & live. A warning here lists any map the server could not put ground under a player on, with the reason |
| Stamp live version | Marks the version published and tells the server, which reports it on /status | Version N is 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.
A map is black in game? Open
https://your-server/status.zones.withoutTerrainlists maps whose ground never reached the server — Publish, and Terrain chunks sends it.zones.terrainProblemslists every other case by map name, with the reason and what to do: the server could not read the map's ground from its database (load-failed— it tries again by itself while a player is waiting, so check the database is reachable), the map's rows hold no painted tile (no-usable-rows— open the map, check it has ground, publish), or a player was sent to a map that is not in the game (not-on-board,streamed-world-piece,unknown-map— Project Health lists what leads there). The server writes the same lines to its log, starting withTerrain problem —, and Publish repeats them under Apply on live server. If Maps & rendering and Terrain chunks read Shared DB — no push needed while your server uses a different database from Studio, update Studio — that message is only right when both read the same database.
What a save sends. When you save an item, ability, monster or other definition, Studio asks your server what it already holds and sends only the rows that differ — usually just the one you saved. Media, maps and map events still travel with Publish. If two updates reach your server at once, the newer one's rows are kept. A server exported before this was added still receives the whole table on each save, and can let an older update undo a newer one — export the server again and redeploy it.
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 -v /opt/ed5/data:/app/data -p 3001:3001 ed5-gameThe . at the end is important — the build needs the whole workspace, not just the server folder.
Do not leave out the
-v. Uploaded images and audio are stored as files underdata/, inside the container. Without that volume they live and die with the container, so the next time you rebuild the image or re-run this command, every asset your game serves is gone — your media rows survive, but the files they point to don't, and the game starts answering No media bytes available. The volume keeps them on the VPS, where an upgrade can't reach them.
Upgrading your server#
Your world lives in two places, and an upgrade only ever replaces one of them. Your database holds the definitions, maps and media records. /opt/ed5/data holds the actual image and audio files (and, if you never moved to Postgres, the embedded database too). The engine itself is disposable; those two are not.
Before you upgrade, back both up:
tar czf ~/ed5-data-$(date +%F).tgz -C /opt/ed5 data
cp /opt/ed5/.env ~/ed5-env-backupThen take a dump of your database — pg_dump for your own Postgres, or a snapshot from the Supabase dashboard.
Then upgrade without overwriting them:
| Do | Don't |
|---|---|
Upload the new server-bundle/ into a new folder, then copy your old data/ and .env across | Replace /opt/ed5 wholesale, or rsync with --delete |
Keep your existing .env | Generate a fresh CONFIG_ENCRYPTION_KEY — a new one makes every saved setting unreadable |
Re-run the container with the same -v /opt/ed5/data:/app/data | Run it without the volume "just this once" |
Then check it worked, before you tell anyone the server is back:
| Check | What good looks like |
|---|---|
curl https://your-server/health | Answers |
curl https://your-server/status | bootErrors is empty, tick.systemFailures is {}, and zones.withoutTerrain.count is 0, and zones.terrainProblems.count is 0 (a map listed in either is black in game — the entry says why) |
curl https://your-server/api/media/manifest | The file count matches your Media Library |
curl -I https://your-server/api/media/<id> for a few assets | Not 404 No media bytes available |
If assets are missing, Publish once from Studio. Publish now compares your Media Library against the files the server actually holds on disk and uploads whatever it lacks, so a rebuilt server re-seats itself from your editor. You will see N file(s) sent on the Media manifest step. (This needs a server new enough to accept them; an older one skips it silently, and you can copy .ed5-media across by hand instead — the filenames are content hashes, so copying over the top is always safe.)
Publish is the repair tool. Because media files are stored under a hash of their own contents, sending them again is harmless and an interrupted publish simply picks up where it left off. If a server is serving No media bytes available, Publish is the first thing to try.
Keep a nightly copy anyway. Publish can re-seat your server's media from your editor, so your editor is a real second copy — but only of what is still in it. That one
tar czfline in a cron job, plus a database dump, is what covers the case where the editor is the machine you lose.
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 |
| A tile stops players and nothing is drawn on it | Damage an older version of the editor saved. Open the map in the map editor — a notice at the top offers Repair (tick Impassable in the eye menu if it does not show). Repair, save, then Publish |
| An object on a streaming map shows in the editor but not in the game, or does not block | It was saved by an older editor. Open the parent map, wait for the message that objects were read again, save, then Publish. On your own server older than 0.6.5, re-export it first |