Run with Docker
Each Moongate release publishes a linux/amd64 image to
GitHub Container Registry.
Images are tagged with the version (without v) and with latest. The examples
below pin 0.6.0; choose the version you want from the changelog
or GitHub releases.
The site header displays the current version.
You need Docker with Compose v2 and your own Ultima Online client files; the image does not include them. The server also needs two PostgreSQL databases, which the image does not bundle either: the Compose file below adds one. TCP port 2593 is the game listener; 2594/tcp is declared for the optional internal API, which is disabled by default.
The image sets MOONGATE_ROOT=/data and runs as the .NET image’s non-root user.
Beside the server it ships the core SQL at /app/migrations and the migration runner
at /app/migration-runner/Moongate.MigrationRunner. Images after 0.6.0 also ship
/app/mgboot and /app/mg-uoxconv.
First start
Section titled “First start”These are the steps of Start a Moongate server, run through Compose. Save the two files in an empty directory, replacing the client path:
services: postgres: image: postgres:16 environment: POSTGRES_USER: moongate POSTGRES_PASSWORD: moongate POSTGRES_DB: auth volumes: - postgres-data:/var/lib/postgresql/data - ./create-world.sql:/docker-entrypoint-initdb.d/create-world.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U moongate -d auth"] interval: 5s timeout: 3s retries: 10
moongate: image: ghcr.io/moongate-community/moongate:0.6.0 depends_on: postgres: condition: service_healthy ports: - "2593:2593" volumes: - moongate-data:/data - /absolute/path/to/ultima:/uo:ro
volumes: moongate-data: postgres-data:-- create-world.sqlCREATE DATABASE world OWNER moongate;The PostgreSQL image creates the auth database and the moongate role from its
environment and runs create-world.sql once, on an empty postgres-data volume.
These are development credentials; for anything else, see
PostgreSQL persistence below.
-
Prepare the root. Start the stack once:
Terminal window docker compose up -dPostgreSQL stays up. The server writes
/data/config/moongate.tomlinto themoongate-datavolume and exits, becauseultima_pathstill holds its placeholder. Images after 0.6.0 can do this without a server start:docker compose run --rm --entrypoint /app/mgboot moongate /data. -
Edit the configuration. Copy it out, edit it, copy it back:
Terminal window docker compose cp moongate:/data/config/moongate.toml ./moongate.toml[ultima]ultima_path = "/uo"[persistence.accounts]connection_string = "postgres://moongate:moongate@postgres:5432/auth"[persistence.realm]connection_string = "postgres://moongate:moongate@postgres:5432/world"The client path is inside the container:
/uois the mount above. The database host is the Compose service name;localhostinside the container is the container itself. On the 0.6.0 image the server and the migration runner read the core SQL from/app/migrations; a root prepared bymgboothas its own copy. Keep the other sections, then:Terminal window docker compose cp ./moongate.toml moongate:/data/config/moongate.toml -
Apply the core migrations. The server refuses to start while files are pending:
Terminal window docker compose run --rm --entrypoint /app/migration-runner/Moongate.MigrationRunner \moongate apply --root-directory /data --target authdocker compose run --rm --entrypoint /app/migration-runner/Moongate.MigrationRunner \moongate apply --root-directory /data --target world -
Start the server.
Terminal window docker compose up -ddocker compose logs --tail 100 -f moongateA successful start logs
Postgres connection successfultwice, then the bound endpoints. Stop the log viewer with Ctrl+C; it does not stop the container. Usedocker compose stopfor a normal shutdown anddocker compose startto resume.
Compose creates project-scoped named volumes. Keep the same project name and
directory when restarting the same world. docker compose down removes the
containers but retains the volumes; adding --volumes deletes the server root
and the databases.
Plain docker run
Section titled “Plain docker run”Without Compose, the same steps work against a PostgreSQL server you already run. Create persistent storage, then start once to write the configuration:
docker volume create moongate-datadocker run -d --name moongate \ --mount type=volume,source=moongate-data,target=/data \ --mount type=bind,source=/absolute/path/to/ultima,target=/uo,readonly \ -p 2593:2593 \ ghcr.io/moongate-community/moongate:0.6.0Edit the configuration as in step 2 above, with docker cp in place of
docker compose cp, and point both connection strings at a host the container can
reach. Apply the migrations with the same entrypoint through docker run --rm and
the same volume mount, then docker start moongate.
PostgreSQL persistence
Section titled “PostgreSQL persistence”The Compose file above uses the generated development credentials and one role for
everything. For a deployment, give the runtime a DML-only role, keep schema changes
on a separate role, and supply each connection_string as a $NAME environment
reference from your secret provider instead of a literal URI. Keep
auto_sync_schema = false. Run reviewed SQL with the migration runner, as in step
3, while the affected server is stopped. See
PostgreSQL persistence and the complete
one login and two game instances example, which builds
local images, separates schema and runtime roles and runs schema jobs as Compose
profiles.
Internal API port
Section titled “Internal API port”The image declares 2593/tcp and 2594/tcp. EXPOSE does not start a listener or
publish a host port. Enable [api] and configure certificates and peer permissions
with API host configuration.
For automatic certificate generation, follow API certificates:
keep the API disabled while creating identities and exchanging public PEM files,
and use a writable persistent volume. The read-only mount below is for
externally provisioned certificates.
Add to the moongate service of the Compose file:
environment: MOONGATE_API_CERTIFICATE_PASSWORD: "${MOONGATE_API_CERTIFICATE_PASSWORD:?Inject the PFX password into the shell environment}" volumes: - /absolute/path/to/api-certificates:/data/config/tls:roObtain the password from your credential provider into the invoking shell’s
environment. Do not write it into TOML or commit it in Compose or .env files. Keep
certificate_path = "tls/server.pfx" and trusted_root_paths = ["tls/root.pem"]
in /data/config/moongate.toml; these resolve under /data/config. The mounted
files must be readable by the image’s non-root user.
Clients on the same Compose network can connect to moongate:2594 without a
published API port. Provision the server certificate with the DNS name the client
uses as its TLS target host. A client on the Docker host can use a loopback-only
mapping added under ports:
- "127.0.0.1:2594:2594"For another private-network host, publish on the Docker host’s private IP instead.
Keep this internal channel on the private network. If you change api.port, use
that value as the mapping’s container port and the client’s destination port; the
Dockerfile’s EXPOSE metadata remains the default 2594.
Check docker compose logs moongate: disabled APIs produce an activation warning;
enabled APIs log the bound endpoint and registered contract/handler counts. Bad
TLS configuration or an occupied listener port fails startup. The API speaks
MessagePack over mutual TLS/TCP, so an HTTP request or curl is not an API probe.
There are no built-in login or realm operations yet; register handlers first.
Storage and multiple instances
Section titled “Storage and multiple instances”A new named volume inherits the writable ownership prepared by the image. If you replace it with a host bind mount, make that directory writable by the container user; changing the mount does not change host ownership automatically.
MOONGATE_ROOT and --root-directory can select another server root. If you change
it, mount persistent storage at that path too. For multiple instances, use a
separate data volume and a different published host port for each server. Do not
share one root between running servers, and give each realm its own database.
The mode setting does not yet select separate login and game runtimes; see
Implementation status.
Update an instance
Section titled “Update an instance”Read the target version’s changelog, stop the server, and follow
your operator data-protection policy before upgrading. Change the pinned image tag
in compose.yaml:
docker compose stopdocker compose pulldocker compose up -ddocker compose logs --tail 100 -f moongateThe named volumes are reused. With plain docker run, stop and remove the old
container, then recreate it with the new image tag and the same named data volume
and client mount. Removing a container does not delete its named volume. If the new
release ships new core SQL, startup reports pending migrations: apply them as in
first-start step 3 before starting the server.
Build the image from source
Section titled “Build the image from source”To run unreleased work, build the current checkout from the repository root:
docker build -f src/Moongate.Server/Dockerfile -t moongate:local .Use moongate:local in place of the registry tag. The sample-plugin build stage
adds the sample plugin bundle; the login and realms example
uses it.
UOX3 content conversion
Section titled “UOX3 content conversion”Images after 0.6.0 include /app/mg-uoxconv, the same tool as
src/Moongate.UoxItemConverter (see Migrate from UOX3),
published as a self-contained single file. Until that release, use an image
built from source. Run it as a one-shot job,
mounting a UOX3 .dfn source directory read-only and an output directory for the
converted TOML:
docker run --rm --entrypoint /app/mg-uoxconv \ --user "$(id -u):$(id -g)" \ -v /path/to/uox3/data/dfndata/items:/uox-source:ro \ -v /path/to/templates:/uox-out \ moongate:local \ --source /uox-source --destination /uox-out/items --loot-destination /uox-out/loots--user "$(id -u):$(id -g)" matters: the image otherwise runs as its own non-root
user, which cannot write into a bind-mounted host directory it does not own. The
/data root avoids the same mismatch because the image creates that directory
pre-owned by the runtime user at build time.
Troubleshooting
Section titled “Troubleshooting”- Exits on first start: expected once, before the configuration is edited. After that, check the logs for the configuration or client-data error.
Postgres connectionfailure: the database host must be reachable from the container.localhostis the container itself; use the Compose service name or a host address. Check that both databases exist and the role can log in.The core migrations directory is missing: on images after 0.6.0 the server reads/data/migrations, whichmgbootcreates. Prepare the root withmgbootas in step 1, or setpersistence.migrations_directory = "/app/migrations".- Pending migrations: run the migration runner as in step 3 for the named target.
- Cannot read client files: confirm the host path exists and is mounted at
/uo, with permission for the container user to read it. - Cannot write config or generated files: check
/datavolume ownership, especially for bind mounts. - Cannot connect: check
docker ps, the2593:2593mapping, the listener configuration, and the host firewall.
See Configuration for all TOML settings and CLI limits, First start for PID ownership, and Diagnostics for process metrics and diagnostic events.
