Step 13.2: Caddy serves the app and proxies the API
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-06 17:47:23 -04:00
parent 5c9534036f
commit c2f42b1541
10 changed files with 175 additions and 12 deletions

View File

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

View File

@ -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) };
}

View File

@ -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

25
deploy/Caddyfile Normal file
View File

@ -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"
}

46
deploy/README.md Normal file
View File

@ -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://<server-ip>` 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.

View File

@ -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" <<EOF
PUBLIC_URL=http://localhost:8080
PUBLIC_URL=$site
SITE_ADDRESS=:80
HTTP_PORT=$port
HTTPS_PORT=${CHECK_TLS_PORT:-8443}
POSTGRES_PASSWORD=$(secret 18)
BETTER_AUTH_SECRET=$(secret 32)
EOF
@ -40,4 +45,24 @@ check() {
check "health" "http://127.0.0.1:3000/api/health" '200 {"status":"ok"}'
check "server time" "http://127.0.0.1:3000/api/time" '200 {"realTime":*'
# Step 13.2: Caddy serves the app and proxies /api on one origin.
expect() {
local name="$1" got="$2" pattern="$3"
if [[ "$got" == $pattern ]]; then echo "ok $name"; else echo "FAIL $name: $got"; fail=1; fi
}
expect "app shell" "$(curl -s "$site/")" '*<title>Deadhead: Space Freight Tycoon</title>*'
expect "client route falls back to the app" "$(curl -s "$site/fleet")" '*<div id="root">*'
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"

View File

@ -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:

25
deploy/web.Dockerfile Normal file
View File

@ -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

View File

@ -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`)

View File

@ -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