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 #024. 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
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
pctanfasst, ist geschrieben und gelesen, aber nie gelaufen.deploy/install.shdagegen ist geprüft — es installiert in einemdebian:13-Abbild vollständig durch und bringt mit--testseine 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-mailund--moduswirken 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 undsystemctl 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-dateisteht es in keiner Datei — weder inlaufzettel.envnoch im Journal — und es wird nie ein zweites Mal angezeigt. Ist es weg, hilft nur ein neues überpsqlbeziehungsweise, 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
- Reverse Proxy davorstellen. Der Dienst bindet auf
127.0.0.1und spricht kein HTTPS; ohne Proxy erreicht ihn nur der Container selbst. Siehereverse-proxy.md. - Anmelden und unter Admin → Einstellungen Organisationsname und Kürzel setzen. Das Skript
schreibt
SEED_ORG_NAME="Eigene Organisation"undSEED_ORG_KUERZEL="EIGEN"— das Kürzel steht später auf Etiketten, es lohnt sich, es früh richtigzustellen. - authentik anbinden: die
OIDC_*-Zeilen in/etc/laufzettel/laufzettel.envsind auskommentiert vorbereitet. Ausfüllen,systemctl restart laufzettel. - 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]:
ss -ltnzeigt keinen Port 5432 — PostgreSQL spricht kein TCP- Die Datenbank ist über den Socket erreichbar, Migration und Seed sind gelaufen (mindestens sieben Vorlagen)
/etc/laufzettel/laufzettel.envhat600 root:root/api/gesundheitantwortet mitstatus 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. |