Step 13.2: Caddy serves the app and proxies the API
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5c9534036f
commit
c2f42b1541
|
|
@ -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`).
|
- **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.
|
- **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.
|
- **`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.
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ function buildInfo() {
|
||||||
) as {
|
) as {
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
let build = "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 {
|
try {
|
||||||
build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
build = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
|
|
@ -79,6 +81,7 @@ function buildInfo() {
|
||||||
} catch {
|
} catch {
|
||||||
// Not a git checkout (e.g. a tarball build): keep "dev".
|
// Not a git checkout (e.g. a tarball build): keep "dev".
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) };
|
return { __APP_VERSION__: JSON.stringify(version), __APP_BUILD__: JSON.stringify(build) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
# The public address players use; sign-in only accepts requests from it.
|
# The public address players use; sign-in only accepts requests from it.
|
||||||
PUBLIC_URL=https://deadhead.belowland.com
|
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 (only reachable inside the stack).
|
||||||
POSTGRES_USER=deadhead
|
POSTGRES_USER=deadhead
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -9,8 +9,13 @@ cd "$(dirname "$0")"
|
||||||
project="deadhead-check"
|
project="deadhead-check"
|
||||||
env_file="$(mktemp)"
|
env_file="$(mktemp)"
|
||||||
secret() { node -e 'console.log(require("crypto").randomBytes(+process.argv[1]).toString("base64url"))' "$1"; }
|
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
|
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)
|
POSTGRES_PASSWORD=$(secret 18)
|
||||||
BETTER_AUTH_SECRET=$(secret 32)
|
BETTER_AUTH_SECRET=$(secret 32)
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -40,4 +45,24 @@ check() {
|
||||||
check "health" "http://127.0.0.1:3000/api/health" '200 {"status":"ok"}'
|
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":*'
|
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"
|
exit "$fail"
|
||||||
|
|
|
||||||
|
|
@ -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
|
# From this folder: docker compose --env-file .env up -d --build
|
||||||
# Settings come from deploy/.env (copy .env.example).
|
# Settings come from deploy/.env (copy .env.example).
|
||||||
name: deadhead
|
name: deadhead
|
||||||
|
|
@ -34,5 +35,27 @@ services:
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
WORKERS_ENABLED: "true"
|
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:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -457,8 +457,10 @@ The app clock is `base clock + offset`; the offset is set only by the test-only
|
||||||
| File | Role |
|
| 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` |
|
| `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 |
|
| `compose.yml` | Production stack (project `deadhead`): Postgres 18 (named volume, not published), the server, and `web` (Caddy on `HTTP_PORT`/`HTTPS_PORT`, certificate volumes) |
|
||||||
| `.env.example` | `PUBLIC_URL` (also Better Auth's URL), Postgres credentials, `BETTER_AUTH_SECRET`, log level; copy to `deploy/.env` |
|
| `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 |
|
| `check.sh` | Builds and starts the stack under a throwaway project, checks it, removes it |
|
||||||
|
|
||||||
## Client (`apps/web`)
|
## Client (`apps/web`)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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).
|
- [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
|
- [ ] Step 13.3 — Database backups
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue