Running Locally
Audience: Sysadmin Status: ✅ Ready
How to stand up a TelosMUD stack on a developer workstation: what Docker Compose brings up,
the make targets that drive it, the configuration surface, the ports each service listens
on, the migrate + seed bootstrap, and how to connect a client. Two authentication paths are
covered in their own sections near the end.
The quickest path
From a checkout, one command builds and starts the whole fleet in Docker:
make up # build + start everything: the stack AND the Grafana LGTM backend
telnet localhost 4000
make up builds the images, brings up the backing services, runs the one-shot
migrate + seed, starts the world shards, the account service, and the gate, and the Grafana LGTM
observability stack (Grafana on http://localhost:3000). Once it settles, connect a telnet client
to port 4000. Tear it down with make down. If you want the leaner stack without observability
(no Grafana, no exporter noise — the same base CI uses), run make up-base instead.
What Compose brings up
There are two Compose topologies under deploy/, and they are meaningfully different.
Multi-shard — deploy/docker-compose.yml (+ the observability overlay)
The realistic topology: multiple world shards, an account service, and the full backing stack.
make up starts this base file plus deploy/docker-compose.obs.yml (the Grafana LGTM overlay);
make up-base (and CI, which references the base file directly) starts the base alone.
| Service | Image / binary | Port(s) | Notes |
|---|---|---|---|
postgres |
postgres:16-alpine |
5432 | Durable state. |
redis |
redis:7-alpine |
6379 | Directory / session routing / leases. |
nats |
nats:2.10-alpine |
4222 (client), 8222 (monitoring) | Cross-shard bus. |
otel-collector |
otel/opentelemetry-collector-contrib |
4317 (OTLP gRPC), 8889 (Prometheus scrape) | Metrics pipeline. |
migrate |
telos-migrate |
— | One-shot: applies migrations, then exits. |
seed |
telos-seed |
— | One-shot: imports the demo pack, then exits. |
world |
telos-world |
9090 (internal) | Hosts the midgaard zone. |
world-darkwood |
telos-world |
9090 (internal) | Hosts the darkwood zone. |
world-crypt |
telos-world |
9090 (internal) | Hosts the crypt zone. |
account |
telos-account |
9100 (gRPC, internal), 8080 (web) | OAuth / accounts / characters. |
gate |
telos-gate |
4000 (telnet) | Primary edge; uses the dev bypass (type a name). Published on 127.0.0.1:4000 only. |
gate-auth |
telos-gate |
4001 (telnet) | Account-backed edge; real GitHub OAuth via the browser device bridge. |
Three separate telos-world services each own one zone, and players are moved between them
across the market ↔ grove boundary by the cross-shard handoff. This
is the topology that exercises the real distributed paths.
Single-box — deploy/docker-compose.single.yml
A deliberately minimal stack for a fast, self-contained look. One world process hosts
all three zones (midgaard, darkwood, crypt), so cross-zone moves like the
market ↔ grove crossing happen entirely in-process — no handoff, no directory re-dial.
Run it explicitly:
docker compose -f deploy/docker-compose.single.yml up --build
telnet localhost 4000
| Service | Port(s) | Notes |
|---|---|---|
postgres |
(internal) | Durable state. |
redis |
6380 → 6379 | Separate host port so it won’t collide with make up. |
migrate / seed |
— | One-shot bootstrap. |
world |
9090 (internal) | One shard, zones midgaard,darkwood,crypt. |
gate |
4000 (telnet) | Plaintext, bare-name login. |
Two things are notable about the single-box stack: it runs no NATS (nothing needs the cross-shard bus when everything is one process) and no account service (it uses the bare-name dev login). It is the smallest thing that boots a walkable world.
Neither Compose file runs a
telos-directorprocess. In these dev topologies zone placement is served directly from the Redis-backed directory (each zone is leased to its shard), so the director — which coordinates dynamic placement, scope leadership, and coordinated pull/reload — is not required to get a world running. See Orchestration & Directors for when you do want one.
The make targets you’ll use
The Makefile is the front door. The targets relevant to running locally:
| Target | What it does |
|---|---|
make up |
Build & start the full multi-shard stack + the Grafana LGTM backend (Grafana on http://localhost:3000). |
make up-base |
Same, but without observability — the lean path CI/smoke use. |
make deps |
Start only the backing services (Postgres, Redis, NATS) — the base for running binaries directly. |
make down |
Stop and remove the stack, including the observability overlay. |
make logs |
Tail stack logs, including observability. |
make build |
Build all cmd/ binaries into ./bin. |
make migrate |
Apply DB migrations (go run ./cmd/telos-migrate up; uses TELOS_POSTGRES_DSN). |
make migrate-status |
Show migration status. |
make seed |
Import the demo content pack into Postgres (go run ./cmd/telos-seed, pack='demo'). |
There is no single “run the binaries” target: the direct-process path is make deps to bring
up the dependencies, then make build and launch the individual ./bin/telos-* binaries
yourself (pointing each at your config). Most people use make up and never touch the raw
binaries.
Configuration
Every service reads one YAML config, and any field can be overridden by a TELOS_*
environment variable. Copy the example and point TELOS_CONFIG at it:
# config.example.yaml
service: telos-world
env: dev
log_level: info
postgres:
dsn: postgres://telos:telos@localhost:5432/telosmud?sslmode=disable
redis:
addr: localhost:6379
nats:
url: nats://localhost:4222
Common overrides (the Compose files set these as env): TELOS_POSTGRES_DSN, TELOS_REDIS_ADDR,
TELOS_NATS_URL, TELOS_SERVICE, TELOS_ENV, TELOS_ZONES (which zones a world shard hosts),
TELOS_WORLD_LISTEN / TELOS_WORLD_TARGET, TELOS_GATE_LISTEN, TELOS_ACCOUNT_TARGET, and
TELOS_CONTENT_PACKS (the enabled pack set — see Content Packs Intro).
Placement is configuration, not code: which zones a shard owns is just its TELOS_ZONES list.
Bootstrapping the database
Before a world can serve content, the schema must exist and a pack must be loaded. Compose
does this for you via the one-shot migrate and seed services; to do it by hand against a
make deps stack:
make migrate # apply db/migrations (embedded goose)
make seed # import the demo pack into pack='demo' rows
telos-migrate applies the SQL migrations under db/migrations. telos-seed merges the
embedded demo pack tree and writes it into the pack='demo' definition rows; it is idempotent
(re-running replaces the pack’s rows) and safe to run twice on the same volume. If NATS is
reachable it also publishes a hot-reload invalidation so a running shard picks up the change
without a restart; if NATS is down the rows are still written and the shard reloads on its next
boot.
Connecting a client
Point a telnet client at the gate:
telnet localhost 4000
On the multi-shard stack, port 4000 is the primary gate and port 4001 is the
account-backed gate. On the single-box stack there is just 4000. Once connected you spawn
into the demo world’s home zone (midgaard); walking north out of the temple takes you to the
market, and north again crosses into the darkwood grove. How you authenticate on connect
depends on which path you chose — see the two sections below.
Authentication: dev bypass
The dev bypass replaces the browser OAuth flow with a “type a name” login, so you can get into the world without an identity provider. It is what port 4000 uses on the dev stack. It is deliberately hard to enable by accident: all three of the following must be true, or the bypass is simply not there.
- Build tag
telos_devauth. The bypass code path only compiles into the binary when built with-tags telos_devauth. A release build (built without it) compiles the path out entirely and forces the flag off — there is no runtime way to switch it on in a release binary. In the dev stack the Dockerfile setsBUILD_TAGSand Compose passes the tag. TELOS_DEV_AUTOAUTH=1. With the tag compiled in, this env var actually activates the bypass at runtime.- A loopback bind. The gate must be listening on loopback (e.g.
127.0.0.1:4000). If the bypass is active but the listener is bound to a non-loopback address, the gate refuses to boot — a guard against ever exposing a passwordless login off-box. (There is an explicit escape hatch,TELOS_DEV_AUTOAUTH_ALLOW_REMOTE_BIND=1, for the rare case you know you want a non-loopback bind; do not use it on anything reachable.)
When active, connecting prompts “By what name shall you be known?” and drops you straight
into the world under that name with no account record behind it. This is telos-gate-only, and
in the dev Compose stack it is wired to 127.0.0.1:4000. The tests behind this path run via
make test-devauth.
The account-less fallback (accountAuthGate)
Separately from the compiled bypass, a gate that is started without a TELOS_ACCOUNT_TARGET
has no account service to talk to, so it also falls back to bare-name login. Because that is
still a passwordless path, such a gate refuses to boot unless TELOS_ALLOW_INSECURE=1 is
set to acknowledge it. This is why the single-box stack (which runs no account service) sets
TELOS_ALLOW_INSECURE=1. Never set it on anything public.
Authentication: real OAuth (local)
To exercise the real login path locally, run telos-account and authenticate through GitHub —
the only OAuth provider implemented today. The flow is broker-mediated: telnet in, the gate
prints a clickable URL, you approve in the browser, and the gate picks up the result. (There is
no typed link code and no connect <code> command — ignore any stale Compose comments
that say otherwise.)
1. Register a GitHub OAuth App. In GitHub → Developer settings → OAuth Apps, create an app with:
- Homepage URL:
http://localhost:8080 - Authorization callback URL:
http://localhost:8080/auth/github/callback
Note its Client ID and generate a Client Secret.
2. Provide the credentials. Copy the example env file and fill it in:
cp deploy/auth.env.example deploy/auth.local.env
# then set, in deploy/auth.local.env:
# TELOS_GITHUB_CLIENT_ID=...
# TELOS_GITHUB_CLIENT_SECRET=...
3. Configure telos-account. The account service hosts the OAuth broker website and needs
Redis (it stores the short-lived device sessions there). The relevant settings:
TELOS_WEB_LISTEN=:8080andTELOS_WEB_PUBLIC_URL=http://localhost:8080TELOS_REDIS_ADDR=…(required — without Redis the broker and device auth are disabled)TELOS_WEB_SECURE_COOKIES=0for local plain-HTTP development (cookies default to secure)
4. Point the gate at the account service and connect on :4001. The account-backed gate
sets TELOS_ACCOUNT_TARGET=account:9100 and must not carry the devauth tag or env. Then:
telnet localhost 4001
The gate prints a http://localhost:8080/login/<device_code> URL. Open it, approve the GitHub
authorization (the broker uses PKCE and CSRF-protected state), and the gate — which has been
polling — sees the completed authorization, resolves or creates your account by your GitHub
identity (never by email), and hands you to character select/create. The 4000 gate remains the
bypass path; 4001 is the real one.
For the production version of this setup — public URLs, TLS, secure cookies, the caller token between gate and account — see OAuth Setup.
Troubleshooting
- “This world has no rooms yet” / immediate login refusal — you booted a world before
seeding content. Run
make seed(or let the Composeseedone-shot complete). The embeddedcorebootstrap pack guarantees at least a lobby even before real content is seeded. - Gate refuses to boot — the dev bypass and other insecure conveniences require an explicit opt-in; without it the gate fails closed. See the dev-bypass section above.
- Port already in use on 4000/6379 — you have both stacks up at once. The single-box stack
intentionally uses Redis host port 6380 to avoid colliding with
make up; stop one stack withmake down(or the single-boxdocker compose … down). - Migrations pending — run
make migrate-statusto see the applied/pending list.