# 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`](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 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 ` | Git-Zweig, aus dem `install.sh` geholt wird. Standard `main`. | | `--hilfe` | Alle Schalter. | ### Erst einmal nur hinsehen ```bash 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. ```bash 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 ` | Die **öffentliche** Adresse, also `APP_URL`. Ohne Angabe nimmt das Skript `http://:` — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst. | | `--admin-mail ` | Kennung des ersten Administrators. Standard `admin@firma.example`. | | `--port ` | Port des Webdienstes. Standard 3000. | | `--bind ` | 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 ` | `INTERN` (Standard) oder `DIENSTLEISTUNG`. | | `--zweig `, `--quelle ` | Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt `--quelle` auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt. | | `--passwort-datei ` | 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 `). **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`](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`](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](authentik.md)**: 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`](sicherung.md). ## Den Dienst bedienen ```bash 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: ```bash 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: ```bash sudo -u postgres psql laufzettel ``` ## Aktualisieren Dasselbe Skript noch einmal aufrufen: ```bash 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: ```bash sudo -u laufzettel git -C /opt/laufzettel status ``` ## Die Selbstprüfung ```bash 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 -- ip -4 addr` zeigt eine Adresse auf `eth0`, und `pct exec -- 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 `, dann `bash /opt/laufzettel/deploy/install.sh` erneut. | | **Kein TCP-Port 5432** | `pct exec -- 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 -- systemctl is-active laufzettel` sagt `active`, und `curl` auf `/api/gesundheit` antwortet `{"status":"ok"}`. | `pct exec -- 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 `, 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://: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://:3000`. | Richtigstellen, **bevor** der erste Aufkleber gedruckt wird: Datei bearbeiten, `systemctl restart laufzettel`. Siehe [`reverse-proxy.md`](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`](reverse-proxy.md). | | Die Sicherung funktioniert | Einmal `pg_dump` ziehen und in eine zweite, leere Datenbank zurückspielen. | Siehe [`sicherung.md`](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 --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. |