feat: rootless Image (UID 10001), /api/health + Middleware-Ausnahme, Deploy-Compose mit Registry-Image, Gitea-CI mit Image-Push, README-Neufassung
Docker Image / test (push) Failing after 58s
Docker Image / image (push) Skipped

This commit is contained in:
opencode committed 2026-10-19 19:38:09 +02:00
1 parent 92f95e5812
commit ebb52d35c7
9 files changed
+270 -35

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
node_modules
.next
.git
.gitea
.worktrees
.superpowers
Dockerfile
+3
View File
@@ -23,3 +23,6 @@ APP_INTERNAL_URL=http://app:3000
ALLOW_PASSWORD_LOGIN=true
TZ=Europe/Berlin
# Optional: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (stattdessen UI-Einstellungen nutzen)
# Deployment-Variante: docker-compose.deploy.yml nutzt das fertige öffentliche
# Image gitea.lokcal.de/loki/gutscheintracker:latest – dafür ist auf dem
# Zielserver kein Build und kein Quellcode nötig (Details siehe README).
+53
View File
@@ -0,0 +1,53 @@
name: Docker Image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
# Nur der jeweils neueste Push läuft; ältere werdende Läufe werden verworfen.
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: gitea.lokcal.de
IMAGE: gitea.lokcal.de/loki/gutscheintracker
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci --no-audit --no-fund
- run: npm test -- --run
- run: npx tsc --noEmit
- run: npm run build
image:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Registry-Login
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.ACTION_BOT }}
run: |
if [ -z "$REGISTRY_TOKEN" ]; then
echo "Registry login fehlgeschlagen: Secret REGISTRY_TOKEN oder ACTION_BOT fehlt." >&2
exit 1
fi
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
- run: docker build -t "$IMAGE:latest" -t "$IMAGE:${{ github.sha }}" .
- run: docker push "$IMAGE:latest"
- run: docker push "$IMAGE:${{ github.sha }}"
+17 -5
View File
@@ -2,18 +2,30 @@ FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S app && adduser -S app -G app && mkdir -p /data && chown app:app /data
COPY --from=builder --chown=app:app /app/.next/standalone ./
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
COPY --from=builder --chown=app:app /app/prisma ./prisma
USER app
# Rootless: feste UID/GID, damit Host-Volumes und Deployments reproduzierbar
# dieselbe Identität sehen (kein zufälliges System-UID je nach Build-Reihenfolge).
# /data gehört der App, damit SQLite ohne Root-Rechte schreiben kann.
RUN addgroup -S -g 10001 app && \
adduser -S -u 10001 -G app app && \
mkdir -p /data && chown 10001:10001 /data
COPY --from=builder --chown=10001:10001 /app/.next/standalone ./
COPY --from=builder --chown=10001:10001 /app/.next/static ./.next/static
COPY --from=builder --chown=10001:10001 /app/prisma ./prisma
# Hinweis: Caddyfile und Compose-Dateien gehören zum Deployment, nicht ins Image
# (siehe .dockerignore) – das Image enthält nur die App.
USER 10001:10001
EXPOSE 3000
# Liveness-Probe gegen den unauthentifizierten /api/health-Endpunkt.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
+116 -29
View File
@@ -1,26 +1,124 @@
# GutscheinNest
Private Gutscheinverwaltung für einen Haushalt (Next.js + SQLite + Prisma). Gemeinsamer Bestand, Anmeldung per Passwort oder SSO (OIDC), Erinnerungen per Mail/Telegram.
Private Gutscheinverwaltung für einen oder mehrere Haushalte (Next.js + SQLite + Prisma). Gemeinsame und private Bestände, Anmeldung per Passwort oder SSO (OIDC), Erinnerungen per Mail/Telegram.
Rollen: Der erste Nutzer ist Admin (wird beim Start aus `APP_USER`/`APP_PASSWORD_HASH` angelegt). Admins verwalten Nutzer, Einladungen und Einstellungen; alle Mitglieder verwalten gemeinsam Gutscheine.
Rollen: Der erste Nutzer ist Server-Admin (wird beim Start aus `APP_USER`/`APP_PASSWORD_HASH` angelegt). Server-Admins verwalten Nutzer, Einladungen, Haushalte und Einstellungen; Haushalts-Admins verwalten ihren eigenen Haushalt; alle Mitglieder verwalten gemeinsam Gutscheine.
## Öffentlich betreiben (Docker + Caddy + HTTPS)
## Funktionen
Voraussetzungen auf dem Server: Docker mit Compose-Plugin, eine Domain mit A/AAAA-Record auf die Server-IP, offene Ports 80/443 (plus SSH für dich). Port 3000 wird **nicht** geöffnet — die App ist nur im internen Compose-Netz erreichbar.
- Private und Haushalts-Gutscheinbestände (strikte Trennung; ein Nutzer gehört höchstens einem Haushalt)
- Einladungen per E-Mail (7 Tage gültig), Passwort-Login und OIDC/SSO (Authentik u. a.)
- Erinnerungen per Mail/Telegram mit frei wählbaren Vorlauf-Tagen
- Haushalts-Export als JSON (ohne Secrets) für Server- und Haushalts-Admins
- Kontakte, Kategorien, Suche, Erinnerungsregeln, Backup-Endpunkt für Admins
## Architektur
- `app/` – Next.js-App (App Router), API unter `app/api/`
- `lib/` – DB-Zugriff, Auth, Zugriffsregeln (`access.ts`), Scheduler
- `prisma/schema.prisma` – SQLite-Schema; `lib/db-migrate.ts` legt beim Boot fehlende Tabellen/Spalten an (frische und alte DBs)
- `instrumentation.ts` → `lib/scheduler.ts` – Erinnerungs-Cron alle 15 Minuten (intern über `APP_INTERNAL_URL`, kein Hairpin über den Proxy)
- Reverse-Proxy: Caddy (automatische Let's-Encrypt-Zertifikate, HTTP→HTTPS)
- Container: rootless – der App-Prozess läuft als UID:GID `10001:10001`, nie als Root; Healthcheck gegen `/api/health`
## Schnellstart: Deployment auf einem anderen Server (fertiges Image, empfohlen)
Voraussetzungen auf dem Zielserver: Docker mit Compose-Plugin, eine Domain mit A/AAAA-Record auf die Server-IP, offene Ports 80/443 (plus SSH). Port 3000 wird **nicht** geöffnet – die App ist nur im internen Compose-Netz erreichbar.
```bash
cp .env.example .env # Pflicht: DOMAIN (z. B. gutscheine.example.de), APP_URL=https://<DOMAIN>, APP_USER, APP_PASSWORD_HASH, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, CRON_SECRET
# 1) Dateien holen (nur Compose + Caddy + .env-Vorlage, kein Quellcode nötig):
# aus dem Gitea-Repo: docker-compose.deploy.yml, Caddyfile, .env.example
cp .env.example .env # Pflicht: DOMAIN, APP_URL=https://<DOMAIN>, APP_USER,
# APP_PASSWORD_HASH, SESSION_SECRET,
# SETTINGS_ENCRYPTION_KEY, CRON_SECRET
# 2) Image ziehen und starten (öffentlich, Login nur beim ersten Pull nötig):
docker login gitea.lokcal.de -u <gitea-benutzer>
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
```
Das Image `gitea.lokcal.de/loki/gutscheintracker:latest` wird bei jedem Push auf `main` per Gitea-Action frisch gebaut (nach Tests, Typcheck und Build) und ist öffentlich. Für reproduzierbare Deployments einen Digest pinnen (steht in Gitea unter → Pakete → container/gutscheintracker).
Prüfen:
- `https://<DOMAIN>/login` lädt mit gültigem Zertifikat, `http://` leitet auf HTTPS um
- `curl http://127.0.0.1:3000/api/health` auf dem Server scheitert nicht am Proxy, sondern zeigt, dass Port 3000 nicht offen ist (kein `ports:`-Mapping)
- Einladungs-Links und Cron-Läufe funktionieren (Test-Mail in den Einstellungen)
- `docker exec <container> id` → `uid=10001(app) gid=10001(app)`
Lokal testen geht mit derselben Datei: `DOMAIN=localhost` in `.env` (dann ohne TLS, Caddy macht automatisch HTTP).
## Variante: lokal bauen (Entwicklung / eigener Build)
```bash
cp .env.example .env # Werte ausfüllen (siehe Konfiguration)
docker compose up -d --build
```
Caddy holt und erneuert das Let's-Encrypt-Zertifikat automatisch und leitet HTTP auf HTTPS um. `APP_URL` muss exakt die öffentliche Adresse sein (wichtig für OIDC-Redirects: in Authentik als Redirect-URI `https://<DOMAIN>/api/auth/oidc/callback` eintragen; der Reminder-Scheduler nutzt intern `APP_INTERNAL_URL`).
App: `https://<DOMAIN>/login` (bzw. lokal `http://localhost/login` über Caddy).
Prüfen:
- `https://<DOMAIN>/login` lädt mit gültigem Zertifikat, `http://` leitet auf HTTPS um.
- Vom Server aus: Port 3000 ist geschlossen (kein `curl http://localhost:3000` möglich).
- Einladungs-Links und Cron-Läufe funktionieren (Test-Mail in den Einstellungen).
## Update
Lokal testen geht mit derselben Datei: `DOMAIN=localhost` in `.env` (dann ohne TLS, Caddy macht automatisch HTTP).
Deployment mit Registry-Image:
```bash
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
```
Lokaler Build:
```bash
git pull
docker compose up --build -d
```
## Konfiguration (`.env`)
| Variable | Pflicht | Beispiel | Wozu |
|---|---|---|---|
| `DOMAIN` | ja | `gutscheine.example.de` | Öffentliche Domain für Caddy/TLS |
| `APP_URL` | ja | `https://gutscheine.example.de` | Exakte öffentliche Adresse (OIDC-Redirects, Einladungs-Links) |
| `APP_INTERNAL_URL` | nein | `http://app:3000` | Interner Scheduler-Zugriff (Standard passt zu Compose) |
| `APP_USER` / `APP_PASSWORD_HASH` | ja | `haushalt` / `$2b$10$…` | Erster Server-Admin (bcrypt-Hash, z. B. `node -e "console.log(require('bcryptjs').hashSync('…',10))"`) |
| `ADMIN_EMAIL` | nein | `admin@beispiel.de` | Kontaktadresse |
| `SESSION_SECRET` / `SETTINGS_ENCRYPTION_KEY` / `CRON_SECRET` | ja | je 32+ Zufallszeichen | Sessions, Einstellungs-Verschlüsselung, Cron-Schutz |
| `SMTP_*` / `MAIL_FROM` | für Mail | – | Einladungen und Erinnerungen per Mail |
| `TELEGRAM_BOT_TOKEN` | für Telegram | – | Erinnerungen per Telegram |
| `ALLOW_PASSWORD_LOGIN` | nein | `true` | `false`: Mitglieder brauchen SSO (Admin behält Passwort-Notfallzugang) |
| `TZ` | nein | `Europe/Berlin` | Zeitzone für Erinnerungen |
| OIDC | für SSO | – | Bevorzugt in der UI (Einstellungen → SSO); `OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET` auch als Env möglich |
In Authentik als Redirect-URI eintragen: `https://<DOMAIN>/api/auth/oidc/callback`.
## Mitglieder einladen
Admins laden unter Einstellungen → Nutzer per E-Mail ein (Link 7 Tage gültig). Eingeladene richten ein Passwort ein oder melden sich per SSO an, sofern die OIDC-E-Mail zur Einladung passt. Mit `ALLOW_PASSWORD_LOGIN=false` brauchen Mitglieder SSO; der Admin behält zusätzlich den Passwort-Notfallzugang.
## Backup & Wiederherstellung
Die SQLite-Datei liegt im Volume `gutschein-data` unter `/data/gutscheine.db`:
```bash
# Sichern (Dateiname enthält den Compose-Projektnamen, ggf. anpassen):
docker cp gutscheintracker-app-1:/data/gutscheine.db ./gutscheine-backup.db
# oder Volume direkt kopieren:
docker run --rm -v gutschein-data:/data -v "$PWD":/backup alpine cp /data/gutscheine.db /backup/gutscheine-backup.db
```
Zusätzlich: Haushalts-Export als JSON in der UI (Einstellungen → Haushalt → „Haushaltsdaten exportieren") – enthält Gutscheine, Kontakte und Mitglieder, aber keine Passwörter oder Secrets. Der Voll-Backup-Endpunkt (`/api/settings/backup`) bleibt Server-Admins vorbehalten.
Wiederherstellung: Container stoppen, Datei nach `/data/gutscheine.db` zurückkopieren (Eigentümer `10001:10001` beachten), Container starten.
## Sicherheit
- Rootless: Image und Compose nutzen fest `user: "10001:10001"`; der Prozess kann keine Root-Rechte erlangen (`no-new-privileges`)
- Kein offener App-Port: nur Caddy (80/443) ist von außen erreichbar
- Security-Header: `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` (ohne Kamera/Mikrofon/Standort)
- Sessions sind signiert; Passwortwechsel invalidiert fremde Sitzungen; Admin-Resets werden protokolliert
- Kein XSS-Risiko: keine `dangerouslySetInnerHTML`/`innerHTML`-Sinks; einziger dynamischer Link ist der validierte Shop-Link mit `rel="noopener noreferrer"`
- Vollständige TLS-Kette via Caddy (Let's Encrypt, Auto-Renew, HTTP→HTTPS)
## Entwicklung
@@ -29,26 +127,15 @@ npm ci
npx prisma generate
npm run dev # http://localhost:3001
npm test
npx tsc --noEmit
npm run build
```
## Mitglieder einladen
Tests: Vitest-Suite (`tests/`), u. a. Migrationstests für Erst-Boot, Idempotenz und Altdaten-Übernahme. CI (`.gitea/workflows/docker.yml`) läuft bei jedem Push auf `main`: Tests → Typcheck → Build → Image-Build und Push (`:latest` + Commit-SHA).
Admins laden unter Einstellungen → Nutzer per E-Mail ein (Link 7 Tage gültig). Eingeladene richten ein Passwort ein oder melden sich per SSO an, sofern die OIDC-E-Mail zur Einladung passt. Mit `ALLOW_PASSWORD_LOGIN=false` brauchen Mitglieder SSO; der Admin behält zusätzlich den Passwort-Notfallzugang.
## Fehlerbehebung
## Update
```bash
git pull
docker compose up --build -d
```
## Backup
Die SQLite-Datei liegt im Volume `gutschein-data` unter `/data/gutscheine.db`:
```bash
docker cp gutscheintracker-app-1:/data/gutscheine.db ./gutscheine-backup.db
# oder Volume direkt kopieren
docker run --rm -v gutschein-data:/data -v "$PWD":/backup alpine cp /data/gutscheine.db /backup/gutscheine-backup.db
```
- Caddy holt kein Zertifikat: `DOMAIN` in `.env` prüfen, DNS A/AAAA und Ports 80/443 prüfen, `docker compose logs caddy`
- OIDC-Login scheitert: `APP_URL` muss exakt der öffentlichen URL entsprechen; Redirect-URI in Authentik prüfen
- Cron/Erinnerungen laufen nicht: `CRON_SECRET` gesetzt? Scheduler nutzt `APP_INTERNAL_URL` (Compose-Standard reicht)
- DB-Schreibfehler (`unable to open database`): Volume/Datei muss der UID `10001` gehören – bei manueller Wiederherstellung `chown 10001:10001` auf die DB-Datei
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
// Unauthentifizierter Liveness-Check für Docker-HEALTHCHECK und Monitoring.
// Gibt bewusst keine internen Details preis.
export async function GET() {
return NextResponse.json({ ok: true });
}
+68
View File
@@ -0,0 +1,68 @@
# Beispiel-Deployment für einen anderen Server: nutzt das fertige,
# öffentliche Image aus der Gitea Container Registry – kein lokaler Build,
# kein Quellcode auf dem Zielserver nötig.
#
# Verwendung auf dem Zielserver:
# 1) docker login gitea.lokcal.de -u <gitea-benutzer>
# (einmalig; danach reicht `docker compose pull` auch ohne Login,
# da das Image öffentlich ist)
# 2) cp .env.example .env # Pflicht: DOMAIN, APP_URL=https://<DOMAIN>,
# # APP_USER, APP_PASSWORD_HASH, SESSION_SECRET,
# # SETTINGS_ENCRYPTION_KEY, CRON_SECRET
# 3) docker compose -f docker-compose.deploy.yml pull
# 4) docker compose -f docker-compose.deploy.yml up -d
#
# Update auf eine neue Version:
# docker compose -f docker-compose.deploy.yml pull
# docker compose -f docker-compose.deploy.yml up -d
# Pinne für reproduzierbare Deployments einen Digest statt :latest
# (Digest steht in Gitea unter → Pakete → container/gutscheintracker).
services:
app:
image: gitea.lokcal.de/loki/gutscheintracker:latest
# Rootless: gleiche feste UID:GID wie im Image (Dockerfile USER).
user: "10001:10001"
# Kein ports:-Mapping: Die App bleibt im internen Compose-Netz;
# nach außen geht es nur über Caddy (443/HTTPS).
expose:
- "3000"
environment:
# Interner Direktzugriff für den Erinnerungs-Scheduler
# (kein Hairpin über den öffentlichen Proxy nötig).
- APP_INTERNAL_URL=http://app:3000
volumes:
- gutschein-data:/data
env_file: [.env]
restart: unless-stopped
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (optional, schadet nicht)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
DOMAIN: ${DOMAIN:?DOMAIN muss in .env gesetzt sein (z. B. gutscheine.example.de)}
depends_on:
- app
security_opt:
- no-new-privileges:true
volumes:
gutschein-data: {}
caddy-data: {}
caddy-config: {}
+4
View File
@@ -1,6 +1,10 @@
services:
app:
build: .
# Rootless: Prozess läuft als UID:GID 10001:10001 (siehe Dockerfile USER).
# Entspricht der festen Identität im Image – auch beim Deployment mit
# fertigem Registry-Image setzen (siehe docker-compose.deploy.yml).
user: "10001:10001"
# Kein ports:-Mapping: Die App ist nur im internen Compose-Netz erreichbar.
# Nach außen geht es ausschließlich über den Caddy-Reverse-Proxy (443/HTTPS).
expose:
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySession, SESSION_COOKIE } from "./lib/session";
const PUBLIC_PREFIXES = ["/login", "/api/auth", "/accept-invite", "/api/users/accept"];
const PUBLIC_PREFIXES = ["/login", "/api/auth", "/api/health", "/accept-invite", "/api/users/accept"];
export async function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;