-
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 on AWS EKS) 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 (+ TELOS_NATS_USER/_PASSWORD per identity) |
Internal — comms + JetStream events. Runs a per-identity authorization matrix (below). |
| otel-collector |
4317 (OTLP), 8889 (Prometheus) |
OTEL_EXPORTER_OTLP_ENDPOINT |
Internal — services push; ops scrape. |
NATS runs a per-identity authorization matrix — set the four credentials. A bare TCP connection to
:4222once had full publish rights on every subject: forge achan/tellwith any author, rewrite a player'sconfig.<id>hear-set, forge acontent.invalidate(the applier treats an unresolvable definition as a deletion), or delete a JetStream stream. The broker now enforces a role matrix (nats.conf) constraining four identities —world,gate,director,seed— each to only the subjects its binary uses, supplied viaTELOS_NATS_USER/TELOS_NATS_PASSWORDper service. Two rows are worth knowing: the gate is deny-all publish (deny = [">"]) because it's a pure sink — a denylist would leave$JS.EVENT.ADVISORY.*forgeable, letting a compromised gate fabricate theMAX_DELIVERIESadvisories the park monitor trusts — andworldanddirectorare grantedcontent.invalidate(the in-gamereloadand the coordinatedpullboth publish it; a naive matrix that misses this makes an admin reload fail with a Permissions Violation).Operator notes: an empty
allow = []is allow-all in NATS, not deny — usedeny = [">"]to forbid. NATS does not hot-reload its config, so the k8s deploy ships it as a content-hashedconfigMapGenerator(a matrix edit renames the ConfigMap → rolls the pod), and the four passwords must be stable and explicitly set (all four, even for an undeployed director — NATS refuses to start if a referenced$VARis unset; the deploy fails fast if any is missing). An in-process handle ACL is a second layer, not a substitute: the gate binary makes zero comms publishes, so its handle is denied publish on the entiretelos.comms.root by default — a new comms subject can't silently inherit gate-publish rights (a legitimate gate publisher would need an explicit, reviewed carve-out). This is the deployment-side fix the content-bus and comms residuals pointed at; a per-message signing layer remains future work.
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:4222carries the whole comms/JetStream data plane (its authorization matrix defends against a wrong-identity publisher, but the dev passwords are shared defaults, so treat host exposure as game-over anyway), 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 modules and Kustomize overlays targeting AWS EKS — is the separate repository:
-
https://github.com/double-nibble/telosMUD-infra — a single
READMEcovers the one-time bootstrap and what an environment brings up. It is a demo of deploying the fleet, built around a one-click up/down lifecycle, not a production app-management platform.
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.
It targeted Oracle Cloud (single-node k3s on Always-Free A1) until mid-2026 and now targets AWS EKS. If you are following an older write-up, the pieces that moved are: local-path PVCs → gp3 EBS, Traefik → ingress-nginx, the gate exposed through an AWS NLB (raw TCP, with
externalTrafficPolicy: Localso the client source IP survives), OCI Object Storage → S3, and Terraform state on S3 with native lockfile locking (no DynamoDB table). CI authenticates by GitHub OIDC → IAM role.
Its observability layer — a full Grafana LGTM stack on staging, a Kustomize-validation PR gate,
a default-deny NetworkPolicy, and a manual-approval production Environment — is documented on the
Observability page, which also covers the local Grafana overlay (in the default
make up; use make up-base to skip it) and the TELOS_OTEL_LOGS log bridge.
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