diff --git a/.gitignore b/.gitignore index 4e853c5..9764a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ dist/ build/ *.egg-info/ +# Deploy secrets (committed via *.example files) +deploy/.env +deploy/auth.toml + # Playwright MCP scratch .playwright-mcp/ board-viewer-*.png diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..389d805 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,13 @@ +# Copy to ./.env (same dir as docker-compose.yml). +# +# BOARD_VIEWER_USERS — basic-auth users for Traefik middleware. +# Compose interpolates env_file values — every `$` must be escaped as `$$`. +# +# Steps: +# 1. Generate the hash: htpasswd -nbB viewer 'STRONG_PASSWORD' +# 2. Replace every `$` in the output with `$$`. +# 3. Paste below. Multiple users → comma-separated. +# +# Example before escaping: viewer:$2y$05$abc...xyz +# Example after escaping: viewer:$$2y$$05$$abc...xyz +BOARD_VIEWER_USERS=viewer:$$2y$$05$$REPLACE_WITH_REAL_BCRYPT_HASH diff --git a/deploy/Dockerfile.build b/deploy/Dockerfile.build new file mode 100644 index 0000000..1bcf9ea --- /dev/null +++ b/deploy/Dockerfile.build @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=optional --no-audit --no-fund + +COPY tsconfig.json vitest.config.ts ./ +COPY src ./src +COPY static ./static + +ENV BOARD_DIST_DIR=/output + +ENTRYPOINT ["/bin/sh", "-c", "while true; do node --experimental-strip-types src/cli.ts \"$BOARD_CONFIG_PATH\" || echo 'render failed; will retry next tick'; sleep ${BOARD_TICK_SEC:-300}; done"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..efd9639 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,134 @@ +# Deploy — `board-viewer` + +Read-only HTML kanban viewer over Gitea API. Runs as a two-service Docker +stack on the VDS, behind Traefik with basic-auth. + +``` +VDS +├── traefik (websecure → letsEncrypt) +│ └── Host(`board.kzntsv.site`) → board-viewer-web (basic-auth middleware) +└── /opt/stacks/board-viewer/ + ├── docker-compose.yml ← stack + ├── auth.toml ← Gitea token + repo whitelist (NOT committed) + ├── .env ← BOARD_VIEWER_USERS (basic-auth bcrypt creds, NOT committed) + └── nginx.conf +``` + +Two services: + +- **`board-viewer-build`** (`registry.kzntsv.site/board-viewer-build`) + Cron-loop: every `BOARD_TICK_SEC` (default 300s) runs + `node --experimental-strip-types src/cli.ts` against `auth.toml`, + writes `/output/{index.html, static/*}` into the shared volume. + +- **`board-viewer-web`** (`nginx:alpine`) + Serves the shared volume on port 80; Traefik attaches host + basic-auth. + +Volume `dist` is internal to the compose project; nginx mounts it read-only. + +--- + +## One-time setup + +### 1. DNS + +Create an A-record: + +``` +board.kzntsv.site → (TTL 3600) +``` + +Wait for propagation (a few minutes typically). + +### 2. Build & push the build image + +From a dev machine **at the repo root** (this Dockerfile expects to be +built from the project root): + +```bash +docker build -f deploy/Dockerfile.build -t registry.kzntsv.site/board-viewer-build:latest . +docker push registry.kzntsv.site/board-viewer-build:latest +``` + +(See `~/projects/.wiki` for the VDS registry login command if your daemon +isn't already authenticated.) + +### 3. Bootstrap on VDS + +SSH to the VDS, then: + +```bash +sudo mkdir -p /opt/stacks/board-viewer +cd /opt/stacks/board-viewer + +# Copy compose + nginx.conf from this repo's deploy/ dir +# (via scp / git pull / whatever you use for other stacks). +sudo cp .../docker-compose.yml . +sudo cp .../nginx.conf . + +# auth.toml — Gitea token + repo whitelist +sudo cp .../auth.toml.example auth.toml +sudo nano auth.toml # paste real gitea_token; edit board_viewer_repos list + +# .env — basic-auth credentials for the Traefik middleware +sudo cp .../.env.example .env +# Generate a bcrypt hash: +htpasswd -nbB viewer 'STRONG_PASSWORD_HERE' +# Output is `viewer:$2y$05$...`. Replace every `$` with `$$` (compose +# interpolates env_file values), then paste into BOARD_VIEWER_USERS in .env. +# Multiple users: comma-separated, each with `$$` escaping. +sudo nano .env + +sudo chmod 600 auth.toml .env + +sudo docker compose pull +sudo docker compose up -d +``` + +### 4. Verify + +```bash +# Service is up +sudo docker compose ps + +# First render happens immediately on container start; check logs: +sudo docker compose logs -f build +# expect: "wrote /output/index.html (N records from M repos)" + +# HTTP smoke (gives 401 without creds, 200 with) +curl -I https://board.kzntsv.site/ # → 401 +curl -I -u viewer:'PASS' https://board.kzntsv.site/ # → 200 +``` + +--- + +## Day-to-day + +- **Force regen now:** `sudo docker compose restart build` (next render + runs immediately on container start). +- **View render logs:** `sudo docker compose logs --tail=200 build` +- **View access logs:** `sudo docker compose logs --tail=200 web` +- **Edit repo list:** edit `/opt/stacks/board-viewer/auth.toml`, then + `sudo docker compose restart build`. +- **Add user:** generate `htpasswd -nbB 'PASS'`, escape every `$` + as `$$`, append (preceded by a comma) to `BOARD_VIEWER_USERS` in `.env`, + then `sudo docker compose up -d` (re-applies labels on the web service). +- **New version of `board-viewer-build` image:** + on dev → `docker build … && docker push …`, + on VDS → `sudo docker compose pull && sudo docker compose up -d`. + +--- + +## Disaster recovery + +The repo is the source of truth. To rebuild the stack from scratch: + +1. Restore VDS hostname + DNS A-record. +2. Restore Traefik stack (separate concern, in `/opt/stacks/traefik/`). +3. `git clone https://git.kzntsv.site/OpeItcLoc03/board-viewer.git`. +4. Follow "One-time setup" above. + +No persistent state lives in this stack — every render is freshly produced +from Gitea. The only artefacts to recreate are `auth.toml` (token from +password manager) and `.env` with `BOARD_VIEWER_USERS` (regenerate bcrypt +hash from password manager). diff --git a/deploy/auth.toml.example b/deploy/auth.toml.example new file mode 100644 index 0000000..c307c64 --- /dev/null +++ b/deploy/auth.toml.example @@ -0,0 +1,14 @@ +# Copy to ./auth.toml (in the same dir as docker-compose.yml). +# Mounted read-only into the build container at /etc/board-viewer/auth.toml. + +gitea_url = "https://git.kzntsv.site" +gitea_token = "REPLACE_WITH_GITEA_ADMIN_TOKEN" + +# Whitelist of repos to scan. Each entry is "owner/repo". +# Only listed repos are inspected; repo without .tasks/STATUS.md → skipped silently. +board_viewer_repos = [ + "OpeItcLoc03/board-viewer", + "OpeItcLoc03/books", + "OpeItcLoc03/claude-skills", + "OpeItcLoc03/projects-meta-mcp", +] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ff7921b --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,65 @@ +# Deploy: board-viewer +# Target: /opt/stacks/board-viewer/docker-compose.yml on VDS +# +# Prereqs: +# 1. DNS A-record: board.kzntsv.site → VDS IP +# 2. Traefik network `proxy` exists (it does — used by Gitea et al) +# 3. ./auth.toml contains gitea_url, gitea_token, board_viewer_repos +# 4. ./.htpasswd built via: htpasswd -nbB >> ./.htpasswd +# +# Apply: +# cd /opt/stacks/board-viewer && docker compose up -d +# +# First build is local: see deploy/README.md for image build & push to +# registry.kzntsv.site, or build on host via `docker compose build`. + +services: + build: + image: registry.kzntsv.site/board-viewer-build:latest + container_name: board-viewer-build + restart: unless-stopped + environment: + BOARD_CONFIG_PATH: /etc/board-viewer/auth.toml + BOARD_DIST_DIR: /output + BOARD_TICK_SEC: "300" + volumes: + - ./auth.toml:/etc/board-viewer/auth.toml:ro + - dist:/output + networks: + - proxy + + web: + image: nginx:alpine + container_name: board-viewer-web + restart: unless-stopped + env_file: + - .env + volumes: + - dist:/usr/share/nginx/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - proxy + depends_on: + - build + labels: + traefik.enable: "true" + traefik.docker.network: "proxy" + + traefik.http.routers.board-viewer.entrypoints: "websecure" + traefik.http.routers.board-viewer.rule: "Host(`board.kzntsv.site`)" + traefik.http.routers.board-viewer.tls.certresolver: "letsEncrypt" + traefik.http.routers.board-viewer.middlewares: "board-viewer-auth" + + # Basic-auth users live in .env as BOARD_VIEWER_USERS + # (one or more `user:bcrypt-hash` entries, comma-separated, with $$ for $). + # See deploy/README.md "One-time setup" step 3. + traefik.http.middlewares.board-viewer-auth.basicauth.users: "${BOARD_VIEWER_USERS}" + + traefik.http.services.board-viewer.loadbalancer.server.port: "80" + +volumes: + dist: + +networks: + proxy: + external: true diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..0406bd6 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80 default_server; + server_name _; + root /usr/share/nginx/html; + index index.html; + + charset utf-8; + charset_types text/plain text/css application/javascript text/html; + + location = / { + try_files /index.html =404; + add_header Cache-Control "no-cache, must-revalidate" always; + } + + location /static/ { + expires 5m; + add_header Cache-Control "public, max-age=300" always; + } + + location / { + try_files $uri $uri/ /index.html; + } +}