Files
timer-app/DEPLOY.md
T
adminandClaude Opus 5 b85e4610e0
Deploy auf den Timer-Server / deploy (push) Failing after 13s
Uebertragung per tar, Abgleich auf dem Zielhost
rsync scheitert im Runner-Container am Docker-Host ('pipe: Permission
denied', auch im 22.04-Image). Das Paket geht jetzt per tar hinueber und
wird auf dem Server abgeglichen, wo rsync normal laeuft.

Dabei die Rechte geradegezogen: der Deploy-Benutzer besitzt den Code und
braucht dafuer kein sudo. Das frueher vorgesehene 'chown -R timerapp' nach
jedem Lauf haette ihm die Schreibrechte fuer den naechsten Lauf entzogen -
und die Einrichtungsanleitung haette dem Dienst das Datenverzeichnis
weggenommen. Jetzt: Code gehoert deploy, data/ gehoert timerapp.

Ein Diagnoseschritt meldet zu Beginn Benutzer, Werkzeuge, Rechte und
sudo-Regeln, damit solche Faelle im Protokoll sichtbar sind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:08:09 +02:00

22 KiB
Raw Blame History

Timer App — Übergabe- und Installationsdokument

Für die Claude-Code-Instanz, die diese App in die Firmeninfrastruktur einbaut.

Stand: 03.08.2026 · Version 2.0 · Getestet auf Debian 13, Node 20.19.2


0. Das Wichtigste in 60 Sekunden

Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung, gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).

  • Keine externen Dienste. Kein Cloud-Backend, keine API-Keys, keine Telemetrie, keine CDN-Einbindung. Die App macht keine ausgehenden Netzwerkverbindungen.
  • Keine Datenbank-Installation nötig. SQLite, eine einzelne Datei.
  • Keine Build-Kette. Kein Webpack, kein TypeScript, kein npm run build. Vanilla JS im Frontend, Express im Backend.
  • Abhängigkeiten: 6 npm-Pakete, alle etabliert (express, express-session, express-rate-limit, sqlite3, bcrypt, dotenv).

Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3 beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon stumm scheitern.


1. Was die App fachlich tut

Ein Mitarbeiter startet am Tresen einen Timer, optional mit einem Namen („Tisch 2", „Herr Müller", „Reparatur Werkbank"). Die App zählt die Zeit und rechnet daraus laufend einen Betrag aus. Beim Beenden wandert der Vorgang in die Historie und kann später als CSV exportiert werden.

Abrechnungsregel: Jede angefangene Einheit wird voll berechnet. Standard ist 10 € pro 10 Minuten, im Menü „Einstellungen" änderbar.

Dauer Betrag (bei 10 €/10 Min)
1 Sekunde 10 €
10 Minuten 10 €
10 Min 1 Sek 20 €
25 Minuten 30 €
60 Minuten 60 €

Diese Regel ist bewusst so — sie war die fachliche Vorgabe. Falls im Betrieb minutengenau abgerechnet werden soll, ist das eine Änderung an genau einer Funktion (calcAmount, siehe Abschnitt 7).


2. Architektur — und warum sie so ist

Timer leben auf dem Server, nicht im Browser

Das ist die zentrale Designentscheidung und der Grund für die meisten Endpunkte. Ein laufender Timer ist eine Zeile in der Tabelle active_timers, kein JavaScript-Objekt im Browser.

Was das im Ladenalltag bedeutet:

Situation Verhalten
Tab wird versehentlich geschlossen Timer läuft weiter, ist nach erneutem Öffnen da
F5 / Seite neu geladen Timer läuft weiter
Tablet-Akku leer, Neustart Timer läuft weiter
Kollege schaut am zweiten Gerät nach sieht dieselben Timer live
Dienst wird neu gestartet (Update) Timer läuft weiter, Anmeldung bleibt auch bestehen
Browser über Nacht zu Zeit läuft weiter wie eine Wanduhr

Der letzte Punkt ist eine bewusste fachliche Entscheidung: Ein laufender Timer zählt die reale verstrichene Zeit, weil der Kunde ja tatsächlich weiter im Laden sitzt. Wer die Zeit anhalten will, drückt „Pause". Falls das für den Firmeneinsatz anders gewünscht ist, siehe Abschnitt 9.

Der Browser zählt zwischen zwei Server-Abgleichen nur lokal hoch, damit die Anzeige flüssig läuft (alle 250 ms). Abgeglichen wird alle 15 Sekunden und zusätzlich sofort, wenn der Tab wieder sichtbar wird. Beim Rechnen wird bewusst nur mit Zeitdifferenzen gearbeitet, nie mit absoluten Zeitstempeln — so ist eine falsch gestellte Uhr auf dem Tablet unerheblich.

Der Betrag entsteht auf dem Server

Der Browser berechnet den Betrag nur für die Anzeige. Was in die Historie geschrieben wird, rechnet ausschließlich der Server (POST /api/timers/:id/stop). Ein manipulierter Client kann den gespeicherten Betrag nicht beeinflussen.

Eigener Sitzungsspeicher

express-session legt Sitzungen standardmäßig im Arbeitsspeicher ab — nach jedem Neustart müssten sich alle Geräte neu anmelden. Da die Timer den Neustart überleben, wäre das ein Bruch im Ablauf. Deshalb steckt in server.js eine kleine SqliteSessionStore-Klasse (~50 Zeilen), die Sitzungen in derselben SQLite-Datei ablegt. Bewusst ohne Zusatzpaket, um die Abhängigkeitsliste kurz zu halten.


3. Die drei Stolpersteine — bitte zuerst lesen

In der .env steht standardmäßig COOKIE_SECURE=true. Das heißt: Der Browser nimmt das Sitzungs-Cookie nur über HTTPS an.

Symptom bei falscher Einstellung: Das Passwort wird akzeptiert (die API antwortet mit 200), aber der Nutzer landet sofort wieder auf der Login-Maske. Endlosschleife, keine Fehlermeldung, nichts im Log.

Deployment Einstellung
Über Reverse Proxy mit HTTPS (https://timer.firma.de) COOKIE_SECURE=true
Intern ohne TLS (http://192.168.1.50:3003) COOKIE_SECURE=false
Über http://localhost zum Testen COOKIE_SECURE=false

3.2 HOST — Bindeadresse

Standard ist HOST=127.0.0.1. Die App ist dann nur lokal erreichbar, was richtig ist, wenn ein Reverse Proxy davorsteht.

Ohne Reverse Proxy (App direkt im LAN): HOST=0.0.0.0 setzen, sonst kommt kein anderes Gerät heran. Dann aber unbedingt auch COOKIE_SECURE=false (siehe oben) und TRUST_PROXY=false.

3.3 node_modules nicht kopieren

Das Paket sqlite3 enthält kompilierte native Binärdateien. Ein aus dieser ZIP oder von einem anderen Rechner kopiertes node_modules führt zu Error: invalid ELF header oder Module did not self-register.

Immer auf dem Zielsystem installieren:

cd backend
npm ci        # bevorzugt, nutzt package-lock.json
# oder: npm install

Falls npm ci mangels Compiler scheitert (node-gyp-Fehler), fehlen die Build-Werkzeuge:

# Debian/Ubuntu
apt-get install -y python3 make g++
# RHEL/Alma
dnf install -y python3 make gcc-c++

4. Installation Schritt für Schritt

# 1. Entpacken
unzip timer-app.zip -d /opt/
cd /opt/timer-app

# 2. Abhängigkeiten (auf dem Zielsystem bauen!)
cd backend
npm ci

# 3. Konfiguration anlegen
cp .env.example .env

# 4. Zufallswert für SESSION_SECRET erzeugen
npm run gen-secret
#    -> Ausgabe in die .env bei SESSION_SECRET eintragen

# 5. Passwort-Hash erzeugen
npm run hash-password -- "EuerPasswort"
#    -> Ausgabe in die .env bei PASSWORD_HASH eintragen

# 6. Datenverzeichnis sicherstellen
mkdir -p ../data

# 7. Testlauf im Vordergrund
node server.js
#    Erwartete Ausgabe:
#      Timer App laeuft auf http://127.0.0.1:3003
#      Datenbank verbunden: .../data/timer.db
#      Standardtarif gesetzt: 10 EUR pro 10 Minuten
#      Datenbank bereit

# 8. Prüfen
curl http://127.0.0.1:3003/health

Die App bricht beim Start bewusst ab, wenn SESSION_SECRET oder das Passwort fehlen — lieber laut scheitern als unsicher laufen.


5. Konfiguration (.env)

Variable Standard Bedeutung
PORT 3003 Lauschender Port
HOST 127.0.0.1 Bindeadresse — siehe 3.2
TRUST_PROXY 1 Anzahl Proxy-Ebenen davor; ohne Proxy false
COOKIE_SECURE true Cookie nur über HTTPS — siehe 3.1
SESSION_SECRET Pflicht. Zufallswert, npm run gen-secret
PASSWORD_HASH bcrypt-Hash, npm run hash-password -- "…"
PASSWORD Notfall-Alternative im Klartext; App warnt beim Start
LOGIN_MAX_ATTEMPTS 20 Anmeldeversuche pro Zeitfenster
LOGIN_WINDOW_MIN 15 Länge des Zeitfensters in Minuten
DB_PATH ../data/timer.db Pfad zur SQLite-Datei
RATE_PER_10MIN 10 Nur beim allerersten Start. Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt.

SESSION_SECRET muss ein echter Zufallswert sein. In der Vorgängerversion stand dort versehentlich der literale Text $(openssl rand -base64 32) — die Befehlssubstitution war beim Anlegen der Datei nie ausgeführt worden. Mit einem erratbaren Secret lassen sich Sitzungs-Cookies fälschen und die Anmeldung umgehen. Deshalb: Wert immer mit npm run gen-secret erzeugen, nie von Hand ausdenken, nie aus einer Doku kopieren.


6. Betrieb

systemd-Unit

Vorlage liegt bei unter deploy/timer-app.service. Anpassen: WorkingDirectory, User, ggf. ExecStart-Pfad zu node.

cp deploy/timer-app.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now timer-app
systemctl status timer-app
journalctl -u timer-app -f

Die Unit läuft in der Vorlage als eigener Benutzer (nicht root) und enthält die üblichen Härtungsoptionen. Wenn ein eigener Benutzer angelegt wird, muss ihm das data/-Verzeichnis gehören:

useradd --system --no-create-home --shell /usr/sbin/nologin timerapp
chown -R timerapp:timerapp /opt/timer-app/data
chown timerapp:timerapp /opt/timer-app/backend/.env
chmod 600 /opt/timer-app/backend/.env

Reverse Proxy

Beispiele liegen bei: deploy/Caddyfile.example und deploy/nginx.conf.example.

Wichtig in beiden Fällen:

  • X-Forwarded-Proto durchreichen (sonst greift trust proxy nicht richtig)
  • /health ist der Endpunkt für Health-Checks (antwortet ohne Anmeldung)
  • Websockets werden nicht gebraucht

Backup

Alles Wichtige steckt in einer Datei: data/timer.db.

# konsistente Sicherung im laufenden Betrieb (WAL-Modus ist aktiv!)
sqlite3 /opt/timer-app/data/timer.db ".backup '/backup/timer-$(date +%F).db'"

⚠️ Nicht einfach cp auf eine laufende Datenbank — im WAL-Modus kann eine so erzeugte Kopie unvollständig sein. Entweder .backup benutzen (wie oben) oder den Dienst vorher stoppen.

Die .env gehört ebenfalls ins Backup (enthält Secret und Passwort-Hash), aber getrennt und mit engeren Rechten.


7. Aufbau des Codes

timer-app/
├── DEPLOY.md                     ← dieses Dokument
├── README.md                     ← Kurzanleitung für Anwender
├── backend/
│   ├── server.js                 ← die gesamte Server-Logik (~470 Zeilen)
│   ├── package.json
│   ├── package-lock.json
│   ├── .env.example
│   └── tools/hash-password.js    ← Hilfsskript für den Passwort-Hash
├── frontend/
│   ├── index.html                ← Markup + komplettes CSS
│   ├── app.js                    ← Oberflächenlogik
│   ├── logo.png                  ← ⚠️ Firmenlogo, ggf. austauschen
│   └── favicon.jpg
└── deploy/
    ├── timer-app.service
    ├── Caddyfile.example
    └── nginx.conf.example

server.js ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank, Tarif & Berechnung, Sitzungsspeicher, Auth, Timer, Einstellungen, Historie).

Datenmodell

-- Laufende Timer: eine Zeile pro Platz/Kunde
active_timers(
  id, customer_name, started_at, accumulated_ms, is_running, created_at
)
-- started_at:     ms-Zeitstempel des letzten Starts/Fortsetzens (NULL bei Pause)
-- accumulated_ms: vor der aktuellen Laufphase gesammelte Zeit
-- Vergangene Zeit = accumulated_ms + (jetzt - started_at), falls is_running

-- Abgeschlossene Vorgänge
sessions(
  id, session_id, start_time, end_time, duration, amount,
  customer_name, rate_amount, rate_interval, created_at
)
-- rate_amount/rate_interval halten fest, welcher Tarif beim Beenden galt,
-- damit eine spätere Tarifänderung alte Belege nicht rückwirkend verändert.
-- created_at ist in SEKUNDEN, alle anderen Zeiten in MILLISEKUNDEN (historisch).

settings(key, value)         -- rate_amount, rate_interval
user_sessions(sid, expires, data)  -- Anmelde-Sitzungen

Das Schema wird beim Start automatisch migriert (ALTER TABLE ADD COLUMN, nur falls Spalte fehlt). Eine Datenbank der Vorgängerversion kann direkt weiterverwendet werden; getestet mit 21 Altdatensätzen, die unverändert erhalten blieben und in der Oberfläche als „ohne Namen" erscheinen.

Die Abrechnungsregel ändern

Genau eine Funktion in server.js, plus die gleichnamige in app.js für die Anzeige:

function calcAmount(ms, rateAmount, rateInterval) {
    if (!ms || ms <= 0) return 0;
    const minutes = Math.ceil(ms / 60000);          // angefangene Minute
    const units = Math.ceil(minutes / rateInterval); // angefangene Einheit
    return Math.round(units * rateAmount * 100) / 100;
}

Für minutengenaue Abrechnung z. B.: return Math.round((ms / 60000) * (rateAmount / rateInterval) * 100) / 100;


8. API-Referenz

Alle Endpunkte außer /health, /api/login, /api/auth/status und /api/logout erfordern eine gültige Sitzung (sonst 401).

Methode Pfad Zweck
POST /api/login {password} → Sitzung
GET /api/auth/status {authenticated: bool}
POST /api/logout Sitzung beenden
GET /api/timers alle laufenden Timer + aktueller Tarif
POST /api/timers {customerName} → neuer Timer
PATCH /api/timers/:id {customerName} umbenennen
POST /api/timers/:id/pause anhalten
POST /api/timers/:id/resume fortsetzen
POST /api/timers/:id/stop beenden und in Historie speichern
DELETE /api/timers/:id verwerfen, nichts speichern
GET /api/settings Tarif lesen
PUT /api/settings {rateAmount, rateInterval}
GET /api/sessions Historie; Parameter from, to, q, limit, offset
DELETE /api/sessions/:id Eintrag löschen
GET /api/sessions/export.csv CSV; gleiche Filterparameter
GET /health Status, ohne Anmeldung

from/to sind Datumsangaben YYYY-MM-DD und werden in der Zeitzone des Servers ausgewertet. Wenn Server und Laden in verschiedenen Zeitzonen stehen, TZ=Europe/Berlin in der systemd-Unit setzen.

Die CSV ist für deutsches Excel vorbereitet: Semikolon als Trenner, UTF-8-BOM (damit Umlaute stimmen), Komma als Dezimaltrennzeichen.


9. Bekannte Grenzen — bewusst so gelassen

Diese Punkte sind keine Fehler, sondern Entscheidungen. Sie stehen hier, damit sie bei Bedarf eingeordnet werden können.

Thema Aktueller Stand Wenn die Firma mehr braucht
Ein gemeinsames Passwort Alle teilen sich ein Passwort, keine Benutzerkonten, keine 2FA Für „wer hat welchen Vorgang erfasst" wären echte Konten nötig. Der Sitzungsspeicher liegt bereits in der DB, eine users-Tabelle wäre der nächste Schritt.
Jeder sieht alles Wer angemeldet ist, sieht alle Timer und die gesamte Historie Im Ladenbetrieb gewollt (Kollege übernimmt die Schicht). Mandantentrennung wäre ein größerer Umbau.
Kein Audit-Log Löschen eines Historien-Eintrags ist endgültig und wird nicht protokolliert Falls die Erfassung abrechnungsrelevant wird, ist ein deleted_at statt echtem Löschen sinnvoll (GoBD-Thema — bitte fachlich klären).
Wanduhr-Verhalten Ein laufender Timer zählt auch nachts weiter Wenn stattdessen automatisch pausiert werden soll: eine Obergrenze in elapsedMs() einbauen oder einen nächtlichen Auto-Pause-Job.
Keine Mehrprozess-Fähigkeit Ein Node-Prozess, SQLite Für die erwartete Last (ein Laden, wenige Geräte) um Größenordnungen ausreichend. Bei mehreren Filialen: PostgreSQL und echte Benutzerverwaltung.
Logo frontend/logo.png ist das Logo des bisherigen Einsatzorts, 654 KB Austauschen und dabei gern verkleinern (~50 KB reichen). Ebenso favicon.jpg. Der Seitentitel steht in index.html (<title>).
Zeitzone Server-Zeitzone bestimmt Tagesgrenzen im Filter TZ in der Unit setzen

10. Fehlersuche

Symptom Ursache Lösung
Login akzeptiert, springt zurück zur Maske COOKIE_SECURE=true ohne HTTPS COOKIE_SECURE=false oder TLS davor
Von anderen Geräten nicht erreichbar HOST=127.0.0.1 ohne Proxy HOST=0.0.0.0
Error: invalid ELF header fremdes node_modules löschen, npm ci
node-gyp-Fehler bei npm ci Build-Werkzeuge fehlen apt install python3 make g++
FATAL: SESSION_SECRET ist nicht gesetzt .env fehlt/unvollständig Abschnitt 4, Schritt 35
SQLITE_CANTOPEN data/ fehlt oder Rechte mkdir -p data && chown
Zeiten wirken um Stunden verschoben Server-Zeitzone TZ=Europe/Berlin in der Unit
Alle ausgeloggt nach Neustart sollte nicht passieren prüfen, ob user_sessions in der DB existiert

Diagnose-Einstieg:

systemctl status timer-app
journalctl -u timer-app -n 50 --no-pager
curl -s http://127.0.0.1:3003/health
sqlite3 data/timer.db "select count(*) from sessions; select * from active_timers;"

11. Sicherheit — Stand und Empfehlungen

Vorhanden:

  • Passwort als bcrypt-Hash (Kostenfaktor 12), nicht im Klartext im Code
  • Sitzungs-ID wird beim Login neu vergeben (kein Session-Fixation)
  • Cookie httpOnly, sameSite=lax, optional secure
  • Ratenbegrenzung auf dem Login (20 Versuche / 15 Min, konfigurierbar)
  • Alle SQL-Zugriffe mit gebundenen Parametern (keine String-Verkettung)
  • Ausgabe im Frontend wird escaped (kein innerHTML mit Rohdaten aus der DB)
  • Beträge werden serverseitig berechnet

Vor dem Produktivgang zu klären:

  1. Erreichbarkeit. Muss die App aus dem Internet erreichbar sein oder reicht das Firmen-LAN/VPN? Ein gemeinsames Passwort ohne 2FA gehört nach Möglichkeit nicht offen ins Internet.
  2. Passwort wechseln. Der Hash in dieser ZIP ist nicht gesetzt — .env.example enthält keinen. Neu erzeugen (Abschnitt 4).
  3. TLS. Wenn öffentlich erreichbar: Reverse Proxy mit Zertifikat und COOKIE_SECURE=true.
  4. Personenbezug. Sobald echte Kundennamen eingetippt werden, sind das personenbezogene Daten — Aufbewahrungsdauer und Löschkonzept mit der zuständigen Stelle klären. Ein Löschbefehl für alte Einträge:
    DELETE FROM sessions WHERE created_at < strftime('%s','now','-90 days');
    

12. Was beim letzten Umbau geändert wurde (03.08.2026)

Für den Fall, dass jemand die Vorgängerversion kennt:

  • Timer laufen jetzt serverseitig statt im Browser — überleben Reload, Neustart und Gerätewechsel (vorher ging bei F5 die gesamte Zeit verloren)
  • Mehrere Timer gleichzeitig, jeder mit eigenem Namen (vorher genau einer)
  • Tarif serverseitig und geräteübergreifend (vorher localStorage, pro Browser unterschiedlich, und der Betrag kam vom Client)
  • Historie mit Datums- und Namensfilter, Seitenweise-Nachladen, Löschen-Knopf, CSV-Export (vorher: fest 100 Einträge, kein Filter, Löschen nur über die DB)
  • Anmeldung mit bcrypt statt Klartextvergleich; kein fest eingebautes Ersatzpasswort mehr im Quelltext
  • Sitzungen überleben Neustarts
  • HOST, COOKIE_SECURE, TRUST_PROXY, DB_PATH konfigurierbar
  • Kein Google-Fonts-Abruf mehr — die Schrift kam vorher bei jedem Seitenaufruf von einem externen Server, was in Firmennetzen blockiert sein kann und datenschutzrechtlich unnötig Angriffsfläche bietet. Jetzt System-Schriften.
  • Sauberes Herunterfahren bei SIGTERM, WAL-Modus, Index auf created_at

Getestet: 36 automatisierte Prüfungen (Anmeldung, Parallelbetrieb, Pause/Weiter, Neustart-Persistenz mit echtem Prozesswechsel, Speichern/Verwerfen, Tarifwechsel, CSV, Filter, Löschen, Abmelden) sowie ein Durchlauf im echten Browser.


13. Wenn etwas unklar ist

Die App ist klein genug, um sie vollständig zu lesen: server.js ~470 Zeilen, app.js ~460 Zeilen, beide durchgehend kommentiert und auf Deutsch beschriftet. Im Zweifel ist das Lesen der Quelle schneller als das Raten.


Automatischer Deploy per Gitea Actions

Jeder Push auf main in git.handy-insel.de/kerimatilgan/timer-app rollt den Stand selbsttaetig auf den Server aus. Der Workflow liegt in .gitea/workflows/deploy.yaml und laesst sich in der Gitea-Oberflaeche unter "Actions" auch von Hand starten.

Was der Workflow tut

  1. Uebertraegt die versionierten Dateien als tar-Paket auf den Server und gleicht sie dort per rsync gegen /opt/timer-app ab. (Im Runner-Container laesst der Docker-Host rsync nicht laufen - pipe: Permission denied.) Nicht angefasst werden backend/.env, data/ und node_modules/ — sie sind vom Abgleich ausgenommen und ueberleben auch das --delete, das geloeschte Dateien auf dem Server aufraeumt.
  2. npm ci --omit=dev nur, wenn sich backend/package*.json geaendert hat. Das laeuft bewusst auf dem Zielhost: bcrypt und sqlite3 sind native Module und muessen zur dortigen Architektur passen.
  3. Neustart des Dienstes nur, wenn sich etwas unter backend/ oder deploy/ geaendert hat. Reine Frontend-Aenderungen brauchen keinen.
  4. Health-Check gegen /health (15 Versuche, 30 s). Antwortet der Dienst nicht, schlaegt der Lauf fehl und die letzten 40 Journal-Zeilen stehen im Protokoll.

Einmalige Einrichtung

Auf dem Zielhost einen Deploy-Benutzer anlegen, ihm das Verzeichnis geben und den Neustart des einen Dienstes erlauben:

useradd -r -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh && chmod 700 /home/deploy/.ssh
echo '<oeffentlicher Schluessel>' >> /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys

# Der Code gehoert dem Deploy-Benutzer, der Dienst liest ihn nur.
chown -R deploy:timerapp /opt/timer-app && chmod -R g+rX /opt/timer-app

# WICHTIG: das Datenverzeichnis gehoert dem Dienst - sonst kann die
# Anwendung ihre Datenbank nicht mehr schreiben.
chown -R timerapp:timerapp /opt/timer-app/data && chmod 750 /opt/timer-app/data

# Genau die drei Befehle ohne Passwort - nicht mehr
cat > /etc/sudoers.d/deploy-timer-app <<'EOF'
deploy ALL=(root) NOPASSWD: /bin/systemctl restart timer-app, \
                            /bin/journalctl -u timer-app *
EOF
chmod 440 /etc/sudoers.d/deploy-timer-app
visudo -c

Im Repo unter Settings -> Actions hinterlegen:

Art Name Wert
Variable DEPLOY_HOST 192.168.1.5
Variable DEPLOY_USER deploy
Variable DEPLOY_PATH /opt/timer-app
Variable DEPLOY_SERVICE timer-app
Variable HEALTH_URL http://127.0.0.1:3003/health
Secret DEPLOY_SSH_KEY privater Deploy-Schluessel (ganze Datei inkl. BEGIN/END-Zeilen)
Secret DEPLOY_KNOWN_HOSTS ssh-keyscan 192.168.1.5 — Zeile(n) fuer den Zielhost

Grenzen

  • Die .env wird nie ausgerollt. Kommt eine neue Einstellung dazu, gehoert sie von Hand in die .env auf dem Server (Vorlage: backend/.env.example), sonst startet der Dienst nach dem Deploy womoeglich nicht.
  • Es gibt keinen automatischen Rollback. Faellt der Health-Check durch, laeuft der Dienst weiter im Fehlerzustand — der Lauf ist rot und das Journal steht im Protokoll. Zurueck geht es mit einem Revert-Commit auf main.