GutscheinNest
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 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.
Funktionen
- 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 unterapp/api/lib/– DB-Zugriff, Auth, Zugriffsregeln (access.ts), Schedulerprisma/schema.prisma– SQLite-Schema;lib/db-migrate.tslegt beim Boot fehlende Tabellen/Spalten an (frische und alte DBs)instrumentation.ts→lib/scheduler.ts– Erinnerungs-Cron alle 15 Minuten (intern überAPP_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.
# 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>/loginlädt mit gültigem Zertifikat,http://leitet auf HTTPS umcurl http://127.0.0.1:3000/api/healthauf dem Server scheitert nicht am Proxy, sondern zeigt, dass Port 3000 nicht offen ist (keinports:-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)
cp .env.example .env # Werte ausfüllen (siehe Konfiguration)
docker compose up -d --build
App: https://<DOMAIN>/login (bzw. lokal http://localhost/login über Caddy).
Update
Deployment mit Registry-Image:
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
Lokaler Build:
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:
# 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 mitrel="noopener noreferrer" - Vollständige TLS-Kette via Caddy (Let's Encrypt, Auto-Renew, HTTP→HTTPS)
Entwicklung
npm ci
npx prisma generate
npm run dev # http://localhost:3001
npm test
npx tsc --noEmit
npm run build
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).
Fehlerbehebung
- Caddy holt kein Zertifikat:
DOMAINin.envprüfen, DNS A/AAAA und Ports 80/443 prüfen,docker compose logs caddy - OIDC-Login scheitert:
APP_URLmuss exakt der öffentlichen URL entsprechen; Redirect-URI in Authentik prüfen - Cron/Erinnerungen laufen nicht:
CRON_SECRETgesetzt? Scheduler nutztAPP_INTERNAL_URL(Compose-Standard reicht) - DB-Schreibfehler (
unable to open database): Volume/Datei muss der UID10001gehören – bei manueller Wiederherstellungchown 10001:10001auf die DB-Datei