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.

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 (+ 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 :4222 once had full publish rights on every subject: forge a chan/tell with any author, rewrite a player’s config.<id> hear-set, forge a content.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 identitiesworld, gate, director, seed — each to only the subjects its binary uses, supplied via TELOS_NATS_USER / TELOS_NATS_PASSWORD per 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 the MAX_DELIVERIES advisories the park monitor trusts — and world and director are granted content.invalidate (the in-game reload and the coordinated pull both 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 — use deny = [">"] to forbid. NATS does not hot-reload its config, so the k8s deploy ships it as a content-hashed configMapGenerator (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 $VAR is 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 entire telos.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 :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 carries 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 :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 modules and Kustomize overlays targeting AWS EKS — is the separate repository:

  • https://github.com/double-nibble/telosMUD-infra — a single README covers 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: Local so 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.