Skip to content

Run with Docker

Moongate publishes Linux images to Docker Hub. Use the documentation for the version you run; the changelog identifies published releases. For the complete first-login deployment, start with the Recommended Compose example below. To run the current source, use a checkout of this repository and build from its root:

Terminal window
docker build -f src/Moongate.Server/Dockerfile -t moongate:local .

The image runs as a non-root user with MOONGATE_ROOT=/data. Mount a persistent writable volume there and mount your own Ultima Online client files read-only; client files are not distributed with Moongate. It ships mgctl, which prepares the root and applies the migrations, the core SQL, the shard data files, the templates and the example scripts. The sample-plugin build target adds the sample plugin bundle.

The login and two game instances example is a complete build-from-source deployment with:

  • A login process on UO TCP port 2593 and two game processes on host ports 2595 and 2596.
  • Separate Accounts, Realm 1 and Realm 2 PostgreSQL databases and schema/runtime roles.
  • One private Redis service for live realm leases and one-use login handoff tickets.
  • Bitwarden-sourced Compose secrets, schema jobs, persistent server/PostgreSQL volumes and a disposable smoke test.

Follow that guide for the exact .env, secret exports, build, SQL application and startup commands. Redis is not published to the host. There is no internal API listener or certificate exchange in this topology.

mode = "standalone" runs both UO roles in one process, with separate login and game listeners. Publish both configured ports. Standalone also needs reachable Accounts and Realm PostgreSQL databases and the shared Redis service; it advertises its own local realm through Redis. Its root TOML includes:

mode = "standalone"
[network]
login_port = 2593
game_port = 2595
[ultima]
ultima_path = "/uo"
[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"

Supply the four referenced variables from a secret provider in the container environment. PostgreSQL variables are postgres:// URIs for role-specific databases. The Redis variable is a StackExchange.Redis connection string such as redis:6379,password=<secret> on a private Docker network; the handoff secret is a different value. Do not commit either value into TOML, Compose or .env. The configuration reference covers the other settings.

Prepare a root before starting (this also copies the shard data files, templates and example scripts into /data/data, /data/templates and /data/scripts), then apply Auth and World SQL to their respective databases:

Terminal window
docker volume create moongate-data
docker run --rm --entrypoint /app/mgctl -v moongate-data:/data moongate:local init /data

Copy the generated configuration out of the volume to a non-secret host file:

Terminal window
docker create --name moongate-config -v moongate-data:/data moongate:local
docker cp moongate-config:/data/config/moongate.toml ./moongate.toml
docker rm moongate-config

Edit ./moongate.toml, keeping its generated sections and applying the settings above. Keep your shell at this directory: the following runs mount this exact file read-only over /data/config/moongate.toml. Set the absolute host client path:

Terminal window
export UO_DATA_PATH="/absolute/path/to/your/ultima-client"

Provision both databases, schema/runtime roles and private Redis first. Follow First start steps 2–4 for the Bitwarden prerequisite, item names, runtime-variable exports and grants. The URIs and Redis address must be reachable from the container; localhost would refer to the container itself. With runtime credentials loaded in the parent shell, use schema credentials only in this migration 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
docker run --rm --entrypoint /app/mgctl -v moongate-data:/data \
-v "$(pwd)/moongate.toml:/data/config/moongate.toml:ro" \
-e MOONGATE_ACCOUNTS_DATABASE moongate:local migrate apply --root-directory /data --target auth
docker run --rm --entrypoint /app/mgctl -v moongate-data:/data \
-v "$(pwd)/moongate.toml:/data/config/moongate.toml:ro" \
-e MOONGATE_REALM_DATABASE moongate:local migrate apply --root-directory /data --target world
)

Continue only when both applies succeed and the runtime grants are in place. Start from the parent shell, which still holds only the runtime database URIs:

Terminal window
docker run --rm -it --name moongate-standalone --stop-timeout 120 \
-p 2593:2593 -p 2595:2595 \
-v moongate-data:/data \
-v "$(pwd)/moongate.toml:/data/config/moongate.toml:ro" \
-v "$UO_DATA_PATH:/uo:ro" \
-e MOONGATE_ACCOUNTS_DATABASE -e MOONGATE_REALM_DATABASE \
-e MOONGATE_REDIS_CONNECTION_STRING -e MOONGATE_HANDOFF_SECRET \
moongate:local --root-directory /data

-it enables the interactive local console. Wait for successful PostgreSQL and Redis checks and the login/game listeners. Press * and create your first account, then follow Connect with ClassicUO, using login port 2593. See First start for account syntax and the world-entry checkpoint. Press Ctrl+C and allow the final save to finish; from another terminal, docker stop -t 120 moongate-standalone also stops it normally.

For subsequent migrations, stop this runtime before applying reviewed SQL. A missing database, Redis connection or required migration fails startup; the server does not create databases or apply unreviewed SQL automatically. See Generate, review and apply.

The current image declares UO client ports 2593 and 2595, and UDP port 12000 for the ping server. Publish 12000/udp only for the process whose address the clients ping; two processes behind one host address cannot both use it. EXPOSE does not publish a host port; configure ports for the login and each game listener that clients must reach. Keep Redis and PostgreSQL on a private network. Each running Moongate process needs its own /data volume; each realm needs its own Realm database. Do not share one root or Realm database between running game processes.

After pulling a newer image, start it: its entrypoint, mgctl run, prepares the root as mgctl init does before every start, so it adds the data files and core SQL the new release introduces and keeps the files already in the root (pass --no-init to skip that). Running mgctl on the volume by hand, as above, does the same without starting the server. The Compose example’s entrypoint goes through the same launcher.

docker compose down preserves named volumes. Adding --volumes deletes server roots and PostgreSQL data; use it only for a disposable environment. World saves are not PostgreSQL backups. Stop services normally so the final world save can complete: Docker kills a container 10 seconds after asking it to stop, and the first save after a start takes 8 to 15 seconds for the shipped world, so give the server more time with stop_grace_period: 2m in the Compose file or docker stop -t 120.

To update an instance, read the target release’s changelog, stop it, follow your database backup policy, change the image tag, and restart with the same volumes. If startup reports pending migrations, stop the affected server and apply the reviewed SQL before starting it again. Volumes and databases made before the catalogs became one file each need Moving to the one-file catalogs first.

With persistence.auto_apply_migrations = true in config/moongate.toml the server does both steps at startup: it adds the core SQL the new image ships to /data/migrations and applies what is pending, so an update is a new image tag and a restart. See Apply at startup for what it needs and what still stops it; the data files, templates and scripts a release adds still come from mgctl init.

The image’s entrypoint is mgctl run, which starts mgserver as a child and starts it again after the restart command (exit code 75) or a crash. docker run image <server options> works as before: the options reach mgserver unchanged.

  • A restart stays in the container. The container, its volumes, its network and its pod do not change; only the server process does. A server that fails five times in a row, each within a minute, ends the container with exit code 1, and the restart policy of Docker or Kubernetes takes over as before.
  • Stopping. docker stop and a pod deletion send SIGTERM to the launcher, which passes it to the server and waits for the final world save. The grace period (stop_grace_period, docker stop -t, terminationGracePeriodSeconds) must cover that save: two minutes at most.
  • The root is prepared at every start. The launcher runs mgctl init on /data before each start and prints nothing when it works; when it fails (for instance a volume whose migrations conflict with the bundled ones) it prints what it said and the container exits with code 1. docker run image --no-init skips it.
  • Help and version. docker run --rm image --help and --version now answer for the launcher. The server’s own are docker run --rm --entrypoint /app/mgserver image --help.
  • One-shot modes. --initialize-root and --persistence-schema of the server are run with --entrypoint /app/mgserver, or through mgctl init and mgctl migrate as above: behind the launcher a failing one is tried a few more times before it gives up.

The Dockerfile restores dependencies before copying source files, then publishes each bundled executable with --no-restore. Restore and publish use the same configuration, target architecture and self-contained setting. There is no separate server build before publication.

The build context includes source, core migrations, the shard data files, templates and example scripts (moongate_root/data, templates, scripts, without the generated definitions.lua and .luarc.json), build settings, licenses and the sample plugin. Documentation, tests, the website, local bin/obj directories and environment files are excluded. Sample plugin source is copied only into its own build target. When adding a new image input, update .dockerignore and the relevant COPY instructions together.

The development and release workflows build on runners whose Docker daemon keeps the layers, so no build cache is published to a registry. NuGet packages remain in the restore layer so a rebuild recovers them from that cache. A first build without a cache still works normally.

For a local build:

Terminal window
docker buildx build --load -f src/Moongate.Server/Dockerfile -t moongate:local .

Repeat the build with --progress=plain to inspect cached steps. Documentation-only changes should leave restore and publish layers cached. Use --pull to check for updated .NET base images, or --no-cache to rebuild all steps. Published images remain Linux amd64.

The image does not convert content: the converters are a Python tool, moongate-convert, that runs from a checkout of the repository (it needs uv and Python 3.13 or later, and no .NET). Run it on the host, with the UOX3 data in a folder you can read, and write the output where the container’s /data volume is bind-mounted, or copy it in afterwards:

Terminal window
cd tools/convert
uv run moongate-convert uox \
--source /path/to/uox3/data/dfndata/items \
--destination /path/to/templates/items --loot-destination /path/to/templates/loots

See Migrate from UOX3 for what the converter does and its current limits.

  • Database connection fails: localhost inside a container means that container. Use the reachable service name or private-network address and confirm the role has access to its target database.
  • Redis connection fails: verify the private service, its health, the shared credential and noeviction policy. A Redis restart clears tickets and leases; games republish leases after reconnect.
  • Realm absent from login: check the game logs and Redis lease TTLs as shown in the Compose guide.
  • Client redirect cannot connect: the game’s advertised IPv4 address and port must be reachable from the client and match the published host mapping.
  • Missing client data or unwritable root: check the /uo read-only mount and /data ownership for the image’s non-root user.

See First start, Configuration and Diagnostics for server-level operation.

The Helm chart runs the same login and realms topology on Kubernetes and is published to the Helm registry of the Codeberg organization with each release.

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.

For optional administration TLS in a standalone root, append --generate-admin-certificate after /data, with --admin-certificate-hosts naming the DNS/IP used by clients. See mgctl certificate setup for the generated files and client trust. The default bind remains loopback; configure a private interface before connecting from another container. The multi-process Compose override instead uses its mounted TOMLs and operator-provided certificates, as described in the example README.

If you use the host configuration file above, copy or merge the generated [admin_api] changes into it before restarting; the read-only mount takes precedence over the configuration stored in the volume.

Made with ❤️ by Tom

Buy me a coffee