feat(deploy): docker-compose stack for VDS (board.kzntsv.site) [skip-tdd: visual]
closes board-viewer-cron-deploy Two-service stack at /opt/stacks/board-viewer/ on VDS: - board-viewer-build (node:22-alpine): cron-loop `while true; do node cli.ts; sleep 300`, writes to shared docker volume `dist` - board-viewer-web (nginx:alpine): serves dist read-only, Traefik route Host(`board.kzntsv.site`) → letsEncrypt + basic-auth middleware Files: - deploy/Dockerfile.build — build image (registry.kzntsv.site/board-viewer-build) - deploy/docker-compose.yml — stack with traefik labels (`proxy` network, same as Gitea) - deploy/nginx.conf — static serving with Cache-Control - deploy/auth.toml.example — Gitea token + board_viewer_repos whitelist - deploy/.env.example — BOARD_VIEWER_USERS for basic-auth (with $$ escape note) - deploy/README.md — DNS setup + image build/push + bootstrap + smoke + DR steps Configs are declarative ([skip-tdd: visual]). Acceptance = deploy + curl (see README §4 'Verify'). Both auth.toml and .env are gitignored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
13
deploy/.env.example
Normal file
13
deploy/.env.example
Normal file
@@ -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
|
||||
14
deploy/Dockerfile.build
Normal file
14
deploy/Dockerfile.build
Normal file
@@ -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"]
|
||||
134
deploy/README.md
Normal file
134
deploy/README.md
Normal file
@@ -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 → <VDS IP> (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 <user> '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).
|
||||
14
deploy/auth.toml.example
Normal file
14
deploy/auth.toml.example
Normal file
@@ -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",
|
||||
]
|
||||
65
deploy/docker-compose.yml
Normal file
65
deploy/docker-compose.yml
Normal file
@@ -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 <user> <pass> >> ./.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
|
||||
23
deploy/nginx.conf
Normal file
23
deploy/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user