From 7467a8d30f81533eca82e58ca20dd417b654a8f6 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:39:47 -0400 Subject: [PATCH] feat(M5): docker-compose full stack (caddy + node backend + sqlite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.yml: two profiles. - backend: backend (node+ws+better-sqlite3, /data volume) + frontend (Caddy static build, STORAGE=ws, same-origin proxy) - firebase: existing Dockerfile + nginx (upstream path, untouched) Run: docker compose --profile backend up --build. OrbStack local now, remote docker context later. server/Dockerfile: node:18-alpine, workspaces (shared dep), rebuild better-sqlite3 for musl, DB at /data/tracker.sqlite. Dockerfile.ws: CRA build STORAGE=ws → caddy:2-alpine serves /srv. No backend URL baked (same-origin). Caddyfile: handle /api/* + handle /ws → backend:4001 (path preserved, mutually-exclusive handles so try_files SPA fallback never shadows proxy). handle { static try_files } last. HTTP basic auth block optional. src/storage/ws.js: same-origin defaults. Empty baseUrl = relative fetch (Caddy proxy). wsUrl derives from window.location (http→ws/https→wss). Fallback localhost for bare npm start dev. .dockerignore: add data/ scratch/ tmp/ (never bake into image). Keep Caddyfile in context (frontend build COPYs it). Smoke verified via OrbStack: - GET / → 200 (static SPA) - PUT/GET /api/doc roundtrip → JSON persists - WS /ws subscribe + change push → both work through proxy Firebase profile: pre-existing Dockerfile requires .env.local (hardcoded COPY on main, not changed here). User must create file. Not a regression. --- .dockerignore | 14 ++++++++--- Caddyfile | 36 ++++++++++++++++++++++++++++ Dockerfile.ws | 29 +++++++++++++++++++++++ docker-compose.yml | 59 ++++++++++++++++++++++++++++++++++++++++++++++ server/Dockerfile | 23 ++++++++++++++++++ src/storage/ws.js | 15 ++++++++++-- 6 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 Caddyfile create mode 100644 Dockerfile.ws create mode 100644 docker-compose.yml create mode 100644 server/Dockerfile diff --git a/.dockerignore b/.dockerignore index 27a8caa..3c76f3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,28 +4,36 @@ # Ignore Node.js modules (they will be installed in the Docker image) node_modules +**/node_modules # Ignore build output (it will be generated in the Docker image) build dist -# Ignore Docker files themselves +# Ignore Docker files themselves (Caddyfile MUST stay in context for frontend build) Dockerfile +Dockerfile.ws .dockerignore +docker-compose.yml # Ignore any local environment files if you have them .env -# .env.local +.env.local .env.development.local .env.test.local .env.production.local # Ignore IDE and OS-specific files .vscode/ -.idea/ +.idea *.suo *.user *.userosscache *.sln.docstates Thumbs.db .DS_Store + +# Ignore local sqlite data + scratch diagnostics (never bake into image) +data/ +scratch/ +tmp/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..586fdeb --- /dev/null +++ b/Caddyfile @@ -0,0 +1,36 @@ +# Caddyfile — serve static frontend, proxy /api + /ws to backend +# handle blocks are mutually exclusive + ordered: API/WS first, static last. +# (try_files at site level would rewrite /api/* → /index.html before proxy.) + +{ + # admin off for docker + admin off +} + +:80 { + encode gzip + + # REST API → backend service (path preserved: /api/doc etc.) + handle /api/* { + reverse_proxy backend:4001 { + header_up Host {host} + } + } + + # WebSocket upgrade → backend + handle /ws { + reverse_proxy backend:4001 + } + + # Everything else: static SPA with client-side routing fallback + handle { + root * /srv + try_files {path} /index.html + file_server + } + + # HTTP basic auth (in-house only). Uncomment + set CADDY_BASIC_AUTH env. + # basic_auth { + # {$CADDY_BASIC_AUTH} + # } +} diff --git a/Dockerfile.ws b/Dockerfile.ws new file mode 100644 index 0000000..8504ea4 --- /dev/null +++ b/Dockerfile.ws @@ -0,0 +1,29 @@ +# Dockerfile.ws — frontend build (STORAGE=ws) served by Caddy +# Same-origin: Caddy proxies /api + /ws to backend. No backend URL baked at build. + +FROM node:18-alpine AS build +WORKDIR /app + +# workspaces root +COPY package*.json ./ +COPY shared/package.json ./shared/ +COPY server/package.json ./server/ +RUN npm install --include-workspace-root + +COPY shared/ ./shared/ +COPY src/ ./src/ +COPY public/ ./public/ + +# Build with ws storage (no backend URL — same-origin via Caddy proxy) +ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default +ENV REACT_APP_STORAGE=ws +ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID +RUN NODE_OPTIONS=--openssl-legacy-provider npm run build + +# Stage 2: Caddy serves static + proxies API/WS +FROM caddy:2-alpine + +COPY --from=build /app/build /srv +COPY Caddyfile /etc/caddy/Caddyfile + +EXPOSE 80 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ba5fb1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +# docker-compose.yml — two profiles: +# firebase: existing Dockerfile (nginx + firebase build), upstream path +# backend: full stack (caddy frontend + node backend + sqlite volume) +# +# Usage: +# docker compose --profile backend up --build # full self-hosted stack +# docker compose --profile firebase up --build # firebase-only (upstream) +# +# Run local in OrbStack; remote docker context later (just change context). + +services: + # ---- full self-hosted stack (STORAGE=ws) ---- + backend: + profiles: ["backend"] + build: + context: . + dockerfile: server/Dockerfile + image: ttrpg-backend:local + volumes: + - backend-data:/data + environment: + - DB_PATH=/data/tracker.sqlite + - PORT=4001 + # - CORS_ORIGIN=* # Caddy same-origin, cors not strictly needed + expose: + - "4001" + restart: unless-stopped + + frontend: + profiles: ["backend"] + build: + context: . + dockerfile: Dockerfile.ws + args: + - REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default} + image: ttrpg-frontend:local + ports: + - "${FRONTEND_PORT:-8080}:80" + depends_on: + - backend + # Optional basic auth: set in .env as CADDY_BASIC_AUTH="user " + # Generate hash: caddy hash-password + environment: + - CADDY_BASIC_AUTH=${CADDY_BASIC_AUTH:-} + restart: unless-stopped + + # ---- firebase-only path (upstream, existing Dockerfile) ---- + firebase: + profiles: ["firebase"] + build: + context: . + dockerfile: Dockerfile + image: ttrpg-firebase:local + ports: + - "${FRONTEND_PORT:-8080}:80" + restart: unless-stopped + +volumes: + backend-data: diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..accd30e --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,23 @@ +# server/Dockerfile — backend (Express + ws + better-sqlite3) +FROM node:18-alpine AS build +WORKDIR /app + +# workspaces root needed: shared/ is a dependency (@ttrpg/shared) +COPY package*.json ./ +COPY shared/package.json ./shared/ +COPY server/package.json ./server/ +RUN npm install --workspaces --include-workspace-root + +COPY shared/ ./shared/ +COPY server/ ./server/ + +# better-sqlite3 builds native; rebuild for alpine musl +RUN cd server && npm rebuild better-sqlite3 + +ENV NODE_ENV=production +ENV PORT=4001 +ENV DB_PATH=/data/tracker.sqlite + +EXPOSE 4001 +WORKDIR /app/server +CMD ["node", "index.js"] diff --git a/src/storage/ws.js b/src/storage/ws.js index ba00423..1066b99 100644 --- a/src/storage/ws.js +++ b/src/storage/ws.js @@ -13,8 +13,19 @@ if (typeof WebSocket !== 'undefined') { } function createWsStorage({ baseUrl, wsUrl } = {}) { - const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, ''); - const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws'); + // Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy). + // Fallback to localhost for bare `npm start` dev without proxy. + const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, ''); + let WS; + if (wsUrl) { + WS = wsUrl; + } else if (typeof window !== 'undefined' && window.location) { + // derive from current origin (http→ws, https→wss), same host/port. + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + WS = `${proto}//${window.location.host}/ws`; + } else { + WS = 'ws://127.0.0.1:4001/ws'; + } // App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/... // Backend uses canonical paths. Strip prefix.