diff --git a/.env.example b/.env.example index 633b15d..de4eefd 100755 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ DeepLAPIKey= # Production (kapteins-daagbok.eu): # RP_ID=kapteins-daagbok.eu # ORIGIN=https://kapteins-daagbok.eu +# Staging (staging.kapteins-daagbok.eu): +# RP_ID=staging.kapteins-daagbok.eu +# ORIGIN=https://staging.kapteins-daagbok.eu +# POSTGRES_DB=daagbox_staging +# NTFY_TOPIC=kapteins-daagbok-staging-feedback RP_ID=localhost # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) ORIGIN=http://localhost:5173 diff --git a/README.md b/README.md index 7718072..2e62597 100644 --- a/README.md +++ b/README.md @@ -251,15 +251,25 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ ```bash -./scripts/update-prod.sh +./scripts/update-prod.sh -dest prod ``` -Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. +Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md). +### Staging + +Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag: + +```bash +./scripts/update-prod.sh -dest stage +``` + +Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md). + ## Dokumentation | Dokument | Inhalt | @@ -267,6 +277,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen | [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI | | [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle | +| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan | diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..cfbc862 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,62 @@ +services: + db: + image: postgres:16-alpine + container_name: daagbox-staging-db + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./server + dockerfile: Dockerfile + container_name: daagbox-staging-backend + restart: always + environment: + PORT: 5000 + DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public" + RP_ID: ${RP_ID:-localhost} + ORIGIN: ${ORIGIN:-http://localhost} + TRUST_PROXY: ${TRUST_PROXY:-1} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} + VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} + OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} + OpenRouterAPIKey: ${OpenRouterAPIKey:-} + OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} + SESSION_SECRET: ${SESSION_SECRET:-} + ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} + NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} + NTFY_TOPIC: ${NTFY_TOPIC:-} + NTFY_TOKEN: ${NTFY_TOKEN:-} + command: sh -c "npx prisma db push && node dist/index.js" + depends_on: + db: + condition: service_healthy + + frontend: + build: + context: . + dockerfile: client/Dockerfile + args: + APP_VERSION: ${APP_VERSION:-0.1.0.0-dev} + container_name: daagbox-staging-frontend + restart: always + ports: + - "80:80" + depends_on: + backend: + condition: service_healthy + +volumes: + pgdata: + name: daagbox-staging-pgdata diff --git a/docs/deployment/npm-security.md b/docs/deployment/npm-security.md index 6a1b4ed..c45007e 100644 --- a/docs/deployment/npm-security.md +++ b/docs/deployment/npm-security.md @@ -1,14 +1,14 @@ # Deployment: Nginx Proxy Manager & Security (Sprint 1) -Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`). +Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging). ## NPM Proxy Host | Einstellung | Wert | |-------------|------| -| Domain | `kapteins-daagbok.eu` | +| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` | | Scheme | `https` | -| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) | +| Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) | | Forward Port | `80` (Frontend-Nginx) | | Websockets | an, falls genutzt | | Block Common Exploits | an | diff --git a/docs/deployment/predeploy.md b/docs/deployment/predeploy.md index 9a67635..b15d170 100644 --- a/docs/deployment/predeploy.md +++ b/docs/deployment/predeploy.md @@ -34,9 +34,9 @@ cd server && npm test [`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy). ```bash -./scripts/update-prod.sh +./scripts/update-prod.sh -dest prod ``` -Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh` +Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest prod` Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)). diff --git a/docs/deployment/staging.md b/docs/deployment/staging.md new file mode 100644 index 0000000..5951302 --- /dev/null +++ b/docs/deployment/staging.md @@ -0,0 +1,102 @@ +# Staging-Umgebung + +Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion. + +## Unterschiede zu Produktion + +| | Staging | Produktion | +|---|---------|------------| +| Host | `10.0.0.27` | `10.0.0.25` | +| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` | +| Compose | `docker-compose.staging.yml` | `docker-compose.yml` | +| Deploy-Skript | `./scripts/update-prod.sh -dest stage` | `./scripts/update-prod.sh -dest prod` | +| Release-Tag | nein | ja (`v*`) | +| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` | + +Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration. + +## Erstinstallation (VM3) + +```bash +ssh root@10.0.0.27 + +git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging +cd /opt/kapteins-daagbok-staging +git checkout master + +# .env anlegen — Secrets neu generieren, nicht von Prod kopieren +openssl rand -hex 24 # POSTGRES_PASSWORD +openssl rand -base64 48 # SESSION_SECRET + +nano .env +docker compose -f docker-compose.staging.yml up -d --build +``` + +### `.env` (Staging) + +```env +ORIGIN=https://staging.kapteins-daagbok.eu +RP_ID=staging.kapteins-daagbok.eu +TRUST_PROXY=1 + +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +POSTGRES_DB=daagbox_staging + +SESSION_SECRET= + +NTFY_SERVER=https://ntfy.sh +NTFY_TOPIC=kapteins-daagbok-staging-feedback +``` + +Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`. + +## Deploy vom Entwicklungsrechner + +Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag: + +```bash +./scripts/update-prod.sh -dest stage +``` + +Konfiguration via Umgebungsvariablen: + +```bash +REMOTE_HOST=10.0.0.27 \ +REMOTE_DIR=/opt/kapteins-daagbok-staging \ +DEPLOY_BRANCH=master \ +./scripts/update-prod.sh -dest stage +``` + +Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh -dest stage` + +## NPM (VM1) + +| Einstellung | Wert | +|-------------|------| +| Domain | `staging.kapteins-daagbok.eu` | +| Forward Hostname / IP | `10.0.0.27` | +| Forward Port | `80` | +| SSL | Let's Encrypt | + +Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren). + +Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md). + +## Nach Deploy prüfen + +1. https://staging.kapteins-daagbok.eu/api/health — `status: ok` +2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging) +3. Passkey Login +4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax` + +## Daten zurücksetzen + +Staging-Daten sind wegwerfbar: + +```bash +cd /opt/kapteins-daagbok-staging +docker compose -f docker-compose.staging.yml down +docker volume rm daagbox-staging-pgdata +docker compose -f docker-compose.staging.yml up -d +``` diff --git a/scripts/update-prod.sh b/scripts/update-prod.sh index dd77127..5a769f3 100755 --- a/scripts/update-prod.sh +++ b/scripts/update-prod.sh @@ -2,28 +2,102 @@ set -euo pipefail -# Remote deployment configuration -# Override any of these via environment variables if needed, e.g.: -# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh -REMOTE_USER="${REMOTE_USER:-root}" -REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}" -REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}" -REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}" +usage() { + cat <&2 + usage + exit 1 + fi + DEST="$2" + shift 2 + ;; + -dest=*) + DEST="${1#*=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +case "$DEST" in + prod|stage) ;; + *) + echo "Error: Invalid -dest '$DEST' (use prod or stage)." >&2 + usage + exit 1 + ;; +esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERSION_FILE="$REPO_ROOT/VERSION" DEFAULT_VERSION="0.1.0.0" +MAX_WAIT=35 + +REMOTE_USER="${REMOTE_USER:-root}" + +if [[ "$DEST" == "stage" ]]; then + REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}" + REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok-staging}" + COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.staging.yml}" + BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}" + APP_URL="${APP_URL:-https://staging.kapteins-daagbok.eu}" + DEPLOY_BRANCH="${DEPLOY_BRANCH:-master}" + ENV_LABEL="Staging" +else + REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}" + REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}" + COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" + BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}" + APP_URL="${APP_URL:-https://kapteins-daagbok.eu}" + DEPLOY_BRANCH="" + ENV_LABEL="Production" +fi + +REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}" echo "==================================================" -echo " Kapteins Daagbok Prod Environment Update " +echo " Kapteins Daagbok ${ENV_LABEL} Update" echo "==================================================" -echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}" +echo "Destination: ${DEST}" +echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}" +if [[ "$DEST" == "stage" ]]; then + echo "Branch: ${DEPLOY_BRANCH}" +fi +echo "URL: ${APP_URL}" echo "==================================================" cd "$REPO_ROOT" @@ -123,7 +197,11 @@ prepare_release() { export APP_VERSION="$release_version" } -prepare_release +if [[ "$DEST" == "prod" ]]; then + prepare_release +else + APP_VERSION="$(read_current_version)" +fi if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)." @@ -135,56 +213,77 @@ else fi echo "==================================================" -echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}" +echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}" echo "==================================================" -# Run the whole update procedure remotely over SSH. ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \ - "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" "$APP_VERSION" <<'REMOTE_SCRIPT' + "$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT' set -uo pipefail REMOTE_DIR="$1" COMPOSE_FILE="$2" BACKEND_CONTAINER="$3" MAX_WAIT="$4" -REMOTE_HOST="$5" +APP_URL="$5" APP_VERSION="$6" +DEST="$7" +DEPLOY_BRANCH="$8" cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; } -echo "Syncing repository from origin..." -CURRENT_BRANCH="$(git branch --show-current)" -if [ -z "$CURRENT_BRANCH" ]; then - echo "Error: Could not determine current Git branch." - exit 1 -fi - if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then echo "Warning: Local changes on deployment host will be discarded." fi -git fetch --tags origin -if [ $? -ne 0 ]; then - echo "Error: Git fetch failed." - exit 1 -fi +if [[ "$DEST" == "stage" ]]; then + echo "Syncing repository from origin/${DEPLOY_BRANCH}..." + git fetch origin + if [ $? -ne 0 ]; then + echo "Error: Git fetch failed." + exit 1 + fi + git checkout "$DEPLOY_BRANCH" 2>/dev/null || git checkout -b "$DEPLOY_BRANCH" "origin/${DEPLOY_BRANCH}" + git reset --hard "origin/${DEPLOY_BRANCH}" + if [ $? -ne 0 ]; then + echo "Error: Git reset to origin/${DEPLOY_BRANCH} failed." + exit 1 + fi +else + echo "Syncing repository from origin..." + CURRENT_BRANCH="$(git branch --show-current)" + if [ -z "$CURRENT_BRANCH" ]; then + echo "Error: Could not determine current Git branch." + exit 1 + fi -git reset --hard "origin/${CURRENT_BRANCH}" -if [ $? -ne 0 ]; then - echo "Error: Git reset to origin/${CURRENT_BRANCH} failed." - exit 1 -fi + git fetch --tags origin + if [ $? -ne 0 ]; then + echo "Error: Git fetch failed." + exit 1 + fi -REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)" -if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then - echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})." - echo " Building deployed release v${APP_VERSION}." + git reset --hard "origin/${CURRENT_BRANCH}" + if [ $? -ne 0 ]; then + echo "Error: Git reset to origin/${CURRENT_BRANCH} failed." + exit 1 + fi + + REMOTE_VERSION="$(tr -d '[:space:]' < VERSION)" + if [ "$REMOTE_VERSION" != "$APP_VERSION" ]; then + echo "Note: Remote VERSION file already points to next release (v${REMOTE_VERSION})." + echo " Building deployed release v${APP_VERSION}." + fi fi export APP_VERSION="$APP_VERSION" -echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..." -docker compose -f "$COMPOSE_FILE" build --no-cache +if [[ "$DEST" == "prod" ]]; then + echo "Rebuilding Docker images without cache (APP_VERSION=${APP_VERSION})..." + docker compose -f "$COMPOSE_FILE" build --no-cache +else + echo "Rebuilding Docker images (APP_VERSION=${APP_VERSION})..." + docker compose -f "$COMPOSE_FILE" build +fi if [ $? -ne 0 ]; then echo "Error: Docker compose build failed." exit 1 @@ -198,10 +297,7 @@ if [ $? -ne 0 ]; then fi echo "Cleaning up old/unused Docker resources..." -docker system prune -f -if [ $? -ne 0 ]; then - echo "Warning: Docker system prune failed to run completely." -fi +docker system prune -f || echo "Warning: Docker system prune failed." echo "Waiting for services to become healthy..." COUNTER=0 @@ -227,15 +323,15 @@ docker compose -f "$COMPOSE_FILE" ps echo "==================================================" if [ "$IS_READY" = true ]; then - echo "SUCCESS: Production environment updated and healthy!" - echo " -> Version: v${APP_VERSION}" - echo " -> App Frontend (Nginx): http://${REMOTE_HOST}" - echo " -> Backend API Health: http://${REMOTE_HOST}/api/health" + echo "SUCCESS: ${DEST} environment updated and healthy!" + echo " -> Version: v${APP_VERSION}" + echo " -> App Frontend: ${APP_URL}" + echo " -> Backend API Health: ${APP_URL}/api/health" echo "==================================================" else echo "WARNING: Backend did not transition to healthy in time." - echo "Check backend container logs for details:" - echo " -> docker compose logs backend" + echo "Check backend container logs:" + echo " -> docker compose -f ${COMPOSE_FILE} logs backend" echo "==================================================" exit 3 fi @@ -244,11 +340,11 @@ REMOTE_SCRIPT REMOTE_EXIT=$? echo "==================================================" if [ $REMOTE_EXIT -eq 0 ]; then - echo "Remote update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})." + echo "${ENV_LABEL} update completed successfully on ${REMOTE_TARGET} (v${APP_VERSION})." elif [ $REMOTE_EXIT -eq 3 ]; then - echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}." + echo "${ENV_LABEL} update finished, but the backend was not healthy in time on ${REMOTE_TARGET}." else - echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})." + echo "${ENV_LABEL} update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})." fi echo "==================================================" exit $REMOTE_EXIT diff --git a/scripts/update-staging.sh b/scripts/update-staging.sh new file mode 100755 index 0000000..807e8a5 --- /dev/null +++ b/scripts/update-staging.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Backward-compatible wrapper — prefer: ./scripts/update-prod.sh -dest stage +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/update-prod.sh" -dest stage "$@"