First start
This is the one first-start sequence for Moongate. It applies whether you installed the release with the Linux installer, run the container image, or build from source. Moongate is under active development: characters enter the world, walk and see each other, fight, and NPCs and items run Lua scripts, but some gameplay is still missing. See Implementation status.
A server start needs a root, readable client files, the active role’s PostgreSQL database and reviewed SQL, and a private Redis instance for realm leases and one-use handoff tickets. The steps below prepare these dependencies.
Before you start
Section titled “Before you start”- Your own Ultima Online client data. Moongate does not distribute it. The initial packet protocol targets ClassicUO with UO 7.x client data.
- A Unix shell such as Bash or Zsh for the examples. Install the Bitwarden CLI,
sign in to your vault and unlock it with
BW_SESSIONset in this shell.bw statusmust reportunlocked; it does not print stored passwords. Keep shell tracing disabled (set +x) before loading secrets. Stop if any vault lookup fails. - A reachable PostgreSQL server on which you can create databases. The examples in this repository use PostgreSQL 16.
- A reachable Redis 7+ server with authentication and
maxmemory-policy noeviction. Keep it on a private network. - Two free TCP ports in standalone mode: login defaults to 2593 and game to 2595. The UDP ping server uses port 12000 when it is free.
- For a source build: Git and the .NET 10 SDK (
global.jsononly setsrollForwardtolatestPatchand refuses prerelease SDKs; it pins no version). Node.js is only needed to work on the documentation website.
Where the commands live
Section titled “Where the commands live”For a first Linux server, use the installed release path below. Choose source only when developing Moongate; use Docker when you want the supplied dependency topology. Keep the chosen path’s working directory for its commands:
-
Installed release: finish the Linux installation. Run
mgctlandmoongatefrom any directory with the installed commands onPATH. -
Source checkout: run the following from the directory where you keep projects, then stay at the repository root for all
dotnetcommands on this page:Terminal window git clone https://codeberg.org/moongate/moongate.gitcd moongategit switch developdotnet build Moongate.slnx -c Releasedevelopincludes unreleased work. To reproduce a release, check out its tag and use the documentation published for that version. -
Docker: follow the complete login and realms recipe, including configuration, secrets, schema jobs and startup commands. Return to Connect with ClassicUO for the client profile. For a single process with existing databases and Redis, use Standalone container.
The steps below show complete release and source commands. Run only one variant
at each step. They use /srv/moongate as the server data root.
First start
Section titled “First start”-
Prepare the root. Give the server a directory of its own:
Terminal window sudo mkdir -p /srv/moongate && sudo chown "$USER" /srv/moongatemgctl init /srv/moongateFor a source checkout, replace only the final command above with this complete command, run from the repository root:
Terminal window dotnet run --project src/Moongate.Server -c Release -- --initialize-root --root-directory /srv/moongateThis writes
config/moongate.tomlwith the defaults, createslogs/andplugins/, copies the release’s core SQL intomigrations/, its shard data files intodata/, its templates intotemplates/and its example scripts intoscripts/. It needs no database and no client files. Prepare a root with mgctl describes what happens on a root that already exists. -
Edit the configuration. Open
/srv/moongate/config/moongate.tomland set the client path and both database connections. Keep the other generated sections:[ultima]ultima_path = "/absolute/path/to/your/ultima-client"[persistence.accounts]connection_string = "$MOONGATE_ACCOUNTS_DATABASE"[persistence.realm]connection_string = "$MOONGATE_REALM_DATABASE"[redis]connection_string = "$MOONGATE_REDIS_CONNECTION_STRING"handoff_secret = "$MOONGATE_HANDOFF_SECRET"Use an absolute client path; relative paths resolve from the process working directory, not from the root. Supply the two PostgreSQL URIs, the Redis connection string and a separate cluster-wide handoff secret through the referenced environment variables. The secret must be at least 32 UTF-8 bytes. Read credentials from Bitwarden; do not write their values in this file. Every login and game process in one deployment needs the same Redis endpoint, Redis password and handoff secret. The configuration reference lists every setting.
UO client encryption is disabled by default. For a client that sends encrypted traffic, configure
[network.encryption]using the client encryption guide before starting the server. Apply the same profile to separate login and game processes. -
Provision PostgreSQL and Redis. Create the Accounts and World databases and role-specific credentials before starting Moongate. The server never creates databases or roles. Use a DML-only runtime role and a separate schema role; see Separate DDL and runtime roles.
Provision Redis with a strong password, private-network access and
maxmemory-policy noeviction. Keep its password separate from the handoff secret. For a runnable PostgreSQL and Redis topology, use the Docker login and realms example. Standalone checks both databases; login checks Accounts only, and game checks its Realm only. All three modes also check Redis at startup. -
Apply the core migrations with schema credentials. Keep the server stopped. Startup refuses pending SQL. Store the complete schema and runtime PostgreSQL URIs in distinct Bitwarden items (the examples read each item’s password field). Replace the item names below with your own. All four URIs must point to the databases created in step 3: Accounts for auth and Realm for world.
First load the credentials that normal startup will use in the parent shell:
Terminal window export MOONGATE_ACCOUNTS_DATABASE="$(bw get password moongate-accounts-runtime-uri)"export MOONGATE_REALM_DATABASE="$(bw get password moongate-realm-runtime-uri)"export MOONGATE_REDIS_CONNECTION_STRING="$(bw get password moongate-redis-connection)"export MOONGATE_HANDOFF_SECRET="$(bw get password moongate-handoff)"For the installed release, apply reviewed core SQL in a subshell:
Terminal window (set -eMOONGATE_ACCOUNTS_DATABASE="$(bw get password moongate-accounts-schema-uri)"MOONGATE_REALM_DATABASE="$(bw get password moongate-realm-schema-uri)"export MOONGATE_ACCOUNTS_DATABASE MOONGATE_REALM_DATABASEmgctl migrate apply --root-directory /srv/moongate --target authmgctl migrate apply --root-directory /srv/moongate --target world)For a source checkout, use this subshell from the repository root instead:
Terminal window (set -eMOONGATE_ACCOUNTS_DATABASE="$(bw get password moongate-accounts-schema-uri)"MOONGATE_REALM_DATABASE="$(bw get password moongate-realm-schema-uri)"export MOONGATE_ACCOUNTS_DATABASE MOONGATE_REALM_DATABASEdotnet run --project src/Moongate.Ctl -c Release -- migrate apply --root-directory /srv/moongate --target auth --migrations-directory ./migrationsdotnet run --project src/Moongate.Ctl -c Release -- migrate apply --root-directory /srv/moongate --target world --migrations-directory ./migrations)Continue only when both applies succeed.
--target authreads[persistence.accounts],--target worldreads[persistence.realm]from the root’s configuration. The temporary overrides affect only the subshell: after it exits, the parent shell still holds the runtime URIs. Do not start the server inside the migration subshell. Disable shell tracing (set +x) when handling secrets, and do not print these variables.As the schema owner, grant the runtime roles database
CONNECT, entity-schemaUSAGE, tableSELECT,INSERT,UPDATE,DELETE, and sequenceUSAGEandSELECT. Grant onlyUSAGEand history-tableSELECTinmoongate_migrations, without history writes. Apply default privileges for future tables too; Separate DDL and runtime roles describes the contract, and the Docker recipe provides provisioning scripts. Moongate does not grant these rights itself.Use
statusinstead ofapplyto inspect pending files. Each successfulapplyreports the number applied:Applied {count} migration(s) to {target}.Optional Apply at startup needs DDL privileges; keep it disabled for this runtime-role sequence. -
Start the server.
Terminal window moongate --root-directory /srv/moongateOr, from the source repository root:
Terminal window dotnet run --project src/Moongate.Server -c Release -- --root-directory /srv/moongateBoth commands use the parent shell’s runtime credentials. Always pass
--root-directory. Without it, or with a root that is the directory the binary sits in, the server refuses to start with exit code 2, because an upgrade replaces that directory. A successful start logsPostgres connection successfulonce per database, then the loaded services and the bound endpoints. A missingscripts/init.luais a warning and starts an empty scripting environment; a bootstrap script that exists but fails prevents startup. Add scripts with Writing Lua scripts. The interactive console commands are listed in Server commands. -
Create your first account. In the running Login/Standalone server terminal, press
*to unlock the local console. This console is treated as an Administrator, so it can create an account before anyone has logged in. Save a new password in Bitwarden, then type this in the server console, replacing both placeholders (do not type it into your operating-system shell):account create <username> <password> RegularPasswords cannot contain spaces. The console masks the password and excludes this line from its history. Expect confirmation naming the account and
Regular; see account for staff levels and availability. API access is not required for game login. For staff content tutorials, chooseGameMasterinstead ofRegularwhen creating the account. -
Connect and enter the world. Follow Connect with ClassicUO below while the server stays running. Confirm you can create a character and walk before stopping it.
-
Stop it. Press Ctrl+C and let shutdown finish. After a successful startup the host runs a final world save before closing PostgreSQL persistence. A failed startup or a faulted game loop cannot promise that save. Do not terminate the process while it is waiting for one. The first save after a start writes the whole world and takes some seconds (8 to 15 for the shipped one); the server waits up to 2 minutes for it after Ctrl+C or SIGTERM. A save cut half way commits nothing: what happened since the last completed save is lost.
Connect with ClassicUO
Section titled “Connect with ClassicUO”On the computer where you will play, install ClassicUO
and your own complete UO client data. Use the ClassicUO launcher’s Edit Profiles →
Add (or Create your first profile) and create a profile named Moongate.
The official profile instructions
explain where these fields appear.
| Profile field | Value for the first local connection |
|---|---|
| Server IP | 127.0.0.1 when client and server run on the same computer |
| Server port | 2593, the login listener, not the game port |
| UO directory | Absolute path to the UO data on the client computer |
| Client version | The 7.x version of those UO files, as shown by their client.exe; do not invent a version |
| Encryption | Disabled (0), matching Moongate’s default |
Leave saved credentials and automatic login disabled; enter the account from step 6
at the login screen. If you launch the client directly, these are the corresponding
fields to merge into its existing settings.json while ClassicUO is closed:
{ "ip": "127.0.0.1", "port": 2593, "ultimaonlinedirectory": "/absolute/path/to/your/ultima-client", "clientversion": "7.0.50.1", "encryption": 0, "saveaccount": false, "autologin": false}7.0.50.1 is an example: replace it with your data’s actual version. On Windows,
use your client path with JSON-escaped backslashes. ClassicUO documents these options
in its launch arguments;
-settings can select a separate settings file for this shard.
- Launch the profile, enter your account name and password, and connect.
- Select the advertised shard. A default standalone lists
Moongate(or your configured shard name); the Docker recipe lists its two realms. Login redirects the client to the selected game’s port. - Select an empty character slot, complete character creation and choose a starting city. Existing characters can be selected instead.
- Wait for the world to appear and move your character. Seeing the character and walking confirms the login, redirect and world entry succeeded.
For a remote connection, put the server’s reachable DNS name or IPv4 address in
the profile instead of 127.0.0.1. Before starting Moongate, set
realm_directory.advertised_address to a client-reachable IPv4 address and
advertised_port to the reachable game port (2595 for this standalone example).
The standalone default advertises loopback, which works only for a client on the
same computer. Bind the server to a suitable local interface with
network.listen_address, and allow/forward both login TCP 2593 and game TCP 2595
through the firewall/router. In Docker, publish those ports and use the host-facing
game port in the advertisement. A reachable login port alone cannot complete world
entry. See Configuration and the
Docker topology.
If login succeeds but the shard is absent, inspect the game startup and Redis lease. If choosing a shard fails to connect, check its advertised address and game port. For client-data and startup errors, use Common startup problems.
Optional administration certificate
Section titled “Optional administration certificate”To also enable optional administration with a self-signed TLS certificate, use:
mgctl init /srv/moongate --generate-admin-certificate \ --admin-certificate-hosts "login.example.test"Replace the example host with the DNS name or IP address your admin client uses;
omit --admin-certificate-hosts for localhost only. This creates
certificates/admin.pfx and public certificates/admin.crt, and explicitly
updates four [admin_api] settings. The default bind stays 127.0.0.1:2590.
See certificate setup for
client trust, private-network access and reuse of an existing identity.
Older releases
Section titled “Older releases”mgctl ships in releases after 0.11.0; releases 0.7 to 0.11 have mgboot <root>
instead. On 0.6.0, start the server once instead:
it writes the configuration and exits. Nothing else is needed, because on 0.6.0
both the server and the migration runner read the core SQL from
/opt/moongate/migrations, beside the executable:
moongate --root-directory /srv/moongateSource build helper
Section titled “Source build helper”Once the root is configured, scripts/run_server.sh does the build and the start in
one step: it publishes a Release build of the server and mgctl
into dist/moongate, runs mgctl init on the root to add the shipped files it lacks, and
starts the server on it:
scripts/run_server.sh --root-directory "$HOME/moongate"--skip-build starts the build already in dist/moongate, --build-only publishes
without starting, and every other option goes to the server as it is, for example
--pid-file-name game.pid.
Without --root-directory the script uses MOONGATE_ROOT. Each build deletes
dist/moongate first, so keep nothing of your own in it. The script exports
MOONGATE_ROOT for a configuration that names its paths through ${MOONGATE_ROOT};
a MOONGATE_ROOT already set is kept as it is, also when --root-directory names
another root, so unset it or pass the same path.
Additional instances
Section titled “Additional instances”To run another standalone instance, give it its own root, distinct login and
game listener ports, a distinct realm_directory.realm_id and server_index,
and its own realm database. Reusing the default realm ID and index replaces the
first instance’s Redis lease. Never point two servers at one root or at one
realm database.
Files and process ownership
Section titled “Files and process ownership”All server-managed paths below are relative to --root-directory:
| Path | Purpose |
|---|---|
config/moongate.toml |
Created if missing; normal startup preserves it, while explicit certificate setup updates four [admin_api] settings |
certificates/admin.pfx, certificates/admin.crt |
Optional mgctl administration TLS identity: private server PFX and public PEM for client trust |
migrations/auth/, migrations/world/ |
Core SQL copied by mgctl init; plugins ship their own under plugins/ |
data/ |
Shard data files copied by mgctl, read at game and standalone startup; see Shard data files |
templates/items/, loots/, carves/, mobiles/, npc_lists/, spawns/, decorations/, gumps/, books/, shops/ |
Templates copied by mgctl, loaded at game and standalone startup |
logs/moongate-*.clef |
Structured JSON log events, one per line |
logs/errors/<id>.md |
The report of each exception the server logged, ready to paste into an issue |
plugins/ |
One assembly bundle per plugin directory; install and manage them with mgctl plugin |
scripts/ |
Lua source: an optional init.lua, the mobile scripts mobiles/<script_id>.lua, the item scripts items/<script_id>.lua, the Lua modules they share in common/, the gump scripts gumps/<id>.lua, the seasonal-event scripts events/<id>.lua, the skill scripts skills/<id>.lua, and the generated definitions.lua and .luarc.json |
moongate.pid |
Current process identifier |
moongate.pid.lock |
Lock file used to exclude another instance |
The PID guard is acquired before configuration is loaded. A live PID or an
already-held lock rejects another start. A stale or malformed PID is replaced.
The guard checks process liveness, not executable identity, so a reused PID can
also reject startup. Investigate that process before changing the PID file.
Normal cleanup removes the PID file if it still belongs to this process; the
.lock file may remain after its handle is released. Its presence alone does
not mean the server is running.
Console logs show time, level, source and message. File logs roll daily and at 10 MiB, keeping up to 30 files. For metrics see Diagnostics; for schema operations and world saves see PostgreSQL persistence.
When something fails
Section titled “When something fails”The console shows an exception as one line, its message and its report, never the stack:
12:04:31.552 ERR BankService | Opening the bank failed: the bank box is missing - details: /srv/moongate/logs/errors/7f3a9c21aa.md (paste it into an issue)The report holds the Moongate version and codename, the system, the .NET runtime, the time, the
source, the level, the message and the whole exception, with its inner exceptions and stacks, in Markdown. Open it and
paste it into an issue. Its
name hashes the exception, so the same one logged again, say by a timer, reuses its report, which
describes the first time it was seen. After 500 reports no new one is written, so exceptions whose
messages vary cannot fill the disk; delete the old ones to make room. In Docker the path is the
one inside the container, under the mounted root. A wrapper such as an AggregateException shows
the message of what it wraps. The .clef logs keep the full exception of every event.
Common startup problems
Section titled “Common startup problems”| Symptom | Check |
|---|---|
Exits right after writing config/moongate.toml |
Expected on a fresh root: ultima_path is still ChangeMe. Continue with step 2 |
| Client path error | Set ultima.ultima_path to readable, real client data |
Map ... needs map{n}.mul or map{n}LegacyMUL.uop, staidx{n}.mul and statics{n}.mul |
The client lacks that map. Use a complete client, or remove the map from data/maps.toml |
The Ultima path has neither MultiCollection.uop nor multi.idx and multi.mul |
The client directory is incomplete. Point ultima.ultima_path at a full client installation |
tiledata.mul not found in the Ultima path |
The client directory is incomplete or is not a client directory. Point ultima.ultima_path at a full client installation |
| TOML parse or validation error | Fix the named field; existing files are not silently replaced |
Postgres connection failure |
The database does not exist, the host is wrong, or the role cannot log in. Inside a container, localhost is the container itself |
| Connection variable missing | Export the PostgreSQL and Redis variables referenced by the active TOML sections |
| Redis connection failure | Check the private endpoint, credential, Redis health and noeviction policy |
| Pending or changed migrations | Run the migration runner status and apply for the named target (step 4), or turn on persistence.auto_apply_migrations. Never edit an applied file |
| PostgreSQL schema changes required | An entity needs DDL that no migration provides. Generate and review a versioned SQL file with --persistence-schema generate, then apply it with the runner while the server is stopped |
| Port binding failure | Check network.listen_address, port availability and interface addresses |
| Another instance detected | Check the PID and running process; use a separate root for another server |
... file ... not found for a data file, such as maps.toml |
The root has no data/. Run mgctl on the root again: it adds the missing files and keeps the others |
InvalidDataException naming a data file |
Fix the entry the message names; see the validation rules in Shard data files |
| Script startup error | Fix scripts/init.lua; inspect the script filename and line in the log |
Made with ❤️ by Tom
