Files
Kontrollverlust/notes/deploy.md
T
irrlichtandClaude Opus 5.5 81a74375b7 Passwort-Reset per Einmal-Link (kver reset-link)
Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>),
gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es
steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet
alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt
gemeinsam in checkNewPassword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:56:05 +02:00

7.2 KiB

Deployment mit Podman

Was muss auf den Server?

Nur der Quellcode — das Image wird direkt auf dem Server gebaut.

# Einmalig: Repo klonen
git clone https://codeberg.org/<user>/kver.git
cd kver

Oder bei bestehendem Checkout:

git pull origin master

Bestehende Daten (DB + Uploads) liegen auf dem Server in Named Volumes und bleiben bei Image-Updates unberührt — die müssen nicht mitübertragen werden. Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren (s. Abschnitt „Bestehende Daten übernehmen").

Bauen

podman build -t kver .

Multi-Stage-Build (Containerfile): das React-Frontend wird in einer Node-Stage gebaut (npm ci && npm run build), das Go-Binary statisch (CGO aus); das Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer und enthält nur Binary, gebautes Frontend (web/) und static/. Auf dem Server wird also weder Node noch Go gebraucht, nur Podman.

Die Warnung HEALTHCHECK is not supported for OCI image format ist erwartbar: Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der Healthcheck kommt stattdessen aus der Quadlet-Unit (s.u.); alternativ podman build --format=docker oder podman run --health-cmd=....

Starten (Port 7777)

podman run -d --name kver \
  -p 127.0.0.1:7777:8080 \
  --env-file /etc/kver/kver.env \
  -v kver-data:/app/data \
  -v kver-media:/app/static/media \
  kver
  • Die Daten liegen in Postgres (KVER_DSN). kver-data hält nur noch die GeoLite2-ASN-DB (und die alte SQLite-Datei), kver-media die hochgeladenen Bilder. Beides Named Volumes → überleben Image-Updates.
  • Port nur an localhost binden; nach außen geht es über den Reverse-Proxy.
  • Der Container lauscht intern immer auf :8080; der externe Port (hier 7777) ist nur das Host-seitige Mapping.

Konfiguration über Umgebungsvariablen:

Variable Default Bedeutung
KVER_ADDR :8080 Listen-Adresse im Container
KVER_DSN (Pflicht) Postgres-Verbindung, z. B. postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable
KVER_GEOIP_ASN (leer) Pfad zur GeoLite2-ASN-.mmdb; leer = ASN-Auflösung aus
KVER_BASE_URL (leer) Öffentliche Adresse für kver reset-link; leer = nur Pfad

GeoLite2-ASN-Datenbank (optional)

Für die netzwerktopologische Einordnung der Impressions (Spalte impression.asn). Ohne diese DB läuft alles normal weiter, die Impressions haben dann nur kein ASN.

Die GeoLite2-ASN.mmdb gibt es kostenlos bei MaxMind (Konto + Lizenzschlüssel, monatliche Updates). Datei ins Daten-Volume legen und den Pfad setzen:

podman unshare cp GeoLite2-ASN.mmdb \
  "$(podman volume inspect kver-data --format '{{.Mountpoint}}')/"

Dann KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb setzen. Die Datei wird beim Start einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu starten.

Postgres

Die App erwartet eine Datenbank, die ihrem Login-User gehört. Einmalig als Admin:

CREATE ROLE kver LOGIN PASSWORD '...';
CREATE DATABASE kontrollverlust OWNER kver;
REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC;

Tabellen und Indizes legt die App beim Start selbst an (CREATE ... IF NOT EXISTS, als kver → gehören kver). Nicht als Admin anlegen.

Bestehende Medien übernehmen

Medien einmalig ins Volume kopieren (Volumes müssen existieren; Container kann laufen oder gestoppt sein):

podman volume create kver-data
podman volume create kver-media

# Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace-
# Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen.
podman unshare cp -r static/media/. \
  "$(podman volume inspect kver-media --format '{{.Mountpoint}}')/"

Danach Container (neu) starten.

Reverse-Proxy (TLS) — Caddy

Caddy ist die einfachste Wahl: automatisches TLS, setzt X-Forwarded-Proto und X-Forwarded-For ohne Konfiguration, kein eigenes Body-Size-Problem.

/etc/caddy/Caddyfile (oder ~/.config/caddy/Caddyfile bei rootless):

kver.example.org {
    # Unveränderliche Uploads cachen — Dateinamen sind einmalig (<unix>-<rand>),
    # Browser lädt jede Datei genau einmal.
    header /static/media/* Cache-Control "public, max-age=31536000, immutable"

    reverse_proxy 127.0.0.1:7777
}

Das ist alles. Caddy kümmert sich um Let's-Encrypt-Zertifikat, HTTPS-Redirect und setzt automatisch X-Forwarded-Proto: https → Session-Cookie bekommt das Secure-Flag, Rate-Limit sieht echte Client-IPs.

Optional Body-Limit vor der App (App begrenzt selbst auf 17 MB):

    request_body {
        max_size 20MB
    }

Warum /static/media/* nicht direkt aus Caddy serven?

Der nosniff-Header auf Uploads ist sicherheitsrelevant (animierte GIFs werden nicht re-kodiert; ohne nosniff könnte ein präpariertes GIF als HTML/JS interpretiert werden → Stored XSS). Serviert Caddy die Dateien direkt, ist die App-Middleware außen vor. Mit reverse_proxy bleibt die App der einzige Auslieferer und der Header sitzt immer drauf.

Außerdem liegt das Named Volume bei rootless Podman unter einem Subuid-Pfad, den ein Systemdienst nicht ohne Weiteres lesen kann.

nginx (alternativ)

location / {
    proxy_pass http://127.0.0.1:7777;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For   $remote_addr;
    client_max_body_size 20m;   # muss über App-Limit (17 MB) liegen
}

Autostart mit systemd (Quadlet)

~/.config/containers/systemd/kver.container (rootless):

[Unit]
Description=kver (Kontrollverlust)

[Container]
Image=localhost/kver:latest
PublishPort=127.0.0.1:7777:8080
Volume=kver-data:/app/data
Volume=kver-media:/app/static/media
# Optional: ASN-Auflösung der Impressions. Pfad zeigt ins kver-data-Volume,
# daher zur Laufzeit setzen (nicht ins Image backen). Zeile weglassen, wenn
# keine GeoLite2-ASN.mmdb vorhanden ist -> ASN-Auflösung bleibt still aus.
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
# die kennen kein eingebettetes HEALTHCHECK.
HealthCmd=wget -qO /dev/null http://localhost:8080/api/stats
HealthInterval=30s

[Service]
Restart=on-failure

[Install]
WantedBy=default.target

Aktivieren:

systemctl --user daemon-reload
systemctl --user start kver
loginctl enable-linger "$USER"   # damit der Dienst ohne Login läuft

Status prüfen:

systemctl --user status kver
podman healthcheck run kver

Update einspielen

git pull origin master
podman build -t kver .
systemctl --user restart kver

Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende bedient, dann wird die DB-Verbindung geschlossen).

Passwort zurücksetzen

Ohne E-Mail-System erzeugt der Admin einen Einmal-Link und gibt ihn dem Nutzer persönlich:

podman exec kver kver reset-link <nutzername>

Der Link (/reset#<token>) gilt 72 Stunden und nur einmal; ein neuer Link macht ältere desselben Nutzers ungültig. Beim Setzen des neuen Passworts werden alle bestehenden Sessions des Nutzers abgemeldet.