Files
Kontrollverlust/notes/deploy.md
T
irrlichtandClaude Opus 5.5 2714fdb1a5 Datenbank von SQLite auf Postgres umgestellt
Verbindung über KVER_DSN; Tabelle user heißt jetzt account (reserviertes
Wort in Postgres). kver import-sqlite übernimmt die alte Datei einmalig in
einer Transaktion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 22:22:07 +02:00

7.0 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): 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)

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:

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/entry/feed/0
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).