-
Notifications
You must be signed in to change notification settings - Fork 0
Deployment
Audience: Sysadmin Status: ✅ Ready
How to stand up a TelosMUD fleet: the container/service model, the published images, the port and firewall posture, transports and TLS, the fail-closed boot gates, and running as bare processes vs. under an orchestrator. This page is the source of truth for the gomud-side artifacts plus a self-host quickstart. For a full cloud IaC reference (Terraform + Kustomize + k3s, bare-metal or cloud) see the separate infra repo, cross-linked below — the wiki does not duplicate it.
For a fast local bring-up, see Running Locally; the OAuth production wiring has its own page, OAuth Setup.
There is one shared Dockerfile, and it builds one binary per image. The role is fixed at
build time by the SERVICE build arg (go build ./cmd/${SERVICE}), not chosen at runtime —
an image is its role. Setting TELOS_SERVICE at runtime only relabels logs/telemetry; it does
not change what the binary does.
The Dockerfile is a three-stage build: a proto stage regenerates the (gitignored) *.pb.go with a
pinned buf, a build stage cross-compiles a static CGO_ENABLED=0 binary for the target
os/arch, and the runtime stage is gcr.io/distroless/static-debian12:nonroot — minimal, static,
non-root.
Build tags matter for security. The default (empty) build is a release build: the
telos_devauth bypass is physically compiled out, so TELOS_DEV_AUTOAUTH has no effect on it.
The dev Compose file sets BUILD_TAGS: telos_devauth on the gate only; a production build sets no
tags.
Release images are published to GHCR, multi-arch (amd64 + arm64):
ghcr.io/double-nibble/telos-gate
ghcr.io/double-nibble/telos-world
ghcr.io/double-nibble/telos-account
ghcr.io/double-nibble/telos-migrate
ghcr.io/double-nibble/telos-seed
ghcr.io/double-nibble/telos-pull
Every published image is a release build — the telos_devauth bypass is absent. They are also built with the nofixture tag, which strips the embedded demo test fixture: a shipped service carries only the minimal core bootstrap pack, and the deployable world is pulled from the external content store into Postgres rather than seeded from an embedded copy. telos-pull is that puller. The one exception is telos-seed, which keeps the full embed on purpose, so it can still seed the demo for a local/compose stack (make seed).
telos-directorhas no published image. It is not in the release matrix. If you want the orchestration tier (dynamic rebalancing planning, scope broadcasts, scheduled spawns, mail reaping), you must build and publishtelos-directoryourself from the same Dockerfile (SERVICE=telos-director). A fleet runs without a director — placement and failover are decentralized (see Running at Scale) — so this only affects the optimizer tier.
Every listen address is a config key with a TELOS_* override. The critical column is
public vs. internal:
| Service | Port (default) | Env | Public / Internal |
|---|---|---|---|
| gate — plain telnet | :4000 |
TELOS_GATE_LISTEN |
Public only if explicitly enabled (off by default). Cleartext. |
| gate — TLS telnet | operator-chosen (e.g. :4443) |
TELOS_GATE_TLS_LISTEN (+ cert/key) |
Public — the recommended player entrypoint. |
| world — gRPC Play | :9090 |
TELOS_WORLD_LISTEN |
Internal — the gate dials it; peer shards dial it for handoff. |
| director | (none) | — | No inbound listener. Pure client of Redis/PG/NATS; not reachable inbound. |
| account — gRPC API | :9100 |
TELOS_ACCOUNT_LISTEN |
Internal — only the gate dials it. |
| account — OAuth web | :8080 |
TELOS_WEB_LISTEN |
Public — browser sign-in + /auth/github/callback. Off unless set and Redis is up. |
| postgres | 5432 |
TELOS_POSTGRES_DSN |
Internal datastore. |
| redis (cache) | 6379 |
TELOS_REDIS_ADDR |
Internal — checkpoints, presence, device-auth, session locks (and the directory too, if not split out). |
| redis (coordination) | 6379 |
TELOS_REDIS_DIRECTORY_ADDR |
Internal, optional but recommended — give the directory its own instance so it can run noeviction while the cache instance evicts. Declaring it makes an evicting policy there a boot refusal; a configured-but-unreachable one is fatal. See Sysadmin Reference. |
| nats |
4222 (+ 8222 monitor) |
TELOS_NATS_URL |
Internal — comms + JetStream events. Restrict publish rights on the content-invalidation subject (below). |
| otel-collector |
4317 (OTLP), 8889 (Prometheus) |
OTEL_EXPORTER_OTLP_ENDPOINT |
Internal — services push; ops scrape. |
The content-invalidation bus is unauthenticated — restrict it with NATS subject permissions. Invalidations ride a single unsigned subject with no publisher identity, and the shard-side applier treats a definition it cannot resolve as a deletion. The subscriber now fails closed on the kind and pack, which removes the knowledge-free variant, but an attacker who can publish and who names a loaded pack still reaches the zone-shape reconcile, the channel swap and the per-ref re-read — and a kind-mismatched invalidation still evicts. That residual is not closable in the engine, because a forged message of that shape is indistinguishable from a legitimate one. So grant publish on the content subject only to the director and the pull/seed jobs, and give world shards subscribe-only credentials. Treat anything able to reach
:4222as able to edit live content until you have.
The dev-compose
:4000vs:4001split is not a code concept — both are the sametelos-gatebinary. In a real deployment there is one gate role, listening on whateverTELOS_GATE_LISTEN/TELOS_GATE_TLS_LISTENyou set. (:4001in dev just addsTELOS_ACCOUNT_TARGETfor the account-backed login;:4000runs the bare-name bypass.)
The dev Compose stacks publish every host port on
127.0.0.1, not0.0.0.0. Almost all of them are unauthenticated or dev-credentialed and would be a serious exposure on a shared or internet-reachable dev box: NATS:4222alone is a full unauthenticated data plane (read every tell ontelos.comms.tell.<playerId>, forge comms/events, DoS JetStream), plus NATS monitoring:8222, the OTLP ingest:4317and Prometheus scrape:8889, Redis with no auth, Postgres on defaulttelos:teloscreds, the account OAuth/link-code bridge, and the plaintext-telnet gates that carry link codes in the clear. Binding them to host loopback matches the gate's existing precedent and changes nothing functional: container-to-container traffic uses service names over the compose bridge (nats:4222,otel-collector:4317), which host-publish binding doesn't affect, and the smoke/e2e/load tooling reaches them via the host'slocalhost. The internal gRPC ports (9090worlds,9100account) are expose-only, never host-published. This is dev-stack hardening; a real deployment's exposure is governed by the public/internal split in the table above and your network policy, not by Compose port lines.
The gate's transports are configured explicitly — there is no automatic TLS:
-
Plain telnet is OFF by default. It turns on only with
TELOS_GATE_ALLOW_PLAINTEXT=1, and when on the gate logs a loud "play crosses the wire UNENCRYPTED" warning. -
TLS telnet requires operator-supplied cert + key + listen — all three of
TELOS_GATE_TLS_LISTEN,TELOS_GATE_TLS_CERT,TELOS_GATE_TLS_KEY. TLS 1.2 minimum. There is no built-in certificate. Encrypted telnet is entirely opt-in configuration. -
Configure nothing and the gate refuses to boot: "no transport enabled — configure TLS
(cert+key) or set
TELOS_GATE_ALLOW_PLAINTEXT=1." To run encrypted, set the three TLS vars and leaveTELOS_GATE_ALLOW_PLAINTEXTunset. - SSH is removed. Auth is OAuth-only; the only encrypted transport is TLS telnet.
Only GMCP (telnet option 201) is negotiated. MCCP2 compression, NAWS, TTYPE, and CHARSET are
not implemented — notably, with no NAWS there is no terminal-width source, so width-based
word-wrap is not active. A per-write deadline (GateWriteTimeout, default 30s) bounds a wedged
slow client.
Open to the internet, exactly two things:
- The player entrypoint — the gate's TLS telnet port. Open the plain-telnet port only if you deliberately accept cleartext.
-
The OAuth web broker — account
:8080, which browsers need for GitHub sign-in and the callback.
Everything else stays private:
-
The whole gRPC mesh — world Play
:9090and account API:9100. These are plaintext gRPC (the gate dials both with insecure credentials; world↔world handoff likewise). Mesh trust is app-layer only — signed session assertions, theTELOS_ACCOUNT_CALLER_TOKEN, and handoff Ed25519 keys — not transport TLS. Exposing the mesh would allow wire-level spoofing. Keep it on a private network / service mesh. - All datastores/infra — postgres, redis, nats, otel.
TELOS_ALLOW_INSECURE defaults to false and is deliberately separate from TELOS_ENV
(which defaults to dev), so a forgotten secret fails closed rather than silently running open. It
gates several boot refusals:
- the gate refuses to boot with no
TELOS_ACCOUNT_TARGET; - the account service refuses an open gRPC API with no caller token;
- a world refuses a discoverable shard with no handoff verify key;
- the world/account refuse a pack-set divergence.
A production deploy sets none of these to 1. Instead it supplies the real secrets — the
caller token, the handoff keypair, and the account signing/verify keys (see
OAuth Setup). Relatedly, never set
TELOS_DEV_AUTOAUTH_ALLOW_REMOTE_BIND on a real host (it only exists to let the dev bypass bind
off-loopback inside Docker; in a release image the guarded path is dead code anyway).
The same static binary runs identically as a host process or a container — only config delivery and
discovery differ. Config resolves the same way both ways: defaults < an optional YAML file
(TELOS_CONFIG) < TELOS_* env overrides (config.example.yaml is the template).
-
Bare processes: run each role as its own process against shared backing services — one
telos-worldper shard (each needs a uniqueTELOS_SHARD_IDand its ownTELOS_SHARD_ADDR), atelos-gate, atelos-account, and optionally atelos-director, all pointed at the same Postgres/Redis/NATS. Discovery is via Redis, not an orchestrator: worlds self-registershard_id → endpointand claim zone leases from a pool; the gate resolves the home shard from Redis (with aTELOS_WORLD_TARGETfallback). A bare multi-host fleet works with no scheduler — Redis is the coordination substrate. -
Under Compose / Kubernetes: service names become discovery addresses (
world:9090,account:9100,redis:6379, …); ordering is bydepends_on+ healthchecks.
Boot order (both models): run the one-shot telos-migrate, then telos-seed, to completion
before any telos-world starts. Under Compose the one-shots are wired as depends_on
completions; bare processes must reproduce that ordering. Shutdown is signal-driven: on
SIGINT/SIGTERM a world drains its zones and players to a peer before stopping (the zero-drop drain
— see Running at Scale); account and director stop cleanly.
The wiki covers the gomud-side artifacts (above) and a Compose self-host quickstart (Running Locally). The source of truth for cloud infrastructure — Terraform, Kustomize overlays, and a k3s topology for bare-metal or cloud — is the separate repository:
-
https://github.com/double-nibble/telosMUD-infra — see its
DEPLOYMENT.md(a deploy-your-own admin guide) andRUNBOOK.md(operational procedures).
Use the infra repo for a real cloud/cluster stand-up; use this page for what each image/binary is, how ports and firewalling work, and how to configure the fleet.
TelosMUD — Wiki under construction.
- Builder Reference
- Builder Commands
- Trust Tier Model
- Pack Authoring
- Pack MUD Settings
- Pack Lua Scripting
- Pack Lua Hooks
- Pack Entity Reference
- Building Instanced Zones
- Engine Developer Reference
- Architecture Overview
- Entity Component Model
- Zone Runtime & Actor Model
- Instanced Zones
- Command Parser & Targeting
- Edge & Protocol
- GMCP Reference
- Persistence & Durability
- Content Loading & Hot Reload
- Abilities & Effects
- Combat System
- Loot, Spawns & Crafting
- Accounts & Auth Internals
- Orchestration & Directors
- Scoped Event Bus
- Cross-Shard Handoff
- Lua Sandbox Internals
- Distributed Systems Model
- RPC & Protobuf