anvilsign in

collin/mahjong · 67a30800

A Dockerfile, a compose file, and no address a container could lie with

Collin Richards · 2026-08-25 11:02 UTC · 67a30800c94a7a04ea0b7a37233d6afa3a6fae2c · parent a62ae023 · browse files

added.dockerignore+7 −0
1+node_modules
2+dist
3+.git
4+.agent-queue
5+.claude
6+http.pid
7+*.log
addedDockerfile+42 −0
1+# The whole production stack is one process: the built game out of dist/, and
2+# the room relay on /ws of the same origin. So the image is a node runtime, the
3+# bundle, and `server/`.
4+#
5+# No state. Rooms live in memory, and a restart drops them — the next visitor
6+# on an old link recreates the room empty and a host mid-game republishes its
7+# table on the next move. There is nothing here to mount a volume for.
8+
9+# ---- build: typecheck, bundle ------------------------------------------
10+FROM node:24-alpine AS builder
11+WORKDIR /app
12+
13+COPY package.json package-lock.json ./
14+RUN npm ci
15+
16+COPY . .
17+RUN npm run build
18+
19+# The runtime needs `ws` and nothing else the dev toolchain drags in.
20+RUN npm prune --omit=dev
21+
22+# ---- runtime ------------------------------------------------------------
23+FROM node:24-alpine AS runtime
24+WORKDIR /app
25+
26+RUN addgroup -S app && adduser -S app -G app
27+
28+COPY --from=builder --chown=app:app /app/node_modules ./node_modules
29+COPY --from=builder --chown=app:app /app/dist ./dist
30+COPY --from=builder --chown=app:app /app/server ./server
31+COPY --from=builder --chown=app:app /app/package.json ./package.json
32+
33+ENV NODE_ENV=production
34+ENV HOST=0.0.0.0
35+ENV PORT=3000
36+
37+EXPOSE 3000
38+USER app
39+
40+# server/ is TypeScript and node strips the types itself — there is no build
41+# step for it and nothing generated to keep in sync with the source.
42+CMD ["node", "--no-warnings=ExperimentalWarning", "server/index.ts"]
modifiedREADME.md+9 −0
⋯ 362 unchanged lines
363363 ```
364364 npm run dev # vite, with the relay on /ws of the same origin
365365 npm run build && npm run serve # production: dist/ + relay, one process
366+hag deploy # onto hagrid, behind Caddy
366367 ```
367368
368369 Both commands listen on every interface, and both say where that is:
⋯ 14 unchanged lines
383384 TLS in front — a reverse proxy with a certificate — since phones only get
384385 camera and fullscreen on https.
385386
387+`hag deploy` builds the image, pushes it to the private registry and recreates
388+the container on the host — `Dockerfile` and `compose.yaml` at the repo root,
389+the same shape every project there uses. There is no volume, because there is
390+no state: rooms are in memory and the game is in the clients. **Set
391+`PUBLIC_URL`** in a `.env` beside the compose file: inside a container every
392+address belongs to a docker bridge nobody can reach, the relay knows better
393+than to hand one out, and without it there is no QR on the felt at all.
394+
386395 Rooms live in memory. A restart drops them; the next visitor on an old link
387396 recreates the room empty, and a host mid-game republishes its state on the
388397 next move. Joining a room writes the room into the address bar, so the page
⋯ 618 unchanged lines
addedcompose.yaml+43 −0
1+# 台灣麻將 on hagrid, deployed with `hag` -- the shared deploy tool for every
2+# project on that host (build, push to the private registry, then pull and
3+# recreate there). Both this machine and hagrid need
4+# `docker login registry.vibe.richardscollin.com` once.
5+#
6+# IMAGE_TAG picks which build runs. hag sets it to the git sha it just pushed,
7+# so `hag status` names the exact build, and rolling back on the host is
8+# `IMAGE_TAG=<sha> docker compose up -d --no-build`.
9+services:
10+ mahjong:
11+ image: registry.vibe.richardscollin.com/mahjong:${IMAGE_TAG:-latest}
12+ build: .
13+ # Caddy reverse-proxies to `mahjong:3000` by name, so this is load-bearing.
14+ container_name: mahjong
15+ restart: unless-stopped
16+
17+ # PUBLIC_URL above all -- see below. Optional so the container still starts
18+ # on a host that has none.
19+ env_file:
20+ - path: .env
21+ required: false
22+
23+ environment:
24+ PORT: 3000
25+ # Where the outside world reaches this game, and it matters more here
26+ # than it looks: it is the origin every QR carries. The table's own
27+ # address inside the container is a docker bridge address that nobody
28+ # can reach, and the relay knows better than to hand that out (see
29+ # `lanAddress` in server/rooms.ts), so without this there is simply no
30+ # QR tile on the felt and the only way in is a link typed by hand.
31+ #
32+ # https, not http: a phone only gets fullscreen and the camera on a
33+ # secure origin, and Caddy is already terminating TLS in front of this.
34+ PUBLIC_URL: ${PUBLIC_URL:-}
35+
36+ networks:
37+ - hagrid
38+
39+# Caddy runs on this network and reverse-proxies to the container by name, so
40+# no host port is published. The hagrid repo owns the network's lifecycle.
41+networks:
42+ hagrid:
43+ external: true
modifiedserver/rooms.ts+8 −0
11 import { spawn, type ChildProcess } from 'node:child_process';
2+import { existsSync } from 'node:fs';
23 import type { IncomingMessage } from 'node:http';
34 import { networkInterfaces } from 'node:os';
45 import type { Duplex } from 'node:stream';
⋯ 110 unchanged lines
115116 * they are to be it: the home-router range first, then the other two private
116117 * ranges, then anything else that is not loopback. Link-local (169.254) is
117118 * what an interface says when it has no network at all, so it never counts.
119+ *
120+ * Inside a container every one of them is a bridge address on a network that
121+ * exists only on the host, so there is no answer and it says so. A deployed
122+ * game gets its face from `PUBLIC_URL` — see compose.yaml — and a deploy that
123+ * forgot to set one shows no QR at all, which is the truth rather than a code
124+ * that leads nowhere.
118125 */
119126 function lanAddress(): string {
127+ if (existsSync('/.dockerenv')) return '';
120128 const rank = (ip: string): number => {
121129 if (ip.startsWith('192.168.')) return 0;
122130 if (ip.startsWith('10.')) return 1;
⋯ 296 unchanged lines