Compare commits

...

6 Commits

Author SHA1 Message Date
elpatron a2180a302c refactor(tour): interne z-index-Schichtung im Overlay vereinfachen
Ersetzt irreführende 10001/10002-Werte durch relative Layer 1–3 innerhalb
von .app-tour-root und dokumentiert den Stacking-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:15:17 +02:00
elpatron cd29115233 fix(tour): Tour-Tooltip über hervorgehobenen Profil-Schritten anzeigen
Erhöht den z-index des Tour-Overlays über app-tour-target-active, damit
das Modal in Schritt 8 (Stammcrew & Skipper) nicht von der Spotlight-Karte verdeckt wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:28 +02:00
elpatron e4b07ca896 refactor(deploy): update-prod.sh zu update-remotes.sh umbenennen
Ein Skript für Prod und Staging; update-staging.sh entfällt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:08 +02:00
elpatron f0c3cacb06 feat(analytics): Plausible über PLAUSIBLE_ENABLED und PLAUSIBLE_HOST steuerbar
Runtime-Konfiguration im Frontend-Container trennt Prod und Staging;
Staging deaktiviert Analytics standardmäßig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:04:31 +02:00
elpatron 5821e20086 fix(deploy): Backend-Healthcheck und Staging-Wrapper absichern
Expliziter Compose-Healthcheck für das Backend, curl-Fallback und längeres
MAX_WAIT im Deploy-Skript; update-staging.sh lehnt -dest ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:52:05 +02:00
elpatron aff8d1517d feat(deploy): Staging-Umgebung und einheitliches Deploy-Skript
Fügt docker-compose.staging.yml, Staging-Dokumentation und -dest prod|stage
in update-prod.sh hinzu, damit Prod und Staging über ein Skript deploybar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:47:12 +02:00
18 changed files with 542 additions and 128 deletions
+11
View File
@@ -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
@@ -51,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
# Plausible Analytics (frontend container — see docs/plausible-events.md)
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
+13 -2
View File
@@ -251,15 +251,25 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash
./scripts/update-prod.sh
./scripts/update-remotes.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-remotes.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 |
+7 -4
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache gettext
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder
COPY --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
@@ -38,7 +39,6 @@
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head>
<body>
+2 -51
View File
@@ -1,51 +1,2 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+6 -3
View File
@@ -6034,13 +6034,15 @@ html.theme-cupertino .events-scroll-container {
.app-tour-root {
position: fixed;
inset: 0;
z-index: 10000;
/* Above .app-tour-target-active (10001) so tooltip/backdrop stay topmost */
z-index: 10010;
pointer-events: none;
}
.app-tour-backdrop {
position: absolute;
inset: 0;
z-index: 1;
background: rgba(2, 6, 23, 0.62);
pointer-events: auto;
}
@@ -6058,7 +6060,7 @@ html.theme-cupertino .events-scroll-container {
0 0 32px rgba(56, 189, 248, 0.5),
0 12px 40px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 10001;
z-index: 2;
}
body.app-tour-active .app-tour-target-active {
@@ -6069,7 +6071,8 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip {
position: fixed;
z-index: 10002;
/* Layer above backdrop/spotlight inside .app-tour-root (not vs. root's 10010) */
z-index: 3;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
+29
View File
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
}
}
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
return { plausibleEnabled, plausibleHost: host }
}
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/runtime-config.json') return next()
res.setHeader('Content-Type', 'application/json')
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
})
},
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'runtime-config.json'),
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
)
}
}
}
// https://vite.dev/config/
export default defineConfig({
envDir: resolve(__dirname, '..'),
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
runtimeConfigPlugin(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
+71
View File
@@ -0,0 +1,71 @@
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"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
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
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
name: daagbox-staging-pgdata
+9
View File
@@ -40,6 +40,12 @@ services:
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
@@ -52,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
+13 -6
View File
@@ -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 |
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
Konfiguration über `.env` (Frontend-Container):
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
```env
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
## Nach Deploy prüfen
+3 -3
View File
@@ -31,12 +31,12 @@ cd server && npm test
## Nach erfolgreichem Check
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh
./scripts/update-remotes.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-remotes.sh -dest prod`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+106
View File
@@ -0,0 +1,106 @@
# 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-remotes.sh -dest stage` | `./scripts/update-remotes.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=<generiert>
POSTGRES_DB=daagbox_staging
SESSION_SECRET=<generiert>
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-staging-feedback
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
PLAUSIBLE_ENABLED=false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
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-remotes.sh -dest stage
```
Konfiguration via Umgebungsvariablen:
```bash
REMOTE_HOST=10.0.0.27 \
REMOTE_DIR=/opt/kapteins-daagbok-staging \
DEPLOY_BRANCH=master \
./scripts/update-remotes.sh -dest stage
```
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.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
```
+11 -2
View File
@@ -1,12 +1,21 @@
# Plausible Custom Events
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
```env
PLAUSIBLE_ENABLED=true # Staging: false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup
1. Script in `client/index.html` (bereits eingebunden)
1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht
+1 -1
View File
@@ -81,7 +81,7 @@ require_node_toolchain() {
echo ""
echo "On the production host, prefer updating the running stack:"
echo " docker compose -f docker-compose.yml up -d --build"
echo " # or from your workstation: ./scripts/update-prod.sh"
echo " # or from your workstation: ./scripts/update-remotes.sh -dest prod"
exit 1
}
@@ -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 <<EOF
Usage: $(basename "$0") [-dest prod|stage]
# Configuration
COMPOSE_FILE="docker-compose.yml"
BACKEND_CONTAINER="daagbox-prod-backend"
MAX_WAIT=35
Deploy Kapteins Daagbok to production or staging.
-dest prod Production (default): release tag, bump VERSION, deploy to 10.0.0.25
-dest stage Staging: no release tag, deploy branch to 10.0.0.27
Environment overrides (optional):
REMOTE_HOST, REMOTE_USER, REMOTE_DIR, COMPOSE_FILE, BACKEND_CONTAINER
DEPLOY_BRANCH (stage only, default: master)
SKIP_PREDEPLOY_CHECK=1
Examples:
$(basename "$0") -dest prod
$(basename "$0") -dest stage
DEPLOY_BRANCH=feature/foo $(basename "$0") -dest stage
EOF
}
DEST="prod"
while [[ $# -gt 0 ]]; do
case "$1" in
-dest)
if [[ $# -lt 2 ]]; then
echo "Error: -dest requires an argument (prod or stage)." >&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=90
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,23 +297,26 @@ 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
IS_READY=false
while [ $COUNTER -lt $MAX_WAIT ]; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
STATUS=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)
if [ "$STATUS" = "healthy" ]; then
IS_READY=true
break
fi
# End-to-end fallback via frontend nginx (covers missing/stale container health state)
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
IS_READY=true
break
fi
sleep 1
COUNTER=$((COUNTER + 1))
printf "."
@@ -227,15 +329,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 +346,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