Skip to content

Docker login and realms

Connect ClassicUO to login on 127.0.0.1:2593 after completing the steps below; choose either realm and create a character to confirm world entry.

The Compose example builds the current source and runs one login process, two game processes, PostgreSQL 16 and private Redis 7.4. PostgreSQL holds separate Accounts, Realm 1 and Realm 2 databases. Redis holds short-lived realm leases and one-use login handoff tickets. Nothing in the example publishes Redis to the host.

Use Docker Engine or Docker Desktop with Compose v2, Git, your own readable Ultima Online client directory, and a Unix shell such as Bash or Zsh. The export and $(...) examples below do not use PowerShell syntax. Install the Bitwarden CLI, configure it for your vault, sign in and unlock it with BW_SESSION set in the same shell that will run Compose. Check the CLI and vault state without printing stored secrets:

Terminal window
command -v bw >/dev/null
bw status

Continue when the status is unlocked. Follow the linked CLI instructions if it is locked or unauthenticated. Do not enable shell tracing when exporting secrets. Start from a checkout of this repository, at its root:

Terminal window
cd examples/docker/login-realms
cp .env.example .env

Set UO_DATA_PATH in .env to an absolute host directory. Change host-facing ports or non-secret PostgreSQL names there if required. If you change GAME_1_PORT or GAME_2_PORT, set the matching realm_directory.advertised_port in config/game-1.toml or config/game-2.toml to that host port; update the corresponding *-admin.toml as well if you use the administration override. Otherwise the game redirect sends clients to the old port. Keep passwords and handoff keys out of .env, TOML and the repository.

Create nine distinct secret records in Bitwarden: seven PostgreSQL passwords, a 64-character hexadecimal Redis password and an independent hexadecimal handoff secret of at least 64 characters. Export them in the shell that runs Compose, substituting your actual Bitwarden item names:

Terminal window
export MOONGATE_POSTGRES_ADMIN_PASSWORD="$(bw get password moongate-postgres-admin)"
export MOONGATE_ACCOUNTS_SCHEMA_PASSWORD="$(bw get password moongate-accounts-schema)"
export MOONGATE_ACCOUNTS_RUNTIME_PASSWORD="$(bw get password moongate-accounts-runtime)"
export MOONGATE_REALM_1_SCHEMA_PASSWORD="$(bw get password moongate-realm-1-schema)"
export MOONGATE_REALM_1_RUNTIME_PASSWORD="$(bw get password moongate-realm-1-runtime)"
export MOONGATE_REALM_2_SCHEMA_PASSWORD="$(bw get password moongate-realm-2-schema)"
export MOONGATE_REALM_2_RUNTIME_PASSWORD="$(bw get password moongate-realm-2-runtime)"
export MOONGATE_REDIS_PASSWORD="$(bw get password moongate-redis)"
export MOONGATE_HANDOFF_SECRET="$(bw get password moongate-handoff)"

Compose mounts these values as secrets. The server entrypoint, which ends in mgctl run, constructs MOONGATE_REDIS_CONNECTION_STRING=redis:6379,password=... and exports MOONGATE_HANDOFF_SECRET only in the runtime process environment. The mounted TOMLs contain references to those variable names, not their values. Redis reads its password from its own secret mount into a private in-memory config file. The PostgreSQL connection URIs are likewise assembled in memory from role-specific secrets. Before starting the server, the entrypoint runs mgctl on the volume, which adds any shard data file the image ships and the volume lacks, keeping the ones already there.

Validate without printing the rendered Compose model, then build:

Terminal window
docker compose config --quiet
docker compose build login game-1 game-2 auth-schema-apply schema-preview schema-apply schema-apply-realm-2 migration-status

Missing secret inputs fail validation by name. Use --quiet: rendering the expanded model can expose values from Compose’s early-validation extension.

The PostgreSQL init script creates six roles and three databases on the first empty volume. Schema roles own DDL; runtime roles have DML and required sequence access only. Initialization does not rerun when a container is recreated. Never run docker compose down --volumes on data you intend to keep.

Apply the built-in Accounts SQL before login starts:

Terminal window
docker compose up -d --wait postgres redis
docker compose --profile schema run --rm auth-schema-apply

Review and apply the World SQL for Realm 1, the core world tables plus the sample plugin’s, then the core world tables for Realm 2:

Terminal window
docker compose --profile schema run --rm migration-status
docker compose --profile schema run --rm schema-preview
docker compose --profile schema run --rm schema-apply
docker compose --profile schema run --rm migration-status
docker compose --profile schema run --rm schema-apply-realm-2

Every game server needs its World SQL applied before it starts; a pending migration stops startup. The init script creates the world schema in both Realm databases and gives each runtime role DML on its tables and use of its ID sequences. A volume initialized before the core world tables existed lacks those grants: run the provision_world_schema statements of postgres/init.sh for each Realm database by hand, or recreate the disposable volume.

The schema jobs do not start a Redis client. They use their selected schema-role PostgreSQL secret and run only the requested target. The runtime TOMLs set auto_sync_schema = false and use core SQL bundled at /app/migrations. For more on authoring and applying plugin SQL, see Generate, review and apply.

Terminal window
docker compose up -d login game-1 game-2
docker compose ps
docker compose logs --tail 100 login game-1 game-2

Create an account in the login console (not your host shell or a game console):

Terminal window
docker compose attach login

Press * to unlock commands. Save a password in Bitwarden and type this, replacing the placeholders; passwords cannot contain spaces:

account create <username> <password> Regular

Expect confirmation naming the new account and Regular. The local console has Administrator permissions; game-only containers do not register account. Detach with Docker’s Ctrl-P, Ctrl-Q sequence so login keeps running.

On the Docker host, create the ClassicUO profile with IP 127.0.0.1, login port 2593, your client data/version, and encryption disabled. Enter the account, select Moongate Realm 1 or Moongate Realm 2, create a character and walk in the world. For remote play, set the profile’s server address and both games’ advertised IPv4 addresses as described in Topology, and make host ports 2593, 2595 and 2596 reachable. Use changed host ports if you customized .env. If the realm list is empty, check the logs and leases below.

The three runtime services wait for PostgreSQL and Redis health. Startup checks the role’s database and Redis connection. Game processes publish moongate:realm:1 and moongate:realm:2, each with a 15-second lease renewed every five seconds. To inspect them without displaying the Redis password:

Terminal window
docker compose exec -T redis sh -ec 'export REDISCLI_AUTH="$(cat /run/secrets/redis-password)"; redis-cli --raw HGETALL moongate:realm:1; redis-cli TTL moongate:realm:1'

The keys should have positive TTLs. Do not use FLUSHDB: Redis may hold other short-lived Moongate state. The example’s smoke script checks both leases beyond one full lease interval.

Each /data volume contains that process’s PID, logs, scripts and plugins; do not share one root between servers. Stop or recreate one game independently:

Terminal window
docker compose stop game-1
docker compose up -d --no-build --force-recreate game-1

docker compose down removes containers and the private bridge but retains named volumes. World saves are not PostgreSQL backups; set your database backup policy separately.

Service Database Host client endpoint Redis access
login Accounts runtime only 127.0.0.1:2593 Leases and handoff tickets
game-1 Realm 1 runtime only 127.0.0.1:2595 Its lease and handoff tickets
game-2 Realm 2 runtime only 127.0.0.1:2596 Its lease and handoff tickets
redis None No published port Private moongate bridge only
Schema profile jobs Only their selected schema target None None

Each server has its own /data volume. PostgreSQL has a separate persistent volume. Redis has no persistent volume: tickets and leases are deliberately ephemeral. A Redis restart invalidates both; running game processes republish their leases. A Redis outage prevents new realm lists and handoffs, while already admitted game sessions continue. Redis uses maxmemory-policy noeviction, so memory pressure rejects writes instead of silently removing live tickets.

game-1 uses the Dockerfile’s sample-plugin stage. Its plugin registers sample_greeter.notes in Realm 1; the schema jobs exercise the same plugin and reviewed SQL. game-2 uses the ordinary image. Login has no Realm database credential, and games have no Accounts credential. All three share one Redis credential and a separate handoff secret; these servers are therefore one private trust domain. No peer certificate or per-realm ACL is needed.

The UDP ping server answers on host port 12000 (PING_PORT) from game-1 only: the three servers share one host address, and one process per address can own the port, so login and game-2 set enable_ping_server = false.

Inside the containers, login listens on port 2593 and each game listens on 2595. Compose maps game 2 to host port 2596, which its realm_directory.advertised_port also declares. The default advertised IPv4 address is 127.0.0.1 for a client on the Docker host. For remote clients, set MOONGATE_BIND_ADDRESS in .env to a host interface IPv4 address (or 0.0.0.0 to publish on all interfaces); the default 127.0.0.1 accepts only local clients. Set advertised_address in both game TOMLs to an IPv4 address reachable from the client. Allow or forward the published login and game ports through the host firewall/router. Set the same address in game-1-admin.toml and game-2-admin.toml if you use the administration override. The 0xA8 list has IPv4 addresses but no ports; 0x8C supplies the selected realm’s port.

A successful 0x80 login receives a filtered 0xA8 list. 0xA0 selects a realm; login sends 0x8C with its IPv4 address, port and one-use key, then closes that login connection. The client reconnects to the chosen game port, sends the raw four-byte key as its seed, then 0x91 with the same key and credentials. The game consumes the ticket from Redis, associates the account with its local session and replies with the supported features (0xB9) and the account’s saved characters (0xA9) with the starting cities. Character creation saves a new character and its starting items; selecting a character brings it into that realm’s world.

From the repository root:

Terminal window
MOONGATE_SMOKE_UO_PATH=/path/to/ultima-client sh examples/docker/login-realms/smoke.sh

Game servers load tiledata.mul, the maps and the multis at startup, so the script needs a real client directory; it stops with exit code 2 when MOONGATE_SMOKE_UO_PATH has no tiledata.mul. The script creates a unique Compose project with temporary volumes and synthetic process-only credentials. It builds local images, checks PostgreSQL roles and schema behavior, applies the World SQL to both realms and checks their runtime roles can write world items, starts all three hosts, verifies that both Redis leases have positive TTLs after a full lease interval, and checks clean shutdown. The UO 0x80→0x91 handoff and ticket replay are covered by the Redis-backed integration tests in the .NET suite.

  • Compose says a variable is missing: export the named value from Bitwarden in the same shell, or set UO_DATA_PATH in .env.
  • Redis health fails: inspect docker compose logs redis, the secret mount and the container’s private-network address. Redis has no host port to probe.
  • Login has no realms: inspect docker compose logs game-1 game-2 and the moongate:realm:* keys and TTLs. Verify that all three runtime containers share the same Redis service and handoff secret.
  • Startup reports pending SQL: run the matching schema profile job while the affected runtime is stopped.
  • Client cannot enter the selected game: confirm advertised_address is reachable from the client, the host port matches advertised_port, and the redirect key has not expired or been used.
  • Container exits: inspect docker compose ps -a and its logs. Correct the database, Redis or configuration error before recreating it.

Port 2590 is reserved for optional gRPC administration and remains disabled in the default image/Compose configuration. Use server TLS on the private network; no mTLS or public port mapping is required. See the administration guide and opt-in Compose configuration.

Made with ❤️ by Tom

Buy me a coffee