- frontend.go: Seitenrouten liefern die index.html (no-cache), gehashte Assets unter /assets dauerhaft cachebar, sonst echtes 404 - /stats als Seite, /stats.html leitet per 301 weiter - Containerfile: Node-Stage baut das Frontend nach web/ - Vite baut nach ../web (gitignored); web/ (Vanilla-JS) gelöscht - notes/frontend.md, deploy.md angepasst Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
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-datahält nur noch die GeoLite2-ASN-DB (und die alte SQLite-Datei),kver-mediadie 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 |
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.
Umzug SQLite → Postgres (einmalig)
Dienst stoppen, dann mit dem neuen Image die alte Datei aus dem Volume importieren. Der Import läuft in einer Transaktion, prüft die Zeilenzahlen und bricht ab, wenn in Postgres schon Daten liegen:
systemctl --user stop kver
podman run --rm --env-file /etc/kver/kver.env \
-v kver-data:/app/data:ro \
kver import-sqlite /app/data/kver.db
systemctl --user start kver
Der Container braucht dabei (und im Betrieb) dieselbe Netzwerkanbindung an Postgres wie der Dienst selbst.
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).