diff --git a/apps/manacore/apps/landing/src/content/config.ts b/apps/manacore/apps/landing/src/content/config.ts
index 5fd2b7922..2168f52d3 100644
--- a/apps/manacore/apps/landing/src/content/config.ts
+++ b/apps/manacore/apps/landing/src/content/config.ts
@@ -212,6 +212,22 @@ const manascoreCollection = defineCollection({
seo: z.number().min(0).max(100),
})
.optional(),
+ // Dependency health metrics
+ dependencies: z
+ .object({
+ total: z.number(), // Total dependency count
+ outdated: z.number(), // Packages with available updates
+ vulnerabilities: z
+ .object({
+ critical: z.number().default(0),
+ high: z.number().default(0),
+ moderate: z.number().default(0),
+ low: z.number().default(0),
+ })
+ .optional(),
+ lastChecked: z.string().optional(), // ISO date
+ })
+ .optional(),
// Score history for trend visualization
history: z
.array(
diff --git a/apps/manacore/apps/landing/src/content/manascore/2026-03-19-calendar.md b/apps/manacore/apps/landing/src/content/manascore/2026-03-19-calendar.md
index 8eeb0ba8e..33891dde3 100644
--- a/apps/manacore/apps/landing/src/content/manascore/2026-03-19-calendar.md
+++ b/apps/manacore/apps/landing/src/content/manascore/2026-03-19-calendar.md
@@ -18,6 +18,15 @@ scores:
documentation: 98
security: 92
ux: 95
+dependencies:
+ total: 42
+ outdated: 9
+ vulnerabilities:
+ critical: 0
+ high: 0
+ moderate: 0
+ low: 0
+ lastChecked: '2026-03-24'
lighthouse:
performance: 92
accessibility: 95
diff --git a/apps/manacore/apps/landing/src/content/manascore/2026-03-19-todo.md b/apps/manacore/apps/landing/src/content/manascore/2026-03-19-todo.md
index ae5c2e9e8..ec14546af 100644
--- a/apps/manacore/apps/landing/src/content/manascore/2026-03-19-todo.md
+++ b/apps/manacore/apps/landing/src/content/manascore/2026-03-19-todo.md
@@ -18,6 +18,15 @@ scores:
documentation: 95
security: 90
ux: 94
+dependencies:
+ total: 38
+ outdated: 9
+ vulnerabilities:
+ critical: 0
+ high: 0
+ moderate: 0
+ low: 0
+ lastChecked: '2026-03-24'
lighthouse:
performance: 90
accessibility: 93
diff --git a/apps/manacore/apps/landing/src/pages/manascore/[slug].astro b/apps/manacore/apps/landing/src/pages/manascore/[slug].astro
index b5814fbcb..ccc23e7e1 100644
--- a/apps/manacore/apps/landing/src/pages/manascore/[slug].astro
+++ b/apps/manacore/apps/landing/src/pages/manascore/[slug].astro
@@ -274,6 +274,113 @@ function getBarColor(score: number): string {
})()
}
+ {/* Dependency Health */}
+ {
+ audit.data.dependencies &&
+ (() => {
+ const deps = audit.data.dependencies;
+ const vulnCount = deps.vulnerabilities
+ ? deps.vulnerabilities.critical +
+ deps.vulnerabilities.high +
+ deps.vulnerabilities.moderate +
+ deps.vulnerabilities.low
+ : 0;
+ const healthPct = Math.round(((deps.total - deps.outdated) / deps.total) * 100);
+
+ return (
+
+
+
+
Dependency Health
+
+ Paketstand und Sicherheit
+ {deps.lastChecked ? ` (geprüft ${deps.lastChecked})` : ''}
+
+
+
= 80 ? 'text-emerald-500' : healthPct >= 60 ? 'text-yellow-500' : 'text-red-500'}`}
+ >
+ {healthPct}% aktuell
+
+
+
+
+ {deps.total}
+ Pakete gesamt
+
+
+ 10 ? 'text-yellow-500' : 'text-foreground'}`}
+ >
+ {deps.outdated}
+
+ Veraltet
+
+
+ 0 ? 'text-red-500' : 'text-emerald-500'}`}
+ >
+ {vulnCount}
+
+ Vulnerabilities
+
+
+ {deps.vulnerabilities ? (
+
+ {deps.vulnerabilities.critical > 0 && (
+
+ {deps.vulnerabilities.critical} Critical
+
+ )}
+ {deps.vulnerabilities.high > 0 && (
+
+ {deps.vulnerabilities.high} High
+
+ )}
+ {deps.vulnerabilities.moderate > 0 && (
+
+ {deps.vulnerabilities.moderate} Mod
+
+ )}
+ {deps.vulnerabilities.low > 0 && (
+
+ {deps.vulnerabilities.low} Low
+
+ )}
+ {vulnCount === 0 && (
+
+ ✓ Sicher
+
+ )}
+
+ ) : (
+
Keine Daten
+ )}
+
Schweregrad
+
+
+ {/* Health bar */}
+
+
+
= 80 ? 'bg-emerald-500' : healthPct >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`}
+ style={`width: ${healthPct}%`}
+ />
+
+
+
+ {deps.outdated} veraltet
+
+
+ {deps.total - deps.outdated} aktuell
+
+
+
+
+ );
+ })()
+ }
+
{/* Stats */}
{
audit.data.stats && (
diff --git a/apps/manacore/apps/landing/src/pages/manascore/index.astro b/apps/manacore/apps/landing/src/pages/manascore/index.astro
index b04385372..27d3be9bf 100644
--- a/apps/manacore/apps/landing/src/pages/manascore/index.astro
+++ b/apps/manacore/apps/landing/src/pages/manascore/index.astro
@@ -172,6 +172,46 @@ const statuses = [...new Set(sortedAudits.map((a) => a.data.status))];
))}
+ {/* Dependency health */}
+ {data.dependencies &&
+ (() => {
+ const deps = data.dependencies;
+ const vulnCount = deps.vulnerabilities
+ ? deps.vulnerabilities.critical +
+ deps.vulnerabilities.high +
+ deps.vulnerabilities.moderate +
+ deps.vulnerabilities.low
+ : 0;
+ const healthPct = Math.round(
+ ((deps.total - deps.outdated) / deps.total) * 100
+ );
+ const healthColor =
+ vulnCount > 0
+ ? deps.vulnerabilities?.critical || deps.vulnerabilities?.high
+ ? 'text-red-500'
+ : 'text-yellow-500'
+ : healthPct >= 80
+ ? 'text-emerald-500'
+ : 'text-yellow-500';
+ return (
+
+ Deps:
+
+ {deps.total} total, {deps.outdated} outdated
+
+ {vulnCount > 0 ? (
+
+ ⚠ {vulnCount} vulnerabilities
+
+ ) : (
+
+ ✓ no vulnerabilities
+
+ )}
+
+ );
+ })()}
+
{/* Lighthouse scores */}
{data.lighthouse && (
diff --git a/cloudflared-config.yml b/cloudflared-config.yml
index 94cc97d3c..8f1bcd791 100644
--- a/cloudflared-config.yml
+++ b/cloudflared-config.yml
@@ -114,5 +114,27 @@ ingress:
- hostname: glitchtip.mana.how
service: http://localhost:8020
+ # Self-Hosted Landing Pages (via Nginx on port 4400)
+ - hostname: it.mana.how
+ service: http://localhost:4400
+ - hostname: chats.mana.how
+ service: http://localhost:4400
+ - hostname: pics.mana.how
+ service: http://localhost:4400
+ - hostname: zitares.mana.how
+ service: http://localhost:4400
+ - hostname: presis.mana.how
+ service: http://localhost:4400
+ - hostname: clocks.mana.how
+ service: http://localhost:4400
+ - hostname: manadeck.mana.how
+ service: http://localhost:4400
+ - hostname: nutriphi.mana.how
+ service: http://localhost:4400
+ - hostname: citycorners.mana.how
+ service: http://localhost:4400
+ - hostname: docs.mana.how
+ service: http://localhost:4400
+
# Catch-all
- service: http_status:404
diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml
index b9d30c86c..a6a179e0e 100644
--- a/docker-compose.macmini.yml
+++ b/docker-compose.macmini.yml
@@ -87,6 +87,25 @@ services:
done
"
+ # Self-hosted Landing Pages (replaces Cloudflare Pages)
+ # Serves all Astro landing page dist/ folders via Nginx
+ # Build with: ./scripts/mac-mini/build-landings.sh
+ landings:
+ image: nginx:alpine
+ container_name: mana-infra-landings
+ restart: always
+ volumes:
+ - ./docker/nginx/landings.conf:/etc/nginx/conf.d/default.conf:ro
+ - ./docker/nginx/snippets:/etc/nginx/snippets:ro
+ - /Volumes/ManaData/landings:/srv/landings:ro
+ ports:
+ - "4400:80"
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
redis:
image: redis:7-alpine
container_name: mana-infra-redis
@@ -871,9 +890,10 @@ services:
- "4080:80"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
- interval: 120s
+ interval: 180s
timeout: 10s
retries: 3
+ start_period: 20s
matrix-web:
build:
@@ -893,9 +913,10 @@ services:
- "4090:5180"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5180/health"]
- interval: 120s
+ interval: 180s
timeout: 10s
retries: 3
+ start_period: 20s
# Matrix Bots (Ports 4010-4029)
matrix-mana-bot:
@@ -932,7 +953,7 @@ services:
- "4010:4010"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4010/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -961,7 +982,7 @@ services:
- "4011:4011"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4011/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1002,7 +1023,7 @@ services:
- "4012:4012"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4012/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1035,7 +1056,7 @@ services:
- "4013:4013"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4013/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1071,7 +1092,7 @@ services:
- "4014:4014"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4014/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1105,7 +1126,7 @@ services:
- "4015:4015"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4015/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1139,7 +1160,7 @@ services:
- "4016:4016"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4016/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1169,7 +1190,7 @@ services:
- "4017:4017"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4017/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1210,7 +1231,7 @@ services:
- "4018:4018"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4018/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1244,7 +1265,7 @@ services:
- "4019:4019"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4019/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1277,7 +1298,7 @@ services:
- "4021:4021"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4021/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1314,7 +1335,7 @@ services:
- "4020:4020"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4020/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1354,7 +1375,7 @@ services:
- "4022:4022"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:4022/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1907,9 +1928,10 @@ services:
- "8000:8000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8000/api/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 30s
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
@@ -1927,7 +1949,7 @@ services:
- "8010:3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/heartbeat"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
start_period: 40s
@@ -1956,9 +1978,10 @@ services:
- "9090:9090"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:9090/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 20s
pushgateway:
image: prom/pushgateway:v1.7.0
@@ -1968,9 +1991,10 @@ services:
- "9091:9091"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:9091/-/healthy"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 10s
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
@@ -1987,9 +2011,10 @@ services:
- "9110:8080"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 20s
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.15.0
@@ -2036,9 +2061,10 @@ services:
- "9100:9100"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:9100/metrics"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 10s
# ============================================
# Alerting Stack (Ports 9093-9095)
@@ -2067,9 +2093,10 @@ services:
- "8880:8880"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8880/health"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 20s
alertmanager:
image: prom/alertmanager:v0.27.0
@@ -2089,9 +2116,10 @@ services:
- "9093:9093"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:9093/-/healthy"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 10s
alert-notifier:
build:
@@ -2109,10 +2137,10 @@ services:
- "9095:8080"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/health"]
- interval: 120s
+ interval: 300s
timeout: 5s
retries: 3
- start_period: 5s
+ start_period: 10s
# ============================================
# Auto-Update (Watchtower)
@@ -2162,9 +2190,10 @@ services:
condition: service_healthy
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8020/_health/')"]
- interval: 120s
+ interval: 300s
timeout: 10s
retries: 3
+ start_period: 40s
glitchtip-worker:
image: glitchtip/glitchtip:latest
@@ -2203,9 +2232,10 @@ services:
- "5100:5100"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5100/"]
- interval: 120s
+ interval: 180s
timeout: 10s
retries: 3
+ start_period: 20s
volumes:
redis_data:
diff --git a/docker/nginx/landings.conf b/docker/nginx/landings.conf
new file mode 100644
index 000000000..563109109
--- /dev/null
+++ b/docker/nginx/landings.conf
@@ -0,0 +1,108 @@
+# Nginx Configuration for Self-Hosted Landing Pages
+# Each server block serves a different landing page from its dist/ directory
+# All traffic comes through Cloudflare Tunnel → localhost:4400
+
+# Shared settings
+gzip on;
+gzip_vary on;
+gzip_min_length 1024;
+gzip_types text/plain text/css text/xml text/javascript application/javascript application/json image/svg+xml;
+
+# Default server (catch-all → it.mana.how as homepage)
+server {
+ listen 80 default_server;
+ server_name _;
+ root /srv/landings/it;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# it.mana.how — European Tech Independence
+server {
+ listen 80;
+ server_name it.mana.how;
+ root /srv/landings/it;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# chats.mana.how — Chat Landing
+server {
+ listen 80;
+ server_name chats.mana.how;
+ root /srv/landings/chat;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# pics.mana.how — Picture Landing
+server {
+ listen 80;
+ server_name pics.mana.how;
+ root /srv/landings/picture;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# zitares.mana.how — Zitare Landing
+server {
+ listen 80;
+ server_name zitares.mana.how;
+ root /srv/landings/zitare;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# presis.mana.how — Presi Landing
+server {
+ listen 80;
+ server_name presis.mana.how;
+ root /srv/landings/presi;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# clocks.mana.how — Clock Landing
+server {
+ listen 80;
+ server_name clocks.mana.how;
+ root /srv/landings/clock;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# manadeck.mana.how — ManaDeck Landing
+server {
+ listen 80;
+ server_name manadeck.mana.how;
+ root /srv/landings/manadeck;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# nutriphi.mana.how — NutriPhi Landing
+server {
+ listen 80;
+ server_name nutriphi.mana.how;
+ root /srv/landings/nutriphi;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# citycorners.mana.how — CityCorners Landing
+server {
+ listen 80;
+ server_name citycorners.mana.how;
+ root /srv/landings/citycorners;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
+
+# docs.mana.how — Documentation
+server {
+ listen 80;
+ server_name docs.mana.how;
+ root /srv/landings/docs;
+ index index.html;
+ include /etc/nginx/snippets/landing-common.conf;
+}
diff --git a/docker/nginx/snippets/landing-common.conf b/docker/nginx/snippets/landing-common.conf
new file mode 100644
index 000000000..26bfd99cc
--- /dev/null
+++ b/docker/nginx/snippets/landing-common.conf
@@ -0,0 +1,24 @@
+# Common settings for all landing page server blocks
+
+# Security headers
+add_header X-Frame-Options "SAMEORIGIN" always;
+add_header X-Content-Type-Options "nosniff" always;
+add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+# Cache static assets aggressively (Astro hashes filenames)
+location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot|webp|avif)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+}
+
+# SPA fallback: try file, then directory, then index.html
+location / {
+ try_files $uri $uri/ $uri/index.html /index.html;
+}
+
+# Health check
+location /health {
+ access_log off;
+ return 200 "healthy\n";
+ add_header Content-Type text/plain;
+}
diff --git a/docs/CLOUDFLARE_FALLBACK.md b/docs/CLOUDFLARE_FALLBACK.md
new file mode 100644
index 000000000..493ba2ba2
--- /dev/null
+++ b/docs/CLOUDFLARE_FALLBACK.md
@@ -0,0 +1,285 @@
+# Cloudflare Tunnel Fallback-Plan
+
+> Was tun wenn Cloudflare ausfällt oder den Account sperrt?
+
+## Risiko
+
+Cloudflare Tunnel ist der **einzige** Weg vom Internet zum Mac Mini. Wenn Cloudflare nicht erreichbar ist:
+- Alle *.mana.how Subdomains sind offline
+- SSH nur im lokalen Netzwerk möglich
+- Kein Deployment, kein Monitoring
+
+**Wahrscheinlichkeit:** Gering (Cloudflare hat >99.99% Uptime), aber Accountsperren oder Policy-Änderungen sind ein Risiko.
+
+## Plan B: WireGuard + Caddy auf Hetzner VPS
+
+### Architektur
+
+```
+Internet
+ │
+ ▼
+Hetzner VPS (€3.79/Monat, CX22)
+├── Caddy (Reverse Proxy + Auto-TLS)
+├── WireGuard Server
+└── DNS: *.mana.how → VPS IP
+ │
+ │ WireGuard Tunnel (verschlüsselt)
+ │
+ ▼
+Mac Mini (WireGuard Client)
+├── Alle Services auf localhost
+└── Erreichbar über WireGuard-IP (z.B. 10.0.0.2)
+```
+
+### Vorteile
+
+- **Kein Vendor Lock-in:** Hetzner ist deutscher Anbieter
+- **Eigene IP:** Keine Abhängigkeit von Cloudflare Proxy
+- **WireGuard:** Schneller als Cloudflare Tunnel (~10% weniger Latenz)
+- **Let's Encrypt:** Caddy macht TLS automatisch
+- **Kosten:** €3.79/Monat (CX22: 2 vCPU, 4 GB RAM, 40 GB SSD)
+
+### Einrichtung VPS (einmalig, ~1 Stunde)
+
+#### 1. Hetzner VPS erstellen
+
+```bash
+# CX22 (kleinster mit genug RAM für Caddy + WireGuard)
+# Standort: Falkenstein (DE) oder Nürnberg (DE)
+# OS: Ubuntu 24.04
+# SSH Key: Mac Mini public key
+```
+
+#### 2. WireGuard installieren
+
+**Auf dem VPS:**
+```bash
+apt update && apt install -y wireguard
+
+# Keys generieren
+wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
+chmod 600 /etc/wireguard/server_private.key
+
+# Config erstellen
+cat > /etc/wireguard/wg0.conf << EOF
+[Interface]
+Address = 10.0.0.1/24
+PrivateKey = $(cat /etc/wireguard/server_private.key)
+ListenPort = 51820
+
+[Peer]
+# Mac Mini
+PublicKey =
+AllowedIPs = 10.0.0.2/32
+EOF
+
+systemctl enable --now wg-quick@wg0
+```
+
+**Auf dem Mac Mini:**
+```bash
+brew install wireguard-tools
+
+# Keys generieren
+wg genkey | tee /etc/wireguard/client_private.key | wg pubkey > /etc/wireguard/client_public.key
+
+# Config
+cat > /etc/wireguard/wg0.conf << EOF
+[Interface]
+Address = 10.0.0.2/24
+PrivateKey = $(cat /etc/wireguard/client_private.key)
+
+[Peer]
+PublicKey =
+Endpoint = :51820
+AllowedIPs = 10.0.0.0/24
+PersistentKeepalive = 25
+EOF
+
+wg-quick up wg0
+```
+
+#### 3. Caddy installieren (VPS)
+
+```bash
+apt install -y debian-keyring debian-archive-keyring apt-transport-https
+curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
+curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
+apt update && apt install caddy
+```
+
+#### 4. Caddyfile erstellen (VPS)
+
+```Caddyfile
+# /etc/caddy/Caddyfile
+# Alle Domains → Mac Mini via WireGuard
+
+mana.how {
+ reverse_proxy 10.0.0.2:5000
+}
+
+auth.mana.how {
+ reverse_proxy 10.0.0.2:3001
+}
+
+chat.mana.how {
+ reverse_proxy 10.0.0.2:5010
+}
+
+chat-api.mana.how {
+ reverse_proxy 10.0.0.2:3030
+}
+
+todo.mana.how {
+ reverse_proxy 10.0.0.2:5011
+}
+
+todo-api.mana.how {
+ reverse_proxy 10.0.0.2:3031
+}
+
+calendar.mana.how {
+ reverse_proxy 10.0.0.2:5012
+}
+
+calendar-api.mana.how {
+ reverse_proxy 10.0.0.2:3032
+}
+
+clock.mana.how {
+ reverse_proxy 10.0.0.2:5013
+}
+
+clock-api.mana.how {
+ reverse_proxy 10.0.0.2:3033
+}
+
+contacts.mana.how {
+ reverse_proxy 10.0.0.2:5014
+}
+
+contacts-api.mana.how {
+ reverse_proxy 10.0.0.2:3034
+}
+
+storage.mana.how {
+ reverse_proxy 10.0.0.2:5015
+}
+
+storage-api.mana.how {
+ reverse_proxy 10.0.0.2:3035
+}
+
+presi.mana.how {
+ reverse_proxy 10.0.0.2:5016
+}
+
+presi-api.mana.how {
+ reverse_proxy 10.0.0.2:3036
+}
+
+nutriphi.mana.how {
+ reverse_proxy 10.0.0.2:5017
+}
+
+nutriphi-api.mana.how {
+ reverse_proxy 10.0.0.2:3037
+}
+
+photos.mana.how {
+ reverse_proxy 10.0.0.2:5019
+}
+
+photos-api.mana.how {
+ reverse_proxy 10.0.0.2:3039
+}
+
+mukke.mana.how {
+ reverse_proxy 10.0.0.2:5180
+}
+
+picture.mana.how {
+ reverse_proxy 10.0.0.2:5021
+}
+
+picture-api.mana.how {
+ reverse_proxy 10.0.0.2:3040
+}
+
+playground.mana.how {
+ reverse_proxy 10.0.0.2:5090
+}
+
+matrix.mana.how {
+ reverse_proxy 10.0.0.2:4000
+}
+
+element.mana.how {
+ reverse_proxy 10.0.0.2:4080
+}
+
+grafana.mana.how {
+ reverse_proxy 10.0.0.2:8000
+}
+
+stats.mana.how {
+ reverse_proxy 10.0.0.2:8010
+}
+
+glitchtip.mana.how {
+ reverse_proxy 10.0.0.2:8020
+}
+```
+
+#### 5. DNS umstellen (Failover-Schritt)
+
+Beim Ausfall von Cloudflare Tunnel:
+
+```bash
+# 1. WireGuard-Verbindung prüfen
+ssh mana-server "ping -c1 10.0.0.1" # Ping VPS via WireGuard
+
+# 2. DNS bei Cloudflare umstellen (alle *.mana.how → VPS IP)
+# Cloudflare Dashboard → DNS → *.mana.how → A Record →
+# ODER falls Cloudflare komplett down:
+# Domain zu anderem DNS-Provider transferieren (vorher vorbereiten!)
+
+# 3. Caddy starten
+ssh vps "systemctl start caddy"
+
+# 4. Prüfen
+curl https://mana.how # Sollte über VPS → WireGuard → Mac Mini routen
+```
+
+## Failover-Checkliste
+
+| # | Schritt | Zeit | Verantwortlich |
+|---|---------|------|----------------|
+| 1 | Feststellen: Cloudflare Tunnel ist down | Auto (Health Check Alert) | Automatisch |
+| 2 | VPS WireGuard-Verbindung prüfen | 1 Min | Admin |
+| 3 | DNS auf VPS-IP umstellen | 5 Min | Admin (Cloudflare Dashboard) |
+| 4 | Caddy aktivieren | 1 Min | Admin (SSH zu VPS) |
+| 5 | TLS-Zertifikate generieren lassen | 2-5 Min | Automatisch (Caddy + Let's Encrypt) |
+| 6 | Alle Services testen | 5 Min | Admin |
+| **Gesamt** | | **~15 Min** | |
+
+## Vorbereitung (jetzt erledigen)
+
+- [ ] Hetzner Account erstellen
+- [ ] VPS bestellen (CX22, €3.79/Monat)
+- [ ] WireGuard einrichten (VPS + Mac Mini)
+- [ ] WireGuard-Verbindung testen
+- [ ] Caddyfile erstellen (alle Domains)
+- [ ] DNS-Failover-Prozedur testen (mit Test-Subdomain)
+- [ ] Failover-Checkliste ausdrucken / im Wiki speichern
+
+## Plan C: Direkte IP
+
+Falls auch Hetzner nicht verfügbar:
+1. ISP kontaktieren für feste IP-Adresse
+2. Port-Forwarding auf Router einrichten (80, 443)
+3. Let's Encrypt Zertifikat via DNS-Challenge (kein HTTP nötig)
+4. DNS bei einem dritten Provider (z.B. Hetzner DNS, Gandi)
+
+**Nachteil:** Consumer-ISPs blockieren oft Port 25 (E-Mail) und Port 80/443 ist nicht garantiert.
diff --git a/scripts/mac-mini/build-landings.sh b/scripts/mac-mini/build-landings.sh
new file mode 100755
index 000000000..c141e62c1
--- /dev/null
+++ b/scripts/mac-mini/build-landings.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+# Build all landing pages and copy dist/ to the shared nginx volume
+# Run on the Mac Mini after git pull
+#
+# Usage: ./scripts/mac-mini/build-landings.sh
+
+set -e
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+OUTPUT_DIR="/Volumes/ManaData/landings"
+
+echo "=== Building Landing Pages ==="
+echo "Output: $OUTPUT_DIR"
+echo ""
+
+mkdir -p "$OUTPUT_DIR"
+
+# Landing pages to build (filter name → dist path → output name)
+declare -A LANDINGS=(
+ ["it"]="services/it-landing"
+ ["chat"]="apps/chat/apps/landing"
+ ["picture"]="apps/picture/apps/landing"
+ ["zitare"]="apps/zitare/apps/landing"
+ ["presi"]="apps/presi/apps/landing"
+ ["clock"]="apps/clock/apps/landing"
+ ["manadeck"]="apps/manadeck/apps/landing"
+ ["nutriphi"]="apps/nutriphi/apps/landing"
+ ["citycorners"]="apps/citycorners/apps/landing"
+)
+
+cd "$PROJECT_ROOT"
+
+for name in "${!LANDINGS[@]}"; do
+ dir="${LANDINGS[$name]}"
+ if [ -d "$dir" ]; then
+ echo "Building $name ($dir)..."
+ pnpm --filter "./$dir" build 2>&1 | tail -3
+
+ # Copy dist to output
+ if [ -d "$dir/dist" ]; then
+ rm -rf "$OUTPUT_DIR/$name"
+ cp -r "$dir/dist" "$OUTPUT_DIR/$name"
+ echo " → $OUTPUT_DIR/$name ($(du -sh "$OUTPUT_DIR/$name" | cut -f1))"
+ else
+ echo " ⚠ No dist/ found for $name"
+ fi
+ else
+ echo " ⚠ Directory not found: $dir"
+ fi
+ echo ""
+done
+
+echo "=== Done ==="
+echo ""
+echo "Restart nginx to pick up changes:"
+echo " docker restart mana-infra-landings"