Step 13.1: containerize the server
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:41:14 -04:00
parent 2bbcaa4d05
commit 5c9534036f
11 changed files with 179 additions and 5 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
# Keep image builds small and free of local state.
**/node_modules
**/dist
**/coverage
**/test-results
**/playwright-report
.git
.env
.env.*
!.env.example
.pnpm-store
memory-bank
docs
deploy/backups
# The source map art is only an input to the asset script (decision 0.5.37).
apps/web/public/assets/map

View File

@ -1295,3 +1295,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **All four cargo classes pay 50 % more per tonne per M km** (Standard 150, Heavy 210, High-Value 270, Dangerous 300). Fuel is unchanged, so every job is more profitable; the tutorial share rises too (a new player has ~2,950 cr at the deal instead of ~2,130).
- **Chosen with the balance simulator**: +50 % is the largest raise that keeps every Step 12.1 target (first Market ship 60 min, discovery 28, loan paid off in 127, both purchase orders reach four ships at 127). Doubling would put the first Market ship at ~28 minutes, below the 45–75 window.
## Phase 13 — hosting choices (decision 0.5.86)
- **Asked at the start of the phase, as the plan says**: 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.
## Step 13.1
- **The server image runs the TypeScript with tsx**, exactly as `pnpm start` does, rather than adding a bundling step; `tsx` moved from the server's dev dependencies to its dependencies so the production install keeps it. The image is ~590 MB: `pnpm deploy --legacy` brings along some packages that better-auth's optional peers pull in. The non-legacy deploy mode would need the lockfile regenerated with injected workspace packages, which wasn't worth it for a home server.
- **Migrations run on every start** (`migrate-cli`, then `main`): they are idempotent, so a new image migrates the database before serving.
- **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.

View File

@ -21,11 +21,11 @@
"fastify": "^5.12.5",
"pg": "^8.23.1",
"pg-boss": "^12.36.0",
"tsx": "^4.23.15",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15"
"drizzle-kit": "^0.31.11"
}
}

15
deploy/.env.example Normal file
View File

@ -0,0 +1,15 @@
# Production settings for deploy/compose.yml. Copy to deploy/.env and fill in.
# The public address players use; sign-in only accepts requests from it.
PUBLIC_URL=https://deadhead.belowland.com
# Postgres (only reachable inside the stack).
POSTGRES_USER=deadhead
POSTGRES_DB=deadhead
# Generate with: openssl rand -base64 24
POSTGRES_PASSWORD=
# At least 32 random characters. Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=
LOG_LEVEL=info

43
deploy/check.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Builds and starts the production stack under a throwaway project name with
# generated secrets, checks it, then removes it (containers and volumes).
# deploy/check.sh from the repository root or deploy/
# Exits non-zero if any check fails. Needs Docker with Compose v2.
set -euo pipefail
cd "$(dirname "$0")"
project="deadhead-check"
env_file="$(mktemp)"
secret() { node -e 'console.log(require("crypto").randomBytes(+process.argv[1]).toString("base64url"))' "$1"; }
cat > "$env_file" <<EOF
PUBLIC_URL=http://localhost:8080
POSTGRES_PASSWORD=$(secret 18)
BETTER_AUTH_SECRET=$(secret 32)
EOF
compose() { docker compose -p "$project" --env-file "$env_file" "$@"; }
cleanup() {
compose down -v --remove-orphans >/dev/null 2>&1 || true
rm -f "$env_file"
}
trap cleanup EXIT
echo "Building and starting the stack…"
compose up -d --build --wait --wait-timeout 180
fail=0
check() {
local name="$1" url="$2" expect="$3" body
body="$(compose exec -T server node -e "fetch('$url').then(r => r.text().then(t => console.log(r.status + ' ' + t)))")"
if [[ "$body" == $expect ]]; then
echo "ok $name: $body"
else
echo "FAIL $name: $body"
fail=1
fi
}
# Step 13.1: the server, with migrations applied, answers health and time.
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":*'
exit "$fail"

38
deploy/compose.yml Normal file
View File

@ -0,0 +1,38 @@
# Production stack for a home server (Phase 13): Postgres and the API server.
# From this folder: docker compose --env-file .env up -d --build
# Settings come from deploy/.env (copy .env.example).
name: deadhead
services:
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-deadhead}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
POSTGRES_DB: ${POSTGRES_DB:-deadhead}
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 30
server:
build:
context: ..
dockerfile: deploy/server.Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-deadhead}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-deadhead}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in deploy/.env}
BETTER_AUTH_URL: ${PUBLIC_URL:?set PUBLIC_URL in deploy/.env}
LOG_LEVEL: ${LOG_LEVEL:-info}
WORKERS_ENABLED: "true"
volumes:
postgres-data:

32
deploy/server.Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# The API server (Step 13.1). Build from the repository root:
# docker build -f deploy/server.Dockerfile .
# Runs the TypeScript sources with tsx, as `pnpm start` does; migrations run on
# every start (they are idempotent) before the server listens.
FROM node:24-slim AS build
RUN corepack enable
WORKDIR /repo
# Manifests first, so dependency layers are cached across source changes.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/server/package.json apps/server/
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 --prod --filter "@deadhead/server..."
COPY tsconfig.base.json ./
COPY packages packages
COPY apps/server apps/server
# A self-contained copy of the server with its workspace packages and runtime
# dependencies (tsx is one: the server runs its TypeScript directly).
RUN pnpm --filter @deadhead/server deploy --legacy --prod /out
FROM node:24-slim
WORKDIR /app
COPY --from=build /out .
ENV NODE_ENV=production \
PORT=3000
EXPOSE 3000
USER node
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
CMD ["sh", "-c", "node_modules/.bin/tsx src/db/migrate-cli.ts && exec node_modules/.bin/tsx src/main.ts"]

View File

@ -452,6 +452,15 @@ pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a
The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production.
## Deployment (`deploy/`)
| 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` |
| `check.sh` | Builds and starts the stack under a throwaway project, checks it, removes it |
## Client (`apps/web`)
Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In development, Vite proxies `/api` to the server at `127.0.0.1:3000` (same origin, decision 0.5.20).

View File

@ -141,6 +141,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
83. **Deal loan** (CR-013). The deal loan amount is Courier price + Clipper price + Earth bay lease, computed from content.
84. **Economic distance** (Step 12.1, user decision). Contract pay and fuel use `reference × (distance / reference)^exponent` instead of the plain distance (reference 0.384M km, Earth–Moon; exponent 0.65), so a Moon run is unchanged and long legs pay and cost on a gentler curve. Range, travel time, Long Haul and XP keep the plain distance.
85. **A first contact the fleet can fly** (Step 12.1). While some undiscovered place is reachable, one fit slot on each board is a first-contact job sized and skilled for a ship that can fly it from there (decision 0.5.52's fit rules), so exploring never waits on board luck.
86. **Hosting** (Phase 13, user decision). The game runs on a home server with Docker Compose at deadhead.belowland.com; a Caddy container in the stack terminates HTTPS (Let's Encrypt), serves the built web app and proxies `/api`; nightly Postgres dumps go to a mounted folder or NAS share.
---

View File

@ -316,3 +316,11 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-
**Phase 12 complete — waiting for review.**
- [x] **Follow-up: base pay +50 %** (user decision). Every cargo class's base pay per tonne per M km: Standard 100 → 150, Heavy 140 → 210, High-Value 180 → 270, Dangerous 200 → 300. Medians over 7 seeds (Lighter-first): first Market purchase 60 min, first discovery 28, loan paid off 127, four ships 127 (Freighter-first 127); no breaches. Payout hand-calculation tests now read the rate from content.
## Phase 13 — Deployment (Base Game)
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
- [ ] Step 13.3 — Database backups

View File

@ -65,6 +65,9 @@ importers:
pg-boss:
specifier: ^12.36.0
version: 12.36.0(@opentelemetry/api@1.9.1)
tsx:
specifier: ^4.23.15
version: 4.23.15
zod:
specifier: ^4.6.5
version: 4.6.5
@ -75,9 +78,6 @@ importers:
drizzle-kit:
specifier: ^0.31.11
version: 0.31.11
tsx:
specifier: ^4.23.15
version: 4.23.15
apps/web:
dependencies: