Status Produktiv seit 23.05.2026, Auth-Wechsel 24.05.2026
Öffentliche URL intern, hinter Login
Hardware Synology DS2018j im Heimnetz
Cert Let's Encrypt, Auto-Renewal alle 60 Tage
Auth PHP-Session-Login (Cookie KIR_SESSION, bcrypt)

Worum es geht

Ein IT-Projekt mit KI soll erstmal nur ein eng definierter Personenkreis lesen können – aktuell Brokmann selbst und Nicolas Voggenreiter. Gleichzeitig soll die Seite ohne fremde Cloud auskommen und nicht bei einem öffentlichen Hoster liegen. Lösung: Hosting auf dem privaten Synology NAS im Heimnetz, kontrolliert nach außen geöffnet und mit Passwort geschützt.

Architektur

Der Pfad eines Requests von Voggenreiters Browser bis zur Seite:

Internet → FRITZ!Box (Portfreigaben 80/443) → Synology NAS → Nginx (SSL-Termination mit Let's Encrypt-Cert) → statische Dateien direkt, .php an PHP-FPM (parst der Zugangsschutz über PHP-Sitzungen) → /volume1/web/ (vom Ein IT-Projekt mit KI-Generator erzeugte .php-Seiten)

Nginx terminiert SSL, weil das Synology-DSM darauf spezialisiert ist. PHP-FPM ist Pflicht, weil jede Seite per require_once der Zugangsschutz über PHP-Sitzungen ihren Auth-Layer mitbringt. So entsteht ein Authentifizierungs-Layer ohne Custom-Nginx-Konfiguration, die unter DSM-Updates wegfliegen würde.

Diese Darstellung stand bis zum 10.08.2026 falsch hier – sie führte ein Apache-Backend auf, das die .htaccess liest. Das gab es nie. Was daraus folgte, steht weiter unten unter „Was sich als falsch herausgestellt hat“.

Phasen der Einrichtung

  • NAS-Hosting: Web Station aktivieren, Default-Webroot /web, eigener User Claude für SFTP-Deploys angelegt
  • DDNS: Synology-Account verknüpft, Subdomain intern, hinter Login aktiviert, zeigt automatisch auf die öffentliche Heim-IP
  • Router: FRITZ!Box-Portfreigaben für Port 80 (Let's Encrypt-Verifikation) und 443 (HTTPS) zur NAS – ALLE anderen Ports gehen nicht durch
  • Zertifikat: Let's Encrypt-Cert für intern, hinter Login via DSM beantragt, als Default-Cert gesetzt, Auto-Renewal aktiv
  • Virtual Host: Web Station Name-based Portal für die Subdomain mit PHP-FPM-Profil. Als Backend war Apache vorgesehen und wurde jahrelang so dokumentiert – tatsächlich lief und läuft es über Nginx (nachgewiesen 10.08.2026, s. u.)
  • Erster Auth-Versuch (23.05.2026): Apache Basic Auth via .htpasswd (APR1-Hash für User voggenreiter) und .htaccess-Direktiven – funktional sauber, aber bei Voggenreiter kam in seiner Browser-/Proxy-Konstellation gar kein Anmelde-Dialog hoch
  • Umstellung auf PHP-Session-Login (24.05.2026): der Zugangsschutz über PHP-Sitzungen in /web/ mit bcrypt-Hash-Liste, in-page Login-Form (HTTP 401 bei fehlendem Cookie), Cookie KIR_SESSION 30 Tage Secure/HttpOnly/SameSite=Lax. Generator-Output von .html auf .php umgestellt (AddHandler für .html greift im PHP-FPM-Profil nicht), jede Seite bindet der Zugangsschutz über PHP-Sitzungen per require_once ein

Security-Layer auf der Seite

  • PHP-Session-Login als Auth-Gate: jede .php-Seite bindet der Zugangsschutz über PHP-Sitzungen per require_once ein. Ohne Cookie ⇒ HTTP 401 + in-page Login-Form. POST mit korrekten Credentials ⇒ password_verify (bcrypt) ⇒ session_regenerate_id ⇒ 302 auf die ursprüngliche URL
  • Session-Cookie KIR_SESSION: 30 Tage Lebensdauer, Secure (nur HTTPS), HttpOnly (kein JS-Zugriff), SameSite=Lax (kein CSRF aus Fremdseiten)
  • Strikte User-Trennung: Voggenreiter existiert NUR in der bcrypt-Liste in der Zugangsschutz über PHP-Sitzungen, ist KEIN DSM-User – damit kein NAS-/SFTP-/DSM-Zugriff möglich, selbst wenn das Web-Passwort kompromittiert wäre
  • HTTP → HTTPS Redirect (301) aus der Zugangsschutz über PHP-Sitzungen heraus, bevor das Login-Formular je im Klartext ausgeliefert wird – lange einer mod_rewrite-Regel zugeschrieben, die nie lief. Weil er im PHP-Code sitzt, gilt er nur für .php: Stylesheet und robots.txt kommen über HTTP unverschlüsselt zurück
  • HSTS: max-age=31536000; includeSubDomains – Browser merkt sich „nur noch HTTPS für diese Domain" für 1 Jahr
  • X-Frame-Options: DENY – Schutz gegen Click-Jacking via iframe-Einbettung
  • X-Content-Type-Options: nosniff – Browser darf MIME-Typen nicht erraten
  • Referrer-Policy: strict-origin-when-cross-origin – weniger Referrer-Leakage beim Folgen externer Links
  • Permissions-Policy: FLoC/Topics-Tracking deaktiviert
  • Security-Header aus einer Quelle: gesetzt in der Zugangsschutz über PHP-Sitzungen per PHP-header(), und damit auf jeder .php-Antwort. Bis zum 10.08.2026 stand hier „doppelt gesetzt (Belt-and-Suspenders)" – die zweite Ebene existierte nicht
  • Quelltext-Schutz: der Zugangsschutz über PHP-Sitzungen ist PHP-geparst, deshalb wird der Quellcode nie ausgeliefert. Ein Dotfile-Schutz per <Files ".ht*"> war dokumentiert, wirkte aber nie – die Datei wurde deshalb entfernt statt versteckt
  • robots.txt: Disallow: / – Anti-Indexierung als zusätzliche Hürde

Security-Layer auf dem NAS

  • DSM-Firewall: Web (80/443) für alle, DSM-Admin (5000/5001), File Station, SFTP (22) und SMB nur aus dem LAN-Subnet einer Adresse im Heimnetz, Rest deny
  • Auto-Block: 5 Fehlanmeldungen in 5 Minuten ⇒ 24 h IP-Block
  • DoS Protection aktiv
  • Claude-User über SFTP chrooted auf /web + /web_packages – kein Zugriff auf /volume1, /etc, /home, /root
  • DSM-Auto-Update für Security-Patches aktiviert
  • Mail-Notifications über Office-365-SMTP für Cert-Renewal-Failures, Login-Failures und Update-Events

Verifikation extern

Der gesamte Aufbau wurde extern via curl aus Claude Code verifiziert: nur Ports 80 und 443 sind öffentlich erreichbar (22, 445, 5000, 5001 sind dicht), das Let's Encrypt-Cert wird korrekt für die Subdomain ausgeliefert, HTTP wird mit 301 auf HTTPS umgeleitet.

Die Zeile „.htaccess ist mit 403 blockiert“ stand hier bis zum 10.08.2026 – abgeschrieben aus der Konfiguration, nie abgerufen. Tatsächlich lieferte der Server die Datei mit HTTP 200 aus.

Der End-to-End-Login-Flow wurde mit einem Cookie-Jar nachgespielt: Root ohne Cookie liefert HTTP 401 mit Login-Karte im Body, der POST mit korrekten Credentials liefert HTTP 302 mit Set-Cookie: KIR_SESSION, der Folge-Request mit Cookie liefert HTTP 200 und die echte Landing-Seite. Unterseiten verhalten sich identisch. Alle fünf Security-Header sind im Response sichtbar – auch im authentifizierten Zustand, weil sie defensiv in der Zugangsschutz über PHP-Sitzungen gesetzt sind.

Was sich als falsch herausgestellt hat

Am 10.08.2026 stellte sich heraus, dass ein Teil der oben beschriebenen Absicherung nie existiert hat. Der Befund gehört auf diese Seite, weil er über das Ergebnis mehr aussagt als die Teile, die funktioniert haben.

Der Befund. Die .htaccess im Web-Root wurde mit HTTP 200 ohne Anmeldung im Klartext ausgeliefert – obwohl drei Dokumente behaupteten, sie verstecke sich selbst hinter 403. Keine ihrer Direktiven hat je gegriffen.

Die Ursache war nicht die naheliegende. Die erste Vermutung lautete „AllowOverride ist abgeschaltet, Apache liest die Datei nicht“. Tatsächlich steht Apache in diesem Pfad gar nicht: Der Synology-Nginx liefert statische Dateien direkt aus, nur .php geht an PHP-FPM. Eine .htaccess ist dort keine abgeschaltete Konfiguration, sondern eine Datei ohne Bedeutung.

Wie es auffiel – und warum erst so spät. Der Beweis war nicht die Datei selbst, sondern die harmloseste Regel darin: eine 301-Umleitung alter .html-Adressen auf .php. Der Aufruf lieferte 404 statt 301. Damit war klar, dass nicht eine Direktive fehlschlug, sondern alle. Zweieinhalb Monate lang hatte niemand die Datei abgerufen, deren Schutz drei Dokumente zusicherten – geprüft worden war die Konfiguration, nie die Auslieferung.

Was das praktisch bedeutete. Die als doppelt dokumentierte Absicherung war einfach. Die Security-Header kamen ausschließlich aus der Zugangsschutz über PHP-Sitzungen; wäre die Datei ausgefallen, wären Zugangsschutz und Header gemeinsam weggefallen – und niemand hätte eine zweite Ebene vermisst, weil alle Unterlagen eine versprachen.

Die Entscheidung. Die Datei wurde entfernt statt repariert. Sie hätte sich wirksam machen lassen, indem man den Virtual Host auf ein Apache-Backend umstellt – ein Eingriff in die ausliefernde Schicht einer laufenden Seite, und ausgerechnet diese Kombination war auf dieser NAS schon zweimal gescheitert. Ein Leck ohne Gegenwert wird geschlossen, nicht gepflegt; die zusätzliche Schicht bleibt eine eigene, separat entschiedene Frage.

Was bleibt. Eine Sicherheitsaussage, die in der Dokumentation steht und in der Realität nicht gilt, ist gefährlicher als eine fehlende – sie verhindert, dass jemand nachsieht. Geprüft wird seitdem gegen den ausgelieferten Stand, nicht gegen die Konfiguration im Projektordner.

Verbindung zu Ein IT-Projekt mit KI

Diese Strecke ist die Hosting-Schicht unter Ein IT-Projekt mit KI – die eigentliche Webseite wird mit der schlanken Deployment-Strecke NAS über die SFTP-Integration zu Synology NAS ins /web/-Verzeichnis geschoben. Diese Externe-Freigabe-Strecke sorgt dafür, dass das Ergebnis dann auch von außen sichtbar wird – genau für die Personen, die sich mit ihren Credentials anmelden können.

Falls die Anforderungen wachsen (Kunden-Öffnung, mehr Nutzer, höhere Last), lässt sich der gleiche Aufbau ohne große Umbauten erweitern: weitere bcrypt-Einträge in der Zugangsschutz über PHP-Sitzungen für mehr Personen, eine zweite Subdomain für eine öffentliche Variante, Reverse-Proxy- Verkettung für andere Dienste am gleichen NAS. Für ein größeres Nutzer-Setup wäre der nächste logische Schritt, die User-Liste aus der Zugangsschutz über PHP-Sitzungen in eine kleine SQLite-Datei oder gegen einen OIDC-Provider auszulagern – die Auth-Mechanik selbst (Cookie, Session-Handling, in-page-Form) bleibt identisch.

Eingesetzt in

Dieses Projekt wird in folgenden anderen Projekten verwendet:

← zurück zur Projekt-Übersicht