# Deployment mit Podman ## Was muss auf den Server? Nur der Quellcode — das Image wird direkt auf dem Server gebaut. ```sh # Einmalig: Repo klonen git clone https://codeberg.org//kver.git cd kver ``` Oder bei bestehendem Checkout: ```sh 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 ```sh podman build -t kver . ``` Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit- Image ist Alpine mit unprivilegiertem Nutzer. 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) ```sh 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 | ## 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: ```sh 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: ```sql 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: ```sh 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): ```sh 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): ```caddyfile kver.example.org { # Unveränderliche Uploads cachen — Dateinamen sind einmalig (-), # 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): ```caddyfile 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) ```nginx 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): ```ini [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/entry/feed/0 HealthInterval=30s [Service] Restart=on-failure [Install] WantedBy=default.target ``` Aktivieren: ```sh systemctl --user daemon-reload systemctl --user start kver loginctl enable-linger "$USER" # damit der Dienst ohne Login läuft ``` Status prüfen: ```sh systemctl --user status kver podman healthcheck run kver ``` ## Update einspielen ```sh 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).