Files
Laufzettel/docs/betrieb/proxmox.md
T
Florian Wilde d97aebed95
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme (#37)
Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war.

deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert
nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen
im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie
ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und
die verschluesselten Bind-Passwoerter waeren unlesbar.

deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin
auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die
Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist
Nummer 38.

Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle
behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest
localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in
keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein
shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen
Port.

Closes #19
2026-09-27 13:09:14 +02:00

19 KiB

Der native Weg: Proxmox-LXC und Debian 13

Ohne Docker. Ein Skript legt auf dem Proxmox-Host einen unprivilegierten Debian-13-Container an und installiert darin alles: PostgreSQL 17 nur am Unix-Socket, Node 24, die Anwendung als systemd-Dienst. Wer schon ein leeres Debian 13 hat — als LXC, als virtuelle Maschine oder auf Blech — überspringt den ersten Teil und ruft deploy/install.sh direkt auf.

Der andere Weg, mit Docker Compose, steht in inbetriebnahme.md.

Der Proxmox-Teil ist nicht automatisch geprüft. Für diese Arbeit stand kein PVE-Host zur Verfügung; alles, was pct anfasst, ist geschrieben und gelesen, aber nie gelaufen. deploy/install.sh dagegen ist geprüft — es installiert in einem debian:13-Abbild vollständig durch und bringt mit --test seine eigene Selbstprüfung mit. Was du beim ersten echten Lauf nachsehen musst, steht am Ende dieser Seite in einer Prüfliste. Das ist keine Floskel: geh sie durch.

Was am Ende auf dem System steht

Ort Was
/opt/laufzettel Der Quellcode als Git-Arbeitsverzeichnis, Eigentümer laufzettel. Aktualisiert wird mit git pull, deshalb ein Checkout und kein Tarball.
/opt/laufzettel/.next/standalone Der gebaute Server. Von hier startet node server.js.
/etc/laufzettel/laufzettel.env Die Geheimnisse dieser Installation: APP_SECRET, DATABASE_URL mit Passwort, APP_URL. Modus 0600, Eigentümer root. Das Skript überschreibt diese Datei nie — auch nicht beim Aktualisieren.
/etc/systemd/system/laufzettel.service Der Dienst. Läuft als laufzettel, mit NoNewPrivileges, ProtectSystem=strict, PrivateTmp und Restart=on-failure.
/var/cache/laufzettel Heimatverzeichnis des Dienstnutzers — nur der Zwischenspeicher von pnpm und corepack, kein Arbeitskonto.
/etc/postgresql/17/main/conf.d/laufzettel.conf Eine Zeile: listen_addresses = ''.
Datenbank laufzettel Rolle laufzettel mit erzeugtem Passwort, erreichbar nur über den Socket /run/postgresql.

Der Dienstnutzer laufzettel ist ein Systemkonto mit /usr/sbin/nologin als Shell und ohne Heimatverzeichnis unter /home. Anmelden kann sich damit niemand.

Weg A: der One-Liner auf dem Proxmox-Host

In der Shell des PVE-Hosts, als root:

bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)"

Das Skript prüft zuerst, ob es auf einem PVE 8 oder 9 als root läuft, holt bei Bedarf die Debian-13-Vorlage über pveam, und fragt dann per Dialog: Standard oder Erweitert.

Standard nimmt diese Werte, ohne zu fragen:

CTID die nächste freie
Hostname laufzettel
Vorlage Debian 13
Kerne 2
RAM 2 GB
Platte 8 GB
Swap 512 MB
Privilegien unprivilegiert, mit nesting=1 — die systemd-Unit braucht es
Netz DHCP auf vmbr0

Das ist auf einen kleinen Betrieb zugeschnitten und reicht für einige Tausend Läufe. Der erste Bau braucht kurzzeitig mehr RAM als der Betrieb — wenn pnpm build mit Killed abbricht, ist das die Ursache; dann im Erweitert-Dialog 4 GB geben und den Wert danach zurückdrehen.

Erweitert fragt dieselben Werte einzeln ab, dazu eine feste IP statt DHCP, die Bridge und die beiden Speicher getrennt: einen für das Wurzeldateisystem des Containers (Inhalt rootdir) und einen für die Debian-Vorlage (Inhalt vztmpl). Vorbelegt ist jeweils der erste aktive Speicher, der den Inhalt aufnehmen kann; findet das Skript keinen, bricht es mit dieser Begründung ab.

Ein zweiter Aufruf erkennt einen vorhandenen Container am Hostnamen und bietet an, ihn zu aktualisieren, statt einen zweiten anzulegen.

Das Skript installiert selbst nichts. Es legt den Container an, holt deploy/install.sh hinein und ruft es dort auf — damit der Weg mit und ohne Proxmox nicht auseinanderläuft.

Schalter Bedeutung
--dry-run (auch --trockenlauf) Gibt jeden pct-, pveam- und pvesh-Aufruf nur aus. Legt nichts an, lädt nichts, und überspringt auch die Prüfung auf root und PVE — läuft also auf jedem Rechner.
--standard Ohne Dialog, alle Standardwerte nehmen.
--erweitert Direkt in den Erweitert-Dialog.
--aktualisieren Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es keinen Container mit dem Hostnamen laufzettel gibt — es wird also keiner angelegt. Gedacht für den zweiten und jeden weiteren Aufruf, etwa aus einem Wartungsfenster heraus.
--zweig <name> Git-Zweig, aus dem install.sh geholt wird. Standard main.
--hilfe Alle Schalter.

Erst einmal nur hinsehen

bash laufzettel.sh --dry-run

Nimm das beim ersten Mal: der ausgegebene pct create lässt sich lesen, prüfen und notfalls von Hand abschicken. Der Trockenlauf braucht keinen PVE-Host und läuft bis zum Abschlussbildschirm durch.

Weg B: install.sh in einem vorhandenen Debian 13

Ob das Debian in einem LXC steckt, in einer VM oder auf Blech, ist dem Skript gleich. Es braucht root, apt und Internet.

apt install -y git
git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /tmp/laufzettel-quelle
sudo bash /tmp/laufzettel-quelle/deploy/install.sh --url https://laufzettel.firma.de --admin-mail admin@firma.de

Die Schalter:

Schalter Bedeutung
--url <adresse> Die öffentliche Adresse, also APP_URL. Ohne Angabe nimmt das Skript http://<erste IP des Systems>:<port> — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst.
--admin-mail <adresse> Kennung des ersten Administrators. Standard admin@firma.example.
--port <nummer> Port des Webdienstes. Standard 3000.
--bind <adresse> Wohin gebunden wird. Standard 127.0.0.1 — davor gehört ein Reverse Proxy. Läuft der Proxy auf einem anderen Rechner, gehört hier die IP des Containers hin, dazu eine Firewall-Regel.
--modus <wert> INTERN (Standard) oder DIENSTLEISTUNG.
--zweig <name>, --quelle <url|pfad> Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt --quelle auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt.
--passwort-datei <pfad> Legt das beim ersten Lauf erzeugte Administrator-Passwort zusätzlich in diese Datei (0600, root), in den Zeilen SEED_ADMIN_EMAIL= und SEED_ADMIN_PASSWORD=. Gedacht für Aufrufer, die die Ausgabe nicht mitlesen können — genau so holt sich der Proxmox-Weg das Passwort für seinen Abschlussbildschirm. Wer den Schalter benutzt, löscht die Datei nach dem Ablesen, sonst liegt das Passwort dauerhaft im Klartext im System.
--test Nach der Installation eine Selbstprüfung fahren, siehe unten.
--hilfe Alle Schalter.

Dieselben Werte nimmt das Skript auch aus der Umgebung — für Aufrufer ohne Befehlszeile, also pct exec im Proxmox-Weg. Ein Schalter schlägt die Umgebung. Die Namen sind dieselben wie in .env.example, damit es nur eine Schreibweise gibt: APP_URL, BIND_ADDR, APP_PORT, SEED_ADMIN_EMAIL, SEED_MODE, dazu LAUFZETTEL_REPO (wie --quelle), LAUFZETTEL_ZWEIG (wie --zweig) und LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei).

--url, --port, --bind, --admin-mail und --modus wirken nur beim ersten Lauf. Danach steht die Wahrheit in /etc/laufzettel/laufzettel.env, und diese Datei wird nie überschrieben. Ein zweiter Aufruf mit anderen Werten warnt und ignoriert sie. Zum Ändern: die Datei bearbeiten und systemctl restart laufzettel.

Das Skript läuft in neun Schritten und sagt bei jedem, was es tut. Rechne mit fünf bis fünfzehn Minuten, das meiste davon pnpm install und pnpm build.

Der Abschlussbildschirm

Auf Weg A siehst du zwei Abschlüsse: erst den von install.sh aus dem Container, dann den des Proxmox-Skripts, das noch einmal Adresse, Kennung und Passwort zeigt und sagt, wie du in den Container kommst (pct enter <ctid>).

Das erzeugte Admin-Passwort steht in laufzettel.env absichtlich nicht — dort liegen nur APP_SECRET und das Datenbankpasswort. Damit der Abschlussbildschirm es trotzdem zeigen kann, obwohl install.sh hinter pct exec läuft, gibt das Skript ihm LAUFZETTEL_PASSWORT_DATEI mit: install.sh legt das Passwort einmalig in /root/laufzettel-erstpasswort (0600, root), der Aufrufer liest es von dort und löscht die Datei sofort wieder, gelesen oder nicht. Beim zweiten Aufruf — beim Aktualisieren — entsteht kein neues Passwort, und der Abschluss zeigt an dieser Stelle „unverändert (das Konto bestand schon)“. Das ist der Normalfall, kein Fehler.

Die Kennung dagegen wird aus laufzettel.env gelesen; steht dort nichts, zeigt der Abschluss den Verweis auf die Datei statt einer Adresse.

Der Abschluss von install.sh sieht so aus:

Laufzettel ist eingerichtet.

  Adresse          https://laufzettel.firma.de
  Dienst horcht    127.0.0.1:3000
  Administrator    admin@firma.de
  Passwort         xY7k2mQ4pL9wR3tN8vZa

  Dieses Passwort wird genau hier einmal angezeigt. In der Datenbank steht
  nur der argon2-Hash, in /etc/laufzettel/laufzettel.env steht es nicht.

Schreib das Passwort auf, bevor du das Fenster schließt. Ohne --passwort-datei steht es in keiner Datei — weder in laufzettel.env noch im Journal — und es wird nie ein zweites Mal angezeigt. Ist es weg, hilft nur ein neues über psql beziehungsweise, wenn OIDC schon läuft, der Weg über den Identitätsanbieter.

Bestand das Admin-Konto schon — also beim zweiten Lauf — erzeugt das Skript kein neues Passwort und schreibt an dieser Stelle Passwort unveraendert (Konto bestand schon). Das ist der Normalfall beim Aktualisieren.

Der lokale Login ist auf diesem Weg von Anfang an eingeschaltet (AUTH_LOCAL_ENABLED="true" in der Umgebungsdatei), denn sonst käme niemand hinein. Sobald OIDC steht, setz ihn auf false — bis auf das eine Notfallkonto, siehe authentik.md, Abschnitt 7.

Was danach zu tun ist

  1. Reverse Proxy davorstellen. Der Dienst bindet auf 127.0.0.1 und spricht kein HTTPS; ohne Proxy erreicht ihn nur der Container selbst. Siehe reverse-proxy.md.
  2. Anmelden und unter Admin → Einstellungen Organisationsname und Kürzel setzen. Das Skript schreibt SEED_ORG_NAME="Eigene Organisation" und SEED_ORG_KUERZEL="EIGEN" — das Kürzel steht später auf Etiketten, es lohnt sich, es früh richtigzustellen.
  3. authentik anbinden: die OIDC_*-Zeilen in /etc/laufzettel/laufzettel.env sind auskommentiert vorbereitet. Ausfüllen, systemctl restart laufzettel.
  4. Sicherung einrichten, bevor echte Daten drin sind: sicherung.md.

Den Dienst bedienen

systemctl status laufzettel
systemctl restart laufzettel          # nach jeder Änderung an laufzettel.env
journalctl -u laufzettel -f           # Protokoll mitlesen
journalctl -u laufzettel -n 100 --no-pager

Eigene Zusätze zur Unit gehören nach /etc/systemd/system/laufzettel.service.d/ als Drop-in. Änderungen direkt in laufzettel.service überschreibt der nächste Lauf des Installationsskripts.

Nachsehen, ob die Anwendung antwortet:

curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"ok"}

In die Datenbank sehen — über den Socket, denn einen Port gibt es nicht:

sudo -u postgres psql laufzettel

Aktualisieren

Dasselbe Skript noch einmal aufrufen:

sudo bash /opt/laufzettel/deploy/install.sh

Es holt den Quellcode (git pull --ff-only), baut neu, spielt die Migrationen ein, fährt den Seed (der nichts ändert, was schon da ist) und startet den Dienst neu. Geheimnisse und Daten bleiben: laufzettel.env wird nicht angefasst, die Datenbank bleibt stehen, das Admin-Passwort wird nicht neu erzeugt.

Vorher eine Sicherung ziehen. Eine Migration fährt nicht rückwärts.

Schlägt git pull --ff-only fehl, liegen in /opt/laufzettel eigene Änderungen. Nachsehen mit:

sudo -u laufzettel git -C /opt/laufzettel status

Die Selbstprüfung

sudo bash /opt/laufzettel/deploy/install.sh --test

Prüft vier Dinge und nennt jedes einzeln mit [ok] oder [FEHLER]:

  1. ss -ltn zeigt keinen Port 5432 — PostgreSQL spricht kein TCP
  2. Die Datenbank ist über den Socket erreichbar, Migration und Seed sind gelaufen (mindestens sieben Vorlagen)
  3. /etc/laufzettel/laufzettel.env hat 600 root:root
  4. /api/gesundheit antwortet mit status ok

Das ist der schnellste Weg, nach einem Umzug oder einer Aktualisierung zu prüfen, dass der Stand noch der beschriebene ist.

Was du beim ersten Lauf selbst nachprüfen musst

Der pct-Teil ist ungetestet — kein PVE-Host stand zur Verfügung. Diese Liste ist die Gegenprobe. Geh sie in dieser Reihenfolge durch; jede Zeile nennt den Schritt, das erwartete Ergebnis und was zu tun ist, wenn es abweicht.

Schritt Erwartet Wenn es abweicht
bash laufzettel.sh --dry-run auf dem PVE-Host Ein vollständiger pct create-Aufruf mit CTID, Vorlage, --cores 2, --memory 2048, --rootfs …:8, --unprivileged 1, --net0 name=eth0,bridge=vmbr0,ip=dhcp. Es wird nichts angelegt. Fehlt ein Wert oder steht ein leerer Platzhalter im Aufruf, den Befehl nicht von Hand abschicken. Stattdessen den Container über die PVE-Oberfläche anlegen und Weg B gehen.
Vorlage vorhanden pveam list local nennt ein debian-13-standard-Archiv. Fehlt es, holt das Skript es mit pveam download. Findet pveam available keine Debian-13-Vorlage, ist der Vorlagenindex alt: pveam update. Danach noch einmal.
pct create läuft durch Der Container erscheint in pct list mit dem Hostnamen laufzettel. Meldet PVE „storage does not support container directories“, ist der gewählte Speicher kein Verzeichnis- oder ZFS-Speicher. Im Erweitert-Dialog einen anderen wählen (local-lvm, local-zfs).
Der Container bekommt eine Adresse pct exec <ctid> -- ip -4 addr zeigt eine Adresse auf eth0, und pct exec <ctid> -- ping -c1 deb.debian.org kommt durch. Ohne DHCP im Netz bleibt die Adresse leer und die Installation scheitert beim ersten apt. Container anhalten, im Erweitert-Dialog eine feste IP samt Gateway setzen.
install.sh läuft im Container Neun Schritte, jeder mit Meldung, am Ende der Abschlussbildschirm mit Adresse, Kennung und Passwort. Bricht ein Schritt ab, steht die Ursache in der Meldung. Der Lauf ist wiederholbar: pct enter <ctid>, dann bash /opt/laufzettel/deploy/install.sh erneut.
Kein TCP-Port 5432 pct exec <ctid> -- ss -ltn zeigt 5432 nicht. Steht der Port da, hat listen_addresses = '' nicht gegriffen: prüfen, ob /etc/postgresql/17/main/postgresql.conf die Zeile include_dir = 'conf.d' enthält, dann systemctl restart postgresql@17-main.
Der Dienst läuft pct exec <ctid> -- systemctl is-active laufzettel sagt active, und curl auf /api/gesundheit antwortet {"status":"ok"}. pct exec <ctid> -- journalctl -u laufzettel -n 50 --no-pager. Häufigste Ursache: der Bau ist an zu wenig RAM gescheitert und .next/standalone ist unvollständig.
Umgebungsdatei geschützt stat -c '%a %U:%G' /etc/laufzettel/laufzettel.env → 600 root:root. Rechte richtigstellen: chown root:root, chmod 0600. Und prüfen, wer in der Zwischenzeit lesen konnte.
Neustart übersteht alles pct reboot <ctid>, danach antwortet /api/gesundheit ohne Zutun wieder. Kommt der Dienst nicht von selbst, fehlt das enable: systemctl enable laufzettel und systemctl enable postgresql@17-main.
Zweiter Lauf aktualisiert install.sh noch einmal: „laufzettel.env ist vorhanden und bleibt unangetastet“, „Datenbank laufzettel vorhanden — Daten bleiben, wie sie sind“, Passwort „unverändert“. Wird ein neues Admin-Passwort angezeigt, war das Konto nicht auffindbar — dann steht in laufzettel.env eine andere SEED_ADMIN_EMAIL als erwartet. Nachsehen, bevor du weitermachst.
Der Dienst ist von außen erreichbar curl http://<container-ip>:3000/api/gesundheit vom PVE-Host aus antwortet {"status":"ok"}. Antwortet nur curl im Container, aber nicht von außen, steht in /etc/laufzettel/laufzettel.env noch HOSTNAME="127.0.0.1". Das ist die richtige Voreinstellung für einen Proxy im selben Container, aber nicht für einen Proxy davor: auf 0.0.0.0 setzen (oder auf die Container-IP), systemctl restart laufzettel, und eine Firewall-Regel, die den Port nur dem Proxy erlaubt.
APP_URL passt zum Namen, unter dem du später zugreifst In laufzettel.env steht die öffentliche Adresse. Ohne --url setzt install.sh http://<Container-IP>:3000. Richtigstellen, bevor der erste Aufkleber gedruckt wird: Datei bearbeiten, systemctl restart laufzettel. Siehe reverse-proxy.md.
Die Anwendung ist bedienbar Anmelden, einen Lauf öffnen, einen Schritt abhaken, Seite neu laden, Haken ist noch da. Bleibt der Haken nicht, ist der Reverse Proxy falsch eingestellt — mit hoher Wahrscheinlichkeit die Host-Kopfzeile. Siehe reverse-proxy.md.
Die Sicherung funktioniert Einmal pg_dump ziehen und in eine zweite, leere Datenbank zurückspielen. Siehe sicherung.md. Eine Sicherung, die nie zurückgespielt wurde, ist keine.

Der letzte Punkt ist der wichtigste. Alles davor kann man reparieren.

Wenn es klemmt

Symptom Ursache und was zu tun ist
„Das Skript … braucht root“ Mit sudo bash install.sh aufrufen, nicht ohne.
„Erwartet ist Debian 13, gefunden …“ Nur eine Warnung, der Lauf geht weiter. Auf älteren Debians kann die PostgreSQL-Fassung abweichen; das Skript nimmt dann das Metapaket postgresql.
„runuser fehlt (Paket util-linux)“ Ein sehr schlankes Abbild. apt install util-linux.
„/opt/laufzettel ist nicht leer, enthält aber kein Git-Arbeitsverzeichnis“ Dort liegt ein alter Stand ohne .git. Verschieben oder löschen, dann neu starten.
pnpm build bricht mit Killed ab Zu wenig RAM. Dem Container vorübergehend 4 GB geben (pct set <ctid> --memory 4096), erneut laufen lassen, danach zurückdrehen.
„Der Dienst antwortet nach 60 s nicht“ journalctl -u laufzettel -n 50 --no-pager. Meist fehlt etwas in laufzettel.env oder die Datenbank ist nicht erreichbar.
„In laufzettel.env fehlt DATABASE_URL, APP_SECRET oder APP_URL“ Die Datei ist beschädigt. Ergänzen ist der richtige Weg. Löschen legt sie neu an — dabei geht das bisherige APP_SECRET verloren: alle Sitzungen enden, und die verschlüsselt gespeicherten LDAP-Bind-Passwörter sind unlesbar und müssen neu eingegeben werden.
psql: FATAL: password authentication failed Das Passwort in DATABASE_URL passt nicht zur Rolle. Das Skript setzt es nur dann neu, wenn es die Umgebungsdatei selbst neu angelegt hat. Von Hand: sudo -u postgres psql -c '\password laufzettel' und den Wert in laufzettel.env nachziehen.
Kein systemd im Container Das Skript erkennt es, schreibt die Unit trotzdem und sagt, wie der Dienst von Hand zu starten ist. Tritt bei einem docker run debian:13 auf, nicht in einem LXC.