Skip to content

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.

  • 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_SESSION set in this shell. bw status must report unlocked; 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.json only sets rollForward to latestPatch and refuses prerelease SDKs; it pins no version). Node.js is only needed to work on the documentation website.

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 mgctl and moongate from any directory with the installed commands on PATH.

  • Source checkout: run the following from the directory where you keep projects, then stay at the repository root for all dotnet commands on this page:

    Terminal window
    git clone https://codeberg.org/moongate/moongate.git
    cd moongate
    git switch develop
    dotnet build Moongate.slnx -c Release

    develop includes 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.

  1. Prepare the root. Give the server a directory of its own:

    Terminal window
    sudo mkdir -p /srv/moongate && sudo chown "$USER" /srv/moongate
    mgctl init /srv/moongate

    For 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/moongate

    This writes config/moongate.toml with the defaults, creates logs/ and plugins/, copies the release’s core SQL into migrations/, its shard data files into data/, its templates into templates/ and its example scripts into scripts/. It needs no database and no client files. Prepare a root with mgctl describes what happens on a root that already exists.

  2. Edit the configuration. Open /srv/moongate/config/moongate.toml and 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.

  3. 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.

  4. 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 -e
    MOONGATE_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_DATABASE
    mgctl migrate apply --root-directory /srv/moongate --target auth
    mgctl migrate apply --root-directory /srv/moongate --target world
    )

    For a source checkout, use this subshell from the repository root instead:

    Terminal window
    (
    set -e
    MOONGATE_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_DATABASE
    dotnet run --project src/Moongate.Ctl -c Release -- migrate apply --root-directory /srv/moongate --target auth --migrations-directory ./migrations
    dotnet 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 auth reads [persistence.accounts], --target world reads [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-schema USAGE, table SELECT, INSERT, UPDATE, DELETE, and sequence USAGE and SELECT. Grant only USAGE and history-table SELECT in moongate_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 status instead of apply to inspect pending files. Each successful apply reports the number applied: Applied {count} migration(s) to {target}. Optional Apply at startup needs DDL privileges; keep it disabled for this runtime-role sequence.

  5. Start the server.

    Terminal window
    moongate --root-directory /srv/moongate

    Or, from the source repository root:

    Terminal window
    dotnet run --project src/Moongate.Server -c Release -- --root-directory /srv/moongate

    Both 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 logs Postgres connection successful once per database, then the loaded services and the bound endpoints. A missing scripts/init.lua is 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.

  6. 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> Regular

    Passwords 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, choose GameMaster instead of Regular when creating the account.

  7. 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.

  8. 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.

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.

  1. Launch the profile, enter your account name and password, and connect.
  2. 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.
  3. Select an empty character slot, complete character creation and choose a starting city. Existing characters can be selected instead.
  4. 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.

To also enable optional administration with a self-signed TLS certificate, use:

Terminal window
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.

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:

Terminal window
moongate --root-directory /srv/moongate

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:

Terminal window
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.

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.

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.

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.

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

Buy me a coffee