diff --git a/DECISIONS.md b/DECISIONS.md index 4d9d382..4c0901c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1307,3 +1307,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Postgres is not published** outside the stack; the server reaches it on the compose network. It gets its own named volume (`deadhead_postgres-data`). - **The stack's settings live in `deploy/.env`** (git-ignored), with the public address as one setting (`PUBLIC_URL`) used for Better Auth, so sign-in accepts requests from the public site. - **`deploy/check.sh` is the step's test**, run locally (it needs Docker); CI doesn't run it, because the Gitea runner's Docker access isn't set up for building images. It uses a throwaway project name and generated secrets and removes its containers and volumes, so it never touches a real deployment on the same machine. + +## Step 13.2 + +- **Caddy is the one front door** (decision 0.5.20): the same origin serves the app and `/api`, so cookies and Better Auth's origin check need no cross-site setup. HTTPS is automatic for a domain `SITE_ADDRESS`; `:80` gives plain HTTP for LAN testing (the app can't be installed to a home screen without HTTPS). +- **The image builds from the committed map art** and doesn't run the map-art script: the source art is excluded from the build context (it is large and never served). +- **`APP_BUILD` overrides the git commit in the build info**, because image builds have no `.git`; the README's update command passes the checkout's short hash. +- **Caching:** only Vite's content-hashed bundles and fonts are marked immutable. The generated map art keeps its plain names, so it isn't; the service worker precache versions it anyway. The shell, service worker, its registration script and the manifest are `no-cache`, so a new deploy is picked up on the next visit (auto-update, Step 11.1). +- **The 13.2 manual check is scripted locally** (guest sign-up and a tutorial run through Caddy, with the browser's Origin header); loading the real site on desktop and a phone is left to the user, since it needs the home server, DNS and port forwarding. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0156a41..abcfe42 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -71,13 +71,16 @@ function buildInfo() { ) as { version: string; }; - let build = "dev"; - try { - build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] }) - .toString() - .trim(); - } catch { - // Not a git checkout (e.g. a tarball build): keep "dev". + // APP_BUILD wins (image builds have no .git); otherwise the checkout's commit. + let build = process.env.APP_BUILD || "dev"; + if (!process.env.APP_BUILD) { + try { + build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + // Not a git checkout (e.g. a tarball build): keep "dev". + } } return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) }; } diff --git a/deploy/.env.example b/deploy/.env.example index 07553d7..c6ccdfe 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -2,6 +2,12 @@ # The public address players use; sign-in only accepts requests from it. PUBLIC_URL=https://deadhead.belowland.com +# What Caddy serves: the domain alone gets HTTPS from Let's Encrypt (ports 80 +# and 443 must reach this machine). Use :80 for plain HTTP on a LAN. +SITE_ADDRESS=deadhead.belowland.com +# Host ports Caddy listens on. +HTTP_PORT=80 +HTTPS_PORT=443 # Postgres (only reachable inside the stack). POSTGRES_USER=deadhead diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..2dd888e --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,25 @@ +# One site for the game (decision 0.5.20): the API under /api, the app for +# everything else. SITE_ADDRESS is the public domain (HTTPS via Let's Encrypt), +# or ":80" for plain HTTP when testing. +{$SITE_ADDRESS} { + encode zstd gzip + + handle /api/* { + reverse_proxy server:3000 + } + + handle { + root * /srv + # Client-side routes (/fleet, /contracts…) all load the app. + try_files {path} /index.html + file_server + } + + # Vite's hashed bundles and fonts never change under the same name. + @hashed path_regexp hashed ^/assets/[^/]+-[A-Za-z0-9_-]{8}\.(js|css|woff2?)$ + header @hashed Cache-Control "public, max-age=31536000, immutable" + + # The shell and service worker must always be checked for a new version. + @shell path / /index.html /sw.js /registerSW.js /manifest.webmanifest + header @shell Cache-Control "no-cache" +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..267a18a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,46 @@ +# Deploying Deadhead + +The production stack runs on one machine with Docker Compose: Postgres, the API +server and Caddy. Caddy serves the web app, proxies `/api` to the server and +gets HTTPS certificates for the domain automatically. + +## Before the first start + +1. **DNS:** point an `A` record for `deadhead.belowland.com` at the public IP of + the network the server is on. +2. **Router:** forward TCP ports 80 and 443 (and UDP 443 for HTTP/3) to the + server. Let's Encrypt checks port 80 before it issues a certificate. +3. **Docker:** Docker Engine with the Compose plugin (`docker compose version`). +4. **Settings:** in this folder, `cp .env.example .env` and fill in + `POSTGRES_PASSWORD` and `BETTER_AUTH_SECRET` (the comments say how to make + them). Keep `.env` private; it is ignored by git. + +## Start and update + +From this folder, on the server: + +```sh +git pull +APP_BUILD=$(git rev-parse --short HEAD) docker compose up -d --build +``` + +The server applies database migrations on every start, then begins serving. +`docker compose ps` shows each service's health; `docker compose logs -f server` +follows the API log. The About screen in Settings shows the build you deployed. + +## On a LAN only (no domain) + +Set `SITE_ADDRESS=:80` and `PUBLIC_URL=http://` in `.env`. Caddy +then serves plain HTTP. Installing the app to a phone home screen needs HTTPS, +so this is for testing. + +## Check a build before deploying + +```sh +deploy/check.sh +``` + +This builds and starts the whole stack under a separate project name with +throwaway secrets, on port 8088. It checks the API, the app, caching and a guest +sign-up with a tutorial run, then removes everything. It never touches the real +stack. diff --git a/deploy/check.sh b/deploy/check.sh index 326d190..1bd7042 100755 --- a/deploy/check.sh +++ b/deploy/check.sh @@ -9,8 +9,13 @@ cd "$(dirname "$0")" project="deadhead-check" env_file="$(mktemp)" secret() { node -e 'console.log(require("crypto").randomBytes(+process.argv[1]).toString("base64url"))' "$1"; } +port="${CHECK_PORT:-8088}" +site="http://localhost:$port" cat > "$env_file" <Deadhead: Space Freight Tycoon*' +expect "client route falls back to the app" "$(curl -s "$site/fleet")" '*
*' +expect "API through the proxy" "$(curl -s "$site/api/health")" '{"status":"ok"}' +expect "service worker is never cached" "$(curl -sI "$site/sw.js" | tr -d '\r' | grep -i '^cache-control')" '*no-cache*' +bundle="$(curl -s "$site/" | grep -o '/assets/index-[A-Za-z0-9_-]*\.js' | head -1)" +expect "hashed bundle is cached for a year" "$(curl -sI "$site$bundle" | tr -d '\r' | grep -i '^cache-control')" '*immutable*' + +# A guest can start a company and dispatch a tutorial run (Step 13.2's manual check, scripted). +jar="$(mktemp)" +trap 'cleanup; rm -f "$jar"' EXIT +post() { curl -s -b "$jar" -c "$jar" -H "Origin: $site" -H 'content-type: application/json' -X POST "$site$1" -d "$2"; } +expect "guest sign-up" "$(post /api/guest '{}')" '*"tutorial":{*' +contract="$(curl -s -b "$jar" "$site/api/tutorial/board" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).contracts[0].id))')" +expect "tutorial run dispatched" "$(post /api/dispatches "{\"kind\":\"tutorial\",\"contractId\":\"$contract\"}")" '*"dispatch":{*' + exit "$fail" diff --git a/deploy/compose.yml b/deploy/compose.yml index 3b69800..2e1f40b 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -1,4 +1,5 @@ -# Production stack for a home server (Phase 13): Postgres and the API server. +# Production stack for a home server (Phase 13): Postgres, the API server, and +# Caddy serving the web app with /api proxied to the server. # From this folder: docker compose --env-file .env up -d --build # Settings come from deploy/.env (copy .env.example). name: deadhead @@ -34,5 +35,27 @@ services: LOG_LEVEL: ${LOG_LEVEL:-info} WORKERS_ENABLED: "true" + web: + build: + context: .. + dockerfile: deploy/web.Dockerfile + args: + APP_BUILD: ${APP_BUILD:-dev} + restart: unless-stopped + depends_on: + server: + condition: service_healthy + environment: + SITE_ADDRESS: ${SITE_ADDRESS:?set SITE_ADDRESS in deploy/.env} + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + - "${HTTPS_PORT:-443}:443/udp" + volumes: + - caddy-data:/data + - caddy-config:/config + volumes: postgres-data: + caddy-data: + caddy-config: diff --git a/deploy/web.Dockerfile b/deploy/web.Dockerfile new file mode 100644 index 0000000..f457ad0 --- /dev/null +++ b/deploy/web.Dockerfile @@ -0,0 +1,25 @@ +# The web app behind Caddy (Step 13.2). Build from the repository root: +# docker build -f deploy/web.Dockerfile --build-arg APP_BUILD=$(git rev-parse --short HEAD) . +# Caddy serves the built app, proxies /api to the server and gets HTTPS +# certificates for SITE_ADDRESS automatically. + +FROM node:24-slim AS build +RUN corepack enable +WORKDIR /repo +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/ +COPY packages/content/package.json packages/content/ +COPY packages/schema/package.json packages/schema/ +COPY packages/sim/package.json packages/sim/ +RUN pnpm install --frozen-lockfile --filter "@deadhead/web..." +COPY tsconfig.base.json ./ +COPY packages packages +COPY apps/web apps/web +ARG APP_BUILD=dev +# The committed map art (public/assets/map-gen) is used as is: the source art +# isn't in the build context, so the map-art script is not run here. +RUN APP_BUILD=$APP_BUILD pnpm --filter @deadhead/web exec vite build + +FROM caddy:2-alpine +COPY deploy/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /repo/apps/web/dist /srv diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 4c3bd9f..c2cfd0b 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -457,8 +457,10 @@ The app clock is `base clock + offset`; the offset is set only by the test-only | File | Role | |---|---| | `server.Dockerfile` | API image: production dependencies via `pnpm deploy`, TypeScript run with tsx, migrations then listen; non-root; health check on `/api/health` | -| `compose.yml` | Production stack (project `deadhead`): Postgres 18 (named volume, not published) and the server | -| `.env.example` | `PUBLIC_URL` (also Better Auth's URL), Postgres credentials, `BETTER_AUTH_SECRET`, log level; copy to `deploy/.env` | +| `compose.yml` | Production stack (project `deadhead`): Postgres 18 (named volume, not published), the server, and `web` (Caddy on `HTTP_PORT`/`HTTPS_PORT`, certificate volumes) | +| `web.Dockerfile`, `Caddyfile` | Caddy image with the built app: `/api/*` to `server:3000`, client routes fall back to `index.html`, hashed assets cached for a year, shell and service worker never; HTTPS for `SITE_ADDRESS` | +| `README.md` | Home-server setup: DNS, port forwarding, `.env`, start/update, LAN-only mode | +| `.env.example` | `PUBLIC_URL` (also Better Auth's URL), `SITE_ADDRESS`, host ports, Postgres credentials, `BETTER_AUTH_SECRET`, log level; copy to `deploy/.env` | | `check.sh` | Builds and starts the stack under a throwaway project, checks it, removes it | ## Client (`apps/web`) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ac7a4c0..7d514d6 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -322,5 +322,5 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR- Choices (asked at the start of the phase, decision 0.5.86): a home server running Docker Compose at deadhead.belowland.com; a Caddy container in the stack for HTTPS and routing; nightly backups to a mounted folder or NAS share. - [x] **Step 13.1 — Containerize the server.** `deploy/server.Dockerfile` (build from the repo root): installs the server's production dependencies with pnpm, makes a self-contained copy with `pnpm deploy`, and runs the TypeScript with tsx (now a server runtime dependency) as a non-root user; migrations run on every start, then the server listens; a health check calls `/api/health`. `deploy/compose.yml` (project `deadhead`): Postgres 18 with a named volume, not published, and the server, which waits for Postgres to be healthy. Settings come from `deploy/.env` (`deploy/.env.example`: `PUBLIC_URL`, Postgres user/db/password, `BETTER_AUTH_SECRET`, log level). `.dockerignore` keeps local state and the source art out of builds. Test: `deploy/check.sh` builds and starts the stack under a throwaway project with generated secrets, waits until it is healthy, checks `/api/health` and `/api/time` from inside the server container, and removes everything (passes, ~25 s with a warm cache). -- [ ] Step 13.2 — Deploy the client +- [x] **Step 13.2 — Deploy the client.** `deploy/web.Dockerfile` builds the web app (`vite build` from the committed map art, `APP_BUILD` for the About screen's build) into a Caddy image. `deploy/Caddyfile`: one site at `SITE_ADDRESS` (the domain, so Let's Encrypt HTTPS; `:80` for plain HTTP), `/api/*` proxied to the server, everything else from the build with client routes falling back to `index.html`; Vite's hashed bundles and fonts cached for a year, the shell, service worker and manifest never. Compose gains the `web` service (ports from `HTTP_PORT`/`HTTPS_PORT`, certificate volumes). `deploy/README.md` covers DNS, port forwarding, settings, start/update and a LAN-only setup. Test: `deploy/check.sh` now also checks, through Caddy from the host: the app shell, a client route, `/api` through the proxy, the caching headers, and a guest sign-up plus a tutorial run dispatched (all pass). **Manual check for the user:** deploy on the home server and confirm the site loads on desktop and a phone, a guest can start, and a tutorial run can be dispatched. - [ ] Step 13.3 — Database backups