Skip to content

Deployment

Kurt edited this page Jul 20, 2026 · 10 revisions

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.

Container image & service-role model

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.

Published images (GHCR)

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-director has 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 publish telos-director yourself 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.

Port map & exposure

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 :4222 as able to edit live content until you have.

The dev-compose :4000 vs :4001 split is not a code concept — both are the same telos-gate binary. In a real deployment there is one gate role, listening on whatever TELOS_GATE_LISTEN / TELOS_GATE_TLS_LISTEN you set. (:4001 in dev just adds TELOS_ACCOUNT_TARGET for the account-backed login; :4000 runs the bare-name bypass.)

The dev Compose stacks publish every host port on 127.0.0.1, not 0.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 :4222 alone is a full unauthenticated data plane (read every tell on telos.comms.tell.<playerId>, forge comms/events, DoS JetStream), plus NATS monitoring :8222, the OTLP ingest :4317 and Prometheus scrape :8889, Redis with no auth, Postgres on default telos:telos creds, 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's localhost. The internal gRPC ports (9090 worlds, 9100 account) 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.

Transports & TLS

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 leave TELOS_GATE_ALLOW_PLAINTEXT unset.
  • 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.

Firewall: the "one public edge" model

Open to the internet, exactly two things:

  1. The player entrypoint — the gate's TLS telnet port. Open the plain-telnet port only if you deliberately accept cleartext.
  2. 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 :9090 and 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, the TELOS_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.

Fail-closed boot gates

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).

Bare processes vs. container management

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-world per shard (each needs a unique TELOS_SHARD_ID and its own TELOS_SHARD_ADDR), a telos-gate, a telos-account, and optionally a telos-director, all pointed at the same Postgres/Redis/NATS. Discovery is via Redis, not an orchestrator: worlds self-register shard_id → endpoint and claim zone leases from a pool; the gate resolves the home shard from Redis (with a TELOS_WORLD_TARGET fallback). 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 by depends_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.

Production IaC: the infra repo

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:

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.

Clone this wiki locally