---
title: 'Debian 13 Minimal Server Setup (LEMP + Custom Services)'
url: 'https://blog.ro011110ot.de/de/vps/debian-13-minimal-server-setup-lemp-custom-services'
markdown: 'https://blog.ro011110ot.de/de/vps/debian-13-minimal-server-setup-lemp-custom-services.md'
lang: de
date: '2026-10-05'
description: 'Debian 13 Minimal Server Setup (LEMP + Custom Services) Diese Dokumentation beschreibt die vollständige Einrichtung eines Debian 13 („trixie") VPS bei Netcup ohne Verwaltungspanel (kein ISPConfig/cPanel). Das System nutzt einen gehärteten Nginx-Server, MariaDB, duale PHP-Setups, Tailscale-Absicheru…'
---

# Debian 13 Minimal Server Setup (LEMP + Custom Services)

Diese Dokumentation beschreibt die vollständige Einrichtung eines Debian 13 („trixie") VPS bei Netcup ohne Verwaltungspanel (kein ISPConfig/cPanel). Das System nutzt einen gehärteten Nginx-Server, MariaDB, duale PHP-Setups, Tailscale-Absicherung, DNS-01 SSL via deSEC.io, Postfix SMTP-Relay sowie benutzerdefinierte Services (Fishnet, Syncthing, Rclone, Python/uv).

> \[!info\] Stand der Überarbeitung Überarbeitet am 2026-10-01 (Security-Review und Obsidian-Format). Die Repository-Anleitungen (Sury, nginx.org, Tailscale, Syncthing), die deSEC-Token-Policies und der PHP-Support-Status wurden gegen die Hersteller-Dokumentation geprüft. Eine Liste aller Änderungen steht in Abschnitt 10.

## Sicherheitskonzept (Überblick)

| Dienst | Erreichbar über | Absicherung |
|---|---|---|
| HTTP/HTTPS (80/443) | öffentlich | UFW, Nginx-Härtung, Default-Server mit Handshake-Reject, fail2ban |
| SSH (22) | nur Tailscale (`tailscale0`) | Key-only, kein Root-Login, eigener Admin-User |
| MariaDB (3306) | nur Tailscale | Bindung auf Tailscale-IP, UFW, `root` nur per Socket |
| Interne VHosts (`admin.`, `dash.`) | nur Tailscale | `allow`/`deny` auf Tailnet-Range |
| Grav-Admin (`/admin`) | nur Tailscale | `allow`/`deny` auf Tailnet-Range |
| Postfix | nur lokal (ausgehendes Relay) | `inet_interfaces = loopback-only` |

> \[!warning\] Aussperr-Gefahr Vor Änderungen an Netzwerk, SSH oder Firewall immer eine zweite Root-Session offen halten und die Netcup-Konsole (SCP/VNC) als Notzugang bereithalten. Neue Einstellungen zuerst in einer **neuen** Session testen, erst dann die alte schließen.

---

## 1. Netcup IPv6 & IPv4 Netzwerkkonfiguration (Debian 13)

Da Netcup für IPv6 das Link-Local-Gateway `fe80::1` nutzt und bei Debian 13 standardmäßig auf das veraltete `ifupdown` setzt, wird das Netzwerk auf `systemd-networkd` umgestellt.

> \[!warning\] Wichtiger Hinweis zur SSH-Verbindung Sobald `systemd-networkd` die Kontrolle über ein Interface übernimmt, verwirft es alle bisher vom System gesetzten Adressen. Die Konfigurationsdatei **muss zwingend IPv4 und IPv6 gemeinsam definieren**, da andernfalls die IPv4-Adresse entfernt wird und die SSH-Sitzung sofort abbricht.
> 
> Vor dem Anwenden prüfen:
> 
> - **Interface-Name:** `ip -br link` (meist `eth0`, auf manchen Images `ens3`).
> - **IPv4-Gateway:** `ip -4 route show default` (Wert für `Gateway` abgleichen).

## 1. Netcup IPv6 & IPv4 Netzwerkkonfiguration (`ifupdown`)

Netcup nutzt für IPv6 das Link-Local-Gateway `fe80::1`. Um Verbindungsabbrüche durch den Wechsel des Netzwerkmanagers zu vermeiden, verbleiben wir beim Debian-Standard `ifupdown` und legen eine eigene Konfigurationsdatei in `/etc/network/interfaces.d/` an.

> \[!warning\] Cloud-Init beachten Das Netcup-Image nutzt `cloud-init`, welches Netzwerkeinstellungen bei jedem Reboot überschreibt. Die Netzwerkverwaltung von `cloud-init` muss daher zuerst deaktiviert werden.

### 1.1 Cloud-Init Netzwerkverwaltung deaktivieren

```shell
# Prevent cloud-init from overwriting network configuration
echo "network: {config: disabled}" > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg

# Remove generated cloud-init interface configuration
rm -f /etc/network/interfaces.d/50-cloud-init.cfg
```

### 1.2 Netzwerkkonfiguration anlegen

```shell
micro /etc/network/interfaces.d/60-custom-network.cfg
```

```ini
# Primary Network Interface
auto eth0

# IPv4 Configuration (Static)
iface eth0 inet static
    address 185.228.138.89/22
    gateway 185.228.136.1

# IPv6 Configuration (Static - Netcup Gateway fe80::1)
iface eth0 inet6 static
    address 2a03:4000:23:b22::1/64
    gateway fe80::1
```

`cat /etc/network/interfaces`

```bash
# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).
source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback
```

`cat /etc/network/interfaces.d/60-custom-network.cfg`

```bash
# Primary Network Interface
auto eth0

# IPv4 Configuration (Static)
iface eth0 inet static
    address 185.228.138.89/22
    gateway 185.228.136.1

# IPv6 Configuration (Static - Netcup Gateway fe80::1)
iface eth0 inet6 static
    address 2a03:4000:23:b22::1/64
    gateway fe80::1
```

### 1.3 Konfiguration anwenden & testen

```shell
# Restart networking service
systemctl restart networking

# Verify IP addresses and connectivity
ip addr show eth0
ping -c 3 1.1.1.1
ping -6 -c 3 2606:4700:4700::1111
```

### 1.4 Neustart-Test (Optional)

```shell
systemctl reboot
```

Nach dem Neustart die Erreichbarkeit erneut bestätigen:

```shell
ip a
ping -c 3 1.1.1.1
ping -6 -c 3 2606:4700:4700::1111
```

 Tip

Um absolut sicherzugehen, dass beim Systemstart kein einziger Fehler aufgetreten ist, kannst du den Status und das Boot-Log des Netzwerkdienstes prüfen:

```Shell
# Status des Netzwerkdienstes abfragen
systemctl status networking.service

# Boot-Log speziell vom aktuellen Systemstart (-b) anzeigen
journalctl -u networking.service -b --no-pager
```

## 2. Automatisches Setup-Skript (`setup.sh`)

Dieses Skript fügt alle externen APT-Repositories hinzu (Sury PHP, nginx.org, Tailscale, Syncthing), installiert die benötigten Pakete (PHP 8.5 und 8.6, beide aus dem Sury-Repository) und richtet Webroots, Logverzeichnisse, Basis-Firewall und automatische Security-Updates ein.

> \[!warning\] Duales PHP Setup (PHP 8.5 / 8.6) Die PHP-Pakete werden aus dem Sury-Repository bezogen. Das Grav-Blog wird standardmäßig unter PHP 8.5 betrieben und kann bei Bedarf schrittweise auf PHP 8.6 migriert werden (siehe Abschnitt 7.3).

```shell
micro setup.sh
```

```shell
#!/usr/bin/env bash
set -euo pipefail

TARGET_HOSTNAME="server.EXAMPLE.TLD"

echo "===================================================="
echo " Starting Repository Setup & Core Installation"
echo "===================================================="

# 0. Must run as root
if [ "$(id -u)" -ne 0 ]; then
    echo "[-] ERROR: Please run this script as root."
    exit 1
fi

# 1. Verify Hostname
CURRENT_HOSTNAME=$(hostname -f 2>/dev/null || hostname)
if [ "$CURRENT_HOSTNAME" != "$TARGET_HOSTNAME" ]; then
    echo "[-] ERROR: Hostname mismatch! Current: $CURRENT_HOSTNAME, Expected: $TARGET_HOSTNAME"
    echo "    Run: hostnamectl set-hostname $TARGET_HOSTNAME"
    exit 1
fi

export DEBIAN_FRONTEND=noninteractive
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")"

# Create gnupg home directory if missing
mkdir -p -m 0700 /root/.gnupg

# 2. Base packages for repository management
apt-get update
apt-get install -y ca-certificates curl gnupg debian-archive-keyring sudo wget git zsh
install -d -m 0755 /etc/apt/keyrings /usr/share/keyrings

# 3. Add External Repositories

# A) Sury PHP Repository
if [ ! -f /etc/apt/sources.list.d/php.list ]; then
    echo "[*] Adding Sury PHP repository..."
    curl -fsSL -o /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb
    dpkg -i /tmp/debsuryorg-archive-keyring.deb
    rm -f /tmp/debsuryorg-archive-keyring.deb
    echo "deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ ${CODENAME} main" \
        > /etc/apt/sources.list.d/php.list
fi

# B) Official Nginx Repository (fingerprint is verified, packages are pinned)
if [ ! -f /usr/share/keyrings/nginx-archive-keyring.gpg ]; then
    echo "[*] Adding nginx.org repository..."
    curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg
    KEY_INFO="$(gpg --homedir /tmp --dry-run --quiet --no-keyring --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg)"
    if ! grep -q "573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62" <<< "$KEY_INFO"; then
        echo "[-] ERROR: nginx signing key fingerprint mismatch!"
        rm -f /usr/share/keyrings/nginx-archive-keyring.gpg
        exit 1
    fi
    echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] https://nginx.org/packages/debian ${CODENAME} nginx" \
        > /etc/apt/sources.list.d/nginx.list
    printf 'Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n' \
        > /etc/apt/preferences.d/99nginx
fi

# C) Tailscale Repository
if [ ! -f /usr/share/keyrings/tailscale-archive-keyring.gpg ]; then
    echo "[*] Adding Tailscale repository..."
    curl -fsSL "https://pkgs.tailscale.com/stable/debian/${CODENAME}.noarmor.gpg" -o /usr/share/keyrings/tailscale-archive-keyring.gpg
    curl -fsSL "https://pkgs.tailscale.com/stable/debian/${CODENAME}.tailscale-keyring.list" -o /etc/apt/sources.list.d/tailscale.list
fi

# D) Syncthing Repository
if [ ! -f /etc/apt/keyrings/syncthing-archive-keyring.gpg ]; then
    echo "[*] Adding Syncthing repository..."
    curl -fsSL https://syncthing.net/release-key.gpg | gpg --dearmor -o /etc/apt/keyrings/syncthing-archive-keyring.gpg
    echo "deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable-v2" \
        > /etc/apt/sources.list.d/syncthing.list
    printf 'Package: *\nPin: origin apt.syncthing.net\nPin-Priority: 990\n' \
        > /etc/apt/preferences.d/syncthing.pref
fi

# 4. Preseed Postfix
echo "postfix postfix/main_mailer_type select Local only" | debconf-set-selections
echo "postfix postfix/mailname string ${TARGET_HOSTNAME}" | debconf-set-selections

# 5. Update Index & Install Package Stack
apt-get update
apt-get install -y \
    tmux htop btop fastfetch micro python3 ufw \
    unattended-upgrades needrestart \
    rclone syncthing tailscale fail2ban postfix mariadb-server nginx \
    certbot python3-certbot-dns-desec \
    php8.5-fpm php8.5-cli php8.5-mysql php8.5-curl php8.5-gd php8.5-mbstring php8.5-xml php8.5-zip \
    php8.6-fpm php8.6-cli php8.6-mysql php8.6-curl php8.6-gd php8.6-mbstring php8.6-xml php8.6-zip

# 6. Create Webroots and Nginx Log Directories
DOMAINS=(
    "EXAMPLE.TLD"
    "admin.EXAMPLE.TLD"
    "dash.EXAMPLE.TLD"
    "mta-sts.EXAMPLE.TLD"
    "pgp.EXAMPLE.TLD"
)

for DOMAIN in "${DOMAINS[@]}"; do
    install -d -m 0750 -o www-data -g www-data "/var/www/${DOMAIN}/html"
    install -d -m 0750 -o root -g adm "/var/log/nginx/${DOMAIN}"
done

# 7. Postfix loopback constraint
postconf -e 'inet_interfaces = loopback-only'

# 8. Automatic security updates
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF

# 9. Enable Core Services
systemctl enable --now nginx mariadb php8.5-fpm php8.6-fpm fail2ban tailscaled
systemctl reload postfix || systemctl restart postfix

# 10. Configure Base UFW
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw default deny incoming
ufw default allow outgoing
ufw limit 22/tcp comment 'SSH (temporary, moved to tailscale0 in section 3)'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable

# 11. Configure Oh My Zsh & Custom Plugins
echo "[*] Setting up Zsh and Oh My Zsh..."
if [ ! -d "/root/.oh-my-zsh" ]; then
    RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
fi

chsh -s "$(which zsh)" root

ZSH_CUSTOM="/root/.oh-my-zsh/custom"

# Install custom zsh plugins via git (Removed buggy zsh-autocomplete)
if [ ! -d "${ZSH_CUSTOM}/plugins/zsh-autosuggestions" ]; then
    git clone https://github.com/zsh-users/zsh-autosuggestions "${ZSH_CUSTOM}/plugins/zsh-autosuggestions"
fi

if [ ! -d "${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting" ]; then
    git clone https://github.com/zsh-users/zsh-syntax-highlighting.git "${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting"
fi

# Clean up broken zsh-autocomplete if present from earlier runs
rm -rf "${ZSH_CUSTOM}/plugins/zsh-autocomplete" 2>/dev/null || true

# Deploy custom .zshrc
cat > /root/.zshrc <<'EOF'
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="agnoster"
zstyle ':omz:update' mode auto
HIST_STAMPS="yyyy-mm-dd"

plugins=(
    alias-finder
    aliases
    colored-man-pages
    colorize
    dirhistory
    debian
    git
    history
    ssh-agent
    sudo
    systemd
    tailscale
    zsh-autosuggestions
    zsh-syntax-highlighting
)

source $ZSH/oh-my-zsh.sh

fastfetch

# --- CUSTOM ALIASES ---
alias ..='cd ..'
alias ll='ls -lah'
alias update='sudo apt update && sudo apt upgrade -y && command -v uv &>/dev/null && uv self update; needrestart'
alias cls='clear'
alias f2b='uv run ~/scripts/f2b_stats.py'
alias banstatus="sudo fail2ban-client status | awk -F':\t' '/Jail list/ {print \$2}' | sed 's/,//g' | xargs -n1 sudo fail2ban-client status"
alias ports='sudo ss -tulpn | grep LISTEN'
alias myip='curl -4 icanhazip.com'
alias ta='tmux attach || tmux new-session'
alias tls='tmux ls'
alias del-mail='echo "d 1-1000" | mail -N > /dev/null'
alias logs='sudo ~/scripts/check_logs.sh'
alias health='sudo ~/scripts/health-check.sh'
alias sudo='sudo env PATH=$PATH:/usr/local/sbin:/usr/sbin:/sbin'

# Exports
export EDITOR=micro
export VISUAL=micro
EOF

echo "===================================================="
echo " Base Setup Finished Successfully!"
echo " Shell environment is ready. Run 'exec zsh' to apply."
echo "===================================================="
```

```shell
chmod +x setup.sh
./setup.sh
```

Prüfen, ob die gepinnten Repositories tatsächlich gewinnen:

```shell
apt-cache policy nginx syncthing | grep -E '^[a-z]|Candidate'
```

 Caution

`unattended-upgrades` zieht standardmäßig nur Debian-Security. Updates aus Sury (PHP), nginx.org, Tailscale und Syncthing werden manuell eingespielt (`apt update && apt full-upgrade`, z. B. wöchentlich). `needrestart` zeigt danach an, welche Dienste neu gestartet werden müssen.

---

## 3. Tailscale, SSH & Firewall härten

> \[!INFO\] Ziel: SSH und alle internen Dienste sind nur noch über das Tailnet erreichbar. Öffentlich bleiben ausschließlich 80/443.

### 3.1 Tailscale starten

```shell
tailscale up
tailscale ip -4
```

In der Tailscale-Admin-Konsole für diesen Server: Key-Expiry deaktivieren (sonst fällt der Server nach Ablauf aus dem Tailnet) und per ACL festlegen, welche Geräte Port 22 und 3306 erreichen dürfen.

### 3.2 Admin-User mit SSH-Key anlegen

Arbeiten als `root` per SSH-Passwort sollte nach diesem Abschnitt nicht mehr nötig sein.

```shell
adduser DEIN_ADMIN_USER
usermod -aG sudo DEIN_ADMIN_USER
```

Auf dem Client (Key hinterlegen, solange der Passwort-Login noch aktiv ist):

```shell
ssh-copy-id -i ~/.ssh/id_ed25519.pub DEIN_ADMIN_USER@server.EXAMPLE.TLD
```

Login und `sudo -v` in einer **neuen** Session testen, bevor es weitergeht.

### 3.3 sshd härten

Die Datei beginnt mit `10-`, damit sie vor evtl. vorhandenen Drop-ins (z. B. `50-cloud-init.conf`) gelesen wird; bei `sshd` gilt der erste gesetzte Wert.

```shell
micro /etc/ssh/sshd_config.d/10-hardening.conf
```

```ini
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers DEIN_ADMIN_USER
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
```

```shell
sshd -t && systemctl reload ssh
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'
```

### 3.4 SSH nur noch über Tailscale erlauben

```shell
ufw allow in on tailscale0 to any port 22 proto tcp comment 'SSH via Tailscale'
```

Jetzt in einer neuen Session per Tailscale-IP bzw. MagicDNS-Name einloggen. Funktioniert das, die öffentliche Regel entfernen:

```shell
ufw delete limit 22/tcp
ufw status numbered
```

### 3.5 fail2ban Grundkonfiguration

Die Nginx-Jails folgen in Abschnitt 6.6, weil die Logdateien dafür erst existieren müssen.

```shell
micro /etc/fail2ban/jail.d/10-local.conf
```

```ini
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
# Never ban the tailnet
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10 fd7a:115c:a1e0::/48

[sshd]
enabled = true
```

```shell
systemctl restart fail2ban
fail2ban-client status
```

---

## 4. MariaDB Absicherung (Lokaler & Tailscale Zugriff)

Ziele: `root` nur per `unix_socket` (kein Passwort, kein Netzwerk-Login), Netzwerkzugriff nur über Tailscale und nur für einen dedizierten Admin-User von festgelegten Geräten, pro Anwendung ein eigener DB-User mit Rechten auf genau eine Datenbank.

> \[!note\] Grav Grav ist ein Flat-File-CMS und braucht keine Datenbank. MariaDB wird nur für andere Anwendungen benötigt.

### 4.1 MariaDB Netzwerk-Bindung

Statt `0.0.0.0` (öffentlicher Listener, nur durch UFW geschützt) bindet MariaDB direkt an die Tailscale-Adresse. Lokale Anwendungen nutzen weiter den Unix-Socket (`localhost`); Anwendungen, die explizit `127.0.0.1:3306` konfiguriert haben, müssen auf `localhost` umgestellt werden.

```shell
tailscale ip -4
micro /etc/mysql/mariadb.conf.d/99-hardening.cnf
```

```ini
[mariadbd]
# Replace with the Tailscale IPv4 address of this server (tailscale ip -4)
bind-address      = 100.x.y.z
skip-name-resolve = 1
local-infile      = 0
```

Damit MariaDB nicht vor dem Tailscale-Interface startet und beim Booten an der fehlenden Adresse scheitert:

```shell
systemctl edit mariadb
```

```ini
[Unit]
After=tailscaled.service network-online.target
Wants=tailscaled.service network-online.target

[Service]
Restart=on-failure
RestartSec=5s
```

> \[!tip\] Alternative ohne TCP-Listener im Tailnet `bind-address = 127.0.0.1` (Debian-Standard) und Zugriff per SSH-Tunnel: `ssh -L 3306:127.0.0.1:3306 DEIN_ADMIN_USER@server.EXAMPLE.TLD`. Das ist die restriktivste Variante.

### 4.2 SQL-Absicherung & Admin-Zugriff für Tailscale

Zuerst das interaktive Standard-Skript ausführen (unix\_socket-Authentifizierung beibehalten, Root-Passwort **nicht** setzen, anonyme User, Remote-Root und Testdatenbank entfernen):

```shell
mariadb-secure-installation
```

Danach in der MariaDB-Shell (`sudo mariadb`):

```sql
-- root stays on unix_socket only (no password, no network login)
ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket;

-- If the old setup (root@'100.%') was already applied, remove it:
DROP USER IF EXISTS 'root'@'100.%';

-- Admin account for remote administration, only from one specific tailnet device
CREATE USER 'dbadmin'@'DEINE_CLIENT_TAILSCALE_IP' IDENTIFIED BY 'DEIN_SICHERES_ADMIN_PASSWORT';
GRANT ALL PRIVILEGES ON *.* TO 'dbadmin'@'DEINE_CLIENT_TAILSCALE_IP' WITH GRANT OPTION;

-- Example: one dedicated user per application, limited to its own database
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'DEIN_SICHERES_APP_PASSWORT';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
```

Passwörter lassen sich z. B. mit `openssl rand -base64 24` erzeugen.

### 4.3 UFW Regel für MariaDB über Tailscale

```shell
# Allow MariaDB (3306) only on the Tailscale interface
ufw allow in on tailscale0 to any port 3306 proto tcp comment 'MariaDB via Tailscale'
systemctl restart mariadb
ss -tlnp | grep 3306
```

Erwartet: MariaDB lauscht nur auf der `100.x.y.z`-Adresse, nicht auf `0.0.0.0`.

---

## 5. SSL Zertifikate via Certbot & deSEC.io DNS-01 Challenge

Da einige Subdomains nur intern über Tailscale erreichbar sind, nutzen wir die **DNS-01 Challenge** mit der **deSEC.io API**. Dadurch müssen die VHosts für die Zertifikatserstellung nicht öffentlich erreichbar sein.

> \[!tip\] Nebeneffekt des Wildcard-Zertifikats Interne Subdomains wie `admin.` oder `dash.` tauchen dadurch nicht einzeln in den öffentlichen Certificate-Transparency-Logs auf.

### 5.1 deSEC-Token auf die ACME-Challenge beschränken

Ein normaler deSEC-Token darf alle DNS-Records aller Domains im Account ändern. Für Certbot reicht ein Token, das nur den TXT-Record `_acme-challenge` beschreiben darf und nur von der Server-IP aus funktioniert (deSEC-Token-Policies).

1. In der deSEC-Weboberfläche einen neuen Token anlegen. „Domains anlegen/löschen" und „Tokens verwalten" bleiben **aus**.
2. Mit einem Token, der `perm_manage_tokens` besitzt, die Einschränkungen per API setzen:

```shell
read -rsp "Token with perm_manage_tokens: " ADMIN_TOKEN; echo
read -rp  "ID of the new certbot token (not the secret): " TOKEN_ID

H_AUTH="Authorization: Token ${ADMIN_TOKEN}"
H_JSON="Content-Type: application/json"

# Default policy: deny all writes
curl -sS "[https://desec.io/api/v1/auth/tokens/$](https://desec.io/api/v1/auth/tokens/$){TOKEN_ID}/policies/rrsets/" \
    -H "$H_AUTH" -H "$H_JSON" \
    --data '{"domain": null, "subname": null, "type": null, "perm_write": false}'

# Allow writes only to TXT records at _acme-challenge
curl -sS "[https://desec.io/api/v1/auth/tokens/$](https://desec.io/api/v1/auth/tokens/$){TOKEN_ID}/policies/rrsets/" \
    -H "$H_AUTH" -H "$H_JSON" \
    --data '{"domain": "EXAMPLE.TLD", "subname": "_acme-challenge", "type": "TXT", "perm_write": true}'

# Only accept this token from the server itself
curl -sS -X PATCH "[https://desec.io/api/v1/auth/tokens/$](https://desec.io/api/v1/auth/tokens/$){TOKEN_ID}/" \
    -H "$H_AUTH" -H "$H_JSON" \
    --data '{"allowed_subnets": ["DEINE_SERVER_IPV4/32", "2a03:4000:23:b22::/64"]}'

unset ADMIN_TOKEN
```

Ob der eingeschränkte Token funktioniert, zeigt der Dry-Run in Abschnitt 5.4.

### 5.2 API-Zugangsdaten hinterlegen

Die Datei zuerst mit restriktiven Rechten anlegen und erst dann den Token eintragen:

```shell
install -m 0600 /dev/null /etc/letsencrypt/desec.ini
micro /etc/letsencrypt/desec.ini
```

Inhalt:

```ini
dns_desec_token = DEIN_DESEC_API_TOKEN
```

### 5.3 Wildcard-Zertifikat ausstellen

Das Plugin wird laut Dokumentation über `--authenticator dns-desec` aktiviert (die Kurzform `--dns-desec` ist dort nicht vorgesehen).

```shell
certbot certonly \
  --authenticator dns-desec \
  --dns-desec-credentials /etc/letsencrypt/desec.ini \
  --dns-desec-propagation-seconds 60 \
  -d EXAMPLE.TLD \
  -d "*.EXAMPLE.TLD" \
  --agree-tos \
  -m mail@EXAMPLE.TLD \
  --non-interactive
```

### 5.4 Automatische Erneuerung mit Reload-Hook

Die Erneuerung läuft über den `certbot.timer`. Ohne Hook liefert Nginx nach der Erneuerung weiter das alte Zertifikat aus, bis es neu geladen wird.

```shell
micro /etc/letsencrypt/renewal-hooks/deploy/10-reload-services.sh
```

```shell
#!/usr/bin/env bash
# Reload services that use the certificate after every successful renewal
set -euo pipefail
nginx -t
systemctl reload nginx
# systemctl reload postfix   # enable if Postfix uses this certificate
```

```shell
chmod 0750 /etc/letsencrypt/renewal-hooks/deploy/10-reload-services.sh
systemctl list-timers certbot.timer
certbot renew --dry-run
```

### 5.5 CAA-Records setzen

CAA-Records erlauben nur Let's Encrypt, Zertifikate für die Domain auszustellen, und nur per DNS-01. Anlegen bei deSEC (Weboberfläche oder API) für die Apex-Domain:

```text
EXAMPLE.TLD.  IN CAA 0 issue "letsencrypt.org;validationmethods=dns-01"
EXAMPLE.TLD.  IN CAA 0 issuewild "letsencrypt.org;validationmethods=dns-01"
EXAMPLE.TLD.  IN CAA 0 iodef "mailto:mail@EXAMPLE.TLD"
```

Optional lässt sich die Ausstellung zusätzlich per `accounturi=...` an den eigenen ACME-Account binden (die URI steht in `/etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/*/regr.json`).

---

## 6. Nginx VHost Konfigurationen

Jeder VHost erhält eine eigene Konfigurationsdatei unter `/etc/nginx/sites-available/` und schreibt seine Logs nach `/var/log/nginx/SUB.DOMAIN.TLD/`.

### 6.1 Basis-Konfiguration für das nginx.org-Paket

Das Paket von nginx.org kennt weder `sites-available`/`sites-enabled` noch läuft es als `www-data`: Es lädt nur `conf.d/*.conf` und startet Worker als User `nginx`. Außerdem bringt es eine `default.conf` mit. Damit die Konfiguration in diesem Dokument funktioniert (und der PHP-FPM-Socket von `www-data` erreichbar ist), werden folgende Anpassungen gemacht:

```shell
sed -i 's/^user .*/user  www-data;/' /etc/nginx/nginx.conf
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/snippets
rm -f /etc/nginx/conf.d/default.conf
micro /etc/nginx/nginx.conf
```

Im `http { ... }`-Block direkt unter dem vorhandenen `include` eine Zeile ergänzen:

```nginx
http {
    # ... existing directives stay unchanged ...
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}
```

### 6.2 Globale Einstellungen

`micro /etc/nginx/conf.d/00-global.conf`

```nginx
server_tokens off;

# TLS: modern protocols and ECDHE-only ciphers (Mozilla "intermediate" without DHE)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

# Per-IP request limit, applied where needed with "limit_req zone=perip"
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
```

Catch-all-Server: Anfragen mit unbekanntem Hostnamen oder direkt an die IP bekommen weder ein Zertifikat noch eine Antwort. Das verhindert, dass Scanner VHosts und Hostnamen über die IP herausfinden.

`micro /etc/nginx/conf.d/01-default-server.conf`

```nginx
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;

    ssl_reject_handshake on;
    return 444;
}
```

Security-Header als wiederverwendbares Snippet. Achtung: Sobald in einem `location`-Block selbst ein `add_header` steht, werden die Header aus dem `server`-Block dort nicht mehr vererbt; dann das Snippet im `location` erneut einbinden.

`micro /etc/nginx/snippets/security-headers.conf`

```nginx
# HSTS: start with a low max-age (e.g. 300) while testing, then raise to 31536000
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
```

Zugriffsbeschränkung auf das Tailnet für interne VHosts und `/admin`:

`micro /etc/nginx/snippets/tailnet-only.conf`

```nginx
# Tailscale CGNAT range (IPv4) and Tailscale ULA range (IPv6)
allow 100.64.0.0/10;
allow fd7a:115c:a1e0::/48;
deny all;
```

### 6.3 Logrotate für die VHost-Logs

Die mitgelieferte Logrotate-Regel von nginx.org deckt nur `/var/log/nginx/*.log` ab, nicht die Unterverzeichnisse pro VHost. Ohne eigene Regel wachsen diese Logs unbegrenzt. Die Aufbewahrung ist bewusst kurz gehalten (IP-Adressen in Logs sind personenbezogene Daten).

`micro /etc/logrotate.d/nginx-vhosts`

```text
/var/log/nginx/*/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ ! -f /run/nginx.pid ] || kill -USR1 "$(cat /run/nginx.pid)"
    endscript
}
```

### 6.4 Main Domain & Blog (`EXAMPLE.TLD` / Grav CMS) - Öffentlich

Verwendet PHP 8.5 FPM (nach der Migration auf PHP 8.6 `php8.5-fpm.sock` durch `php8.6-fpm.sock` ersetzen, siehe Abschnitt 7.3).

Gegenüber der ersten Fassung:

- Vollständige Grav-Zugriffsregeln (basierend auf der offiziellen Grav-Nginx-Konfiguration): auch `.md`, `.yaml`, `.twig` usw. unter `/user/` sind nicht mehr abrufbar, sonst wären z. B. unveröffentlichte Seiten direkt lesbar.
- PHP wird nur für `/index.php` ausgeführt. Ein hochgeladenes `.php`-File etwa in `images/` bleibt wirkungslos.
- `/admin` ist nur aus dem Tailnet erreichbar. Wer den Grav-Admin auch von außen braucht, entfernt den `location ^~ /admin`-Block (und aktiviert dafür 2FA).
- `http2` als eigene Direktive (`listen ... http2` ist seit nginx 1.25.1 veraltet).

`micro /etc/nginx/sites-available/EXAMPLE.TLD`

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name EXAMPLE.TLD blog.EXAMPLE.TLD;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name EXAMPLE.TLD blog.EXAMPLE.TLD;

    root /var/www/EXAMPLE.TLD/html;
    index index.php index.html;

    access_log /var/log/nginx/EXAMPLE.TLD/access.log;
    error_log /var/log/nginx/EXAMPLE.TLD/error.log;

    ssl_certificate /etc/letsencrypt/live/EXAMPLE.TLD/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/EXAMPLE.TLD/privkey.pem;

    include snippets/security-headers.conf;

    client_max_body_size 64M;

    # --- Grav access controls (regex locations: first match wins, keep this order) ---
    # Dot-files (except .well-known)
    location ~ /\.(?!well-known) { deny all; }

    # Internal folders
    location ~* ^/(backup|tmp|logs|\.git|cache|bin|tests)/ { return 403; }

    # Sources and configs in core and user folders
    location ~* ^/user/(accounts|config|env|data)/ { return 403; }
    location ~* ^/(system|vendor)/.*\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ { return 403; }
    location ~* ^/user/.*\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ { return 403; }

    # Project files in the web root
    location ~* ^/(LICENSE\.txt|composer\.lock|composer\.json|nginx\.conf|web\.config|htaccess\.txt|\.htaccess)$ { return 403; }

    # Grav admin: tailnet only
    location ^~ /admin {
        include snippets/tailnet-only.conf;
        try_files $uri $uri/ /index.php?$query_string;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP: execute index.php only
    location = /index.php {
        limit_req zone=perip burst=40 nodelay;

        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param HTTP_PROXY "";
        fastcgi_read_timeout 60s;
    }

    # Every other .php file is refused
    location ~ \.php$ { return 404; }
}
```

VHost aktivieren und testen:

```shell
ln -s /etc/nginx/sites-available/EXAMPLE.TLD /etc/nginx/sites-enabled/EXAMPLE.TLD
nginx -t && systemctl reload nginx
```

### 6.5 Vorlage für interne VHosts (`admin.`, `dash.`)

Interne Subdomains sind nur aus dem Tailnet erreichbar. Port 443 ist in der Firewall für alle offen, deshalb erzwingt Nginx die Beschränkung selbst. Die eigentliche Anwendung (`location`-Blöcke) wird dienstspezifisch ergänzt.

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name admin.EXAMPLE.TLD;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name admin.EXAMPLE.TLD;

    include snippets/tailnet-only.conf;
    include snippets/security-headers.conf;

    root /var/www/admin.EXAMPLE.TLD/html;
    access_log /var/log/nginx/admin.EXAMPLE.TLD/access.log;
    error_log /var/log/nginx/admin.EXAMPLE.TLD/error.log;

    ssl_certificate /etc/letsencrypt/live/EXAMPLE.TLD/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/EXAMPLE.TLD/privkey.pem;

    # service-specific locations go here
}
```

> \[!note\] Offener Teil der Anleitung Die ursprüngliche Datei endete in Abschnitt 5.1 mitten in der Grav-Konfiguration. Die VHosts für `admin.`, `dash.`, `mta-sts.` und `pgp.` sowie die Abschnitte zu Postfix-Relay, Fishnet, Syncthing, Rclone und Python/uv fehlten. `mta-sts.` und `pgp.` (Web Key Directory) müssen öffentlich erreichbar bleiben und gehören **nicht** hinter `tailnet-only.conf`. Sicherheits-Leitplanken für die fehlenden Dienste stehen in Abschnitt 8.

### 6.6 fail2ban für Nginx

Erst aktivieren, wenn die VHosts laufen, denn die Logdateien müssen existieren (sonst startet der Jail nicht).

```shell
micro /etc/fail2ban/jail.d/20-nginx.conf
```

```ini
[nginx-limit-req]
enabled = true
port    = http,https
logpath = /var/log/nginx/*/error.log

[nginx-botsearch]
enabled = true
port    = http,https
logpath = /var/log/nginx/*/access.log
```

```shell
systemctl restart fail2ban
fail2ban-client status
```

---

## 7. PHP-FPM Härtung

### 7.1 Globale PHP-Einstellungen

Gilt für beide PHP-Versionen. Die Datei wird als `90-...` geladen und überschreibt damit die Debian-Defaults.

```shell
for V in 8.5 8.6; do
cat > "/etc/php/${V}/fpm/conf.d/90-hardening.ini" <<'EOF'
expose_php = Off
display_errors = Off
log_errors = On
cgi.fix_pathinfo = 0
allow_url_include = Off
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
; Optional, test with the application before enabling:
; disable_functions = exec,passthru,shell_exec,system,proc_open,popen
EOF
done
systemctl reload php8.5-fpm php8.6-fpm
```

### 7.2 Optional: eigener User und Pool für Grav

Standardmäßig laufen Nginx und PHP-FPM beide als `www-data`. Ein Fehler in Grav oder einem Plugin hätte dann Schreibzugriff auf alle Webroots. Mit eigenem Pool-User bleibt der Schaden auf die Seite begrenzt, und Nginx bekommt nur Lesezugriff.

```shell
adduser --system --group --no-create-home --home /var/www/EXAMPLE.TLD grav
chown -R grav:www-data /var/www/EXAMPLE.TLD/html
find /var/www/EXAMPLE.TLD/html -type d -exec chmod 750 {} +
find /var/www/EXAMPLE.TLD/html -type f -exec chmod 640 {} +
mv /etc/php/8.5/fpm/pool.d/www.conf /etc/php/8.5/fpm/pool.d/www.conf.disabled
micro /etc/php/8.5/fpm/pool.d/grav.conf
```

```ini
[grav]
user  = grav
group = grav

listen       = /run/php/php8.5-fpm-grav.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
pm.max_requests = 500

security.limit_extensions = .php
```

Danach in der VHost-Datei `fastcgi_pass` auf `unix:/run/php/php8.5-fpm-grav.sock` ändern:

```shell
systemctl restart php8.5-fpm
nginx -t && systemctl reload nginx
```

### 7.3 Migration von PHP 8.5 auf PHP 8.6

Vorgehen zur Umstellung von Grav auf PHP 8.6:

1. Grav und alle Plugins/Themes auf die neueste Version bringen und die Kompatibilität mit PHP 8.6 in den Release-Notes prüfen.
2. Pool-Datei nach `/etc/php/8.6/fpm/pool.d/grav.conf` kopieren, den Socket auf `php8.6-fpm-grav.sock` ändern.
3. In der VHost-Datei `fastcgi_pass` anpassen, `nginx -t && systemctl reload nginx`, Seite und `/admin` testen.
4. Wenn alles läuft: `apt purge 'php8.5*'` und in `setup.sh` sowie in Abschnitt 7.1 die Version 8.5 entfernen.

---

## 8. Sicherheits-Leitplanken für die weiteren Dienste

Diese Abschnitte waren in der Vorlage nicht enthalten. Hier stehen nur die Security-Vorgaben, die bei der Einrichtung eingehalten werden sollten.

**Postfix (SMTP-Relay)**

- `inet_interfaces = loopback-only`, Port 25 bleibt in der Firewall zu. Der Server versendet nur über das Relay und nimmt nichts von außen an.
- TLS zum Relay erzwingen (`smtp_tls_security_level = encrypt`), SASL-Zugangsdaten in `/etc/postfix/sasl_passwd` mit `chmod 600` und anschließend `postmap`.
- SPF, DKIM und DMARC für `EXAMPLE.TLD` setzen; MTA-STS-Policy über den öffentlichen VHost `mta-sts.` ausliefern und TLS-RPT einrichten.

**Syncthing**

- Läuft als eigener unprivilegierter User (`systemctl enable --now syncthing@DEIN_USER`).
- Die Web-GUI bleibt auf `127.0.0.1:8384` (Zugriff per SSH-Tunnel oder Tailscale), GUI-Passwort setzen.
- Port 22000 (TCP/UDP) nur über das Tailnet öffnen, falls nur dort synchronisiert wird: `ufw allow in on tailscale0 to any port 22000`.

**Rclone**

- `~/.config/rclone/rclone.conf` mit `chmod 600`; Backups offsite nur verschlüsselt über ein `crypt`-Remote, mit Zugangsdaten mit minimalen Rechten (Schreibrecht auf den Backup-Bucket, kein Löschen).

**Fishnet und eigene Dienste**

- Jeder Dienst läuft unter eigenem System-User, nie als `root`.
- systemd-Sandboxing per Drop-in (`systemctl edit DIENST`), anschließend mit `systemd-analyze security DIENST` prüfen:

```ini
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
# Directories the service must write to:
ReadWritePaths=/var/lib/DIENST
# May break JIT-based runtimes, test before enabling:
# MemoryDenyWriteExecute=yes
```

**Python / uv**

- `uv` unter einem unprivilegierten User installieren, nicht als `root` und nicht per `curl | sh` auf dem Server (stattdessen Release-Archiv laden und Prüfsumme vergleichen).
- Auf Debian 13 ist `pip` systemweit gesperrt (PEP 668); Projekte laufen in eigenen virtuellen Umgebungen (`uv venv`).

---

## 9. Abschluss-Checkliste

Auf dem Server:

```shell
ss -tulpn                                   # expected public listeners: only 80 and 443
ufw status verbose
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'
nginx -t
curl -sI [https://EXAMPLE.TLD](https://EXAMPLE.TLD) | grep -Ei 'strict-transport|x-content-type'
certbot renew --dry-run
fail2ban-client status
systemd-analyze security | head -n 20
```

Von einem Rechner **außerhalb** des Tailnets:

```shell
nmap -Pn -p 1-65535 server.EXAMPLE.TLD    # expected open: 80 and 443 only
curl -skI https://SERVER_IP/                # expected: no response (default server rejects the handshake)
curl -sI [https://EXAMPLE.TLD/admin](https://EXAMPLE.TLD/admin)        # expected: 403
curl -sI [https://EXAMPLE.TLD/user/pages/](https://EXAMPLE.TLD/user/pages/)  # expected: 403 or 404, never a file listing
```

Zusätzlich sinnvoll: TLS-Test mit `testssl.sh` oder SSL Labs, ein Audit mit `lynis audit system` (Paket `lynis`) und ein regelmäßiger Restore-Test der Backups.

---

## 10. Änderungsübersicht gegenüber der ersten Fassung

| Bereich | Problem in der ersten Fassung | Änderung |
|---|---|---|
| Formatierung | Befehle in Codeblöcken enthielten Markdown-Links der Form `[url](url)` (Copy-Paste-Artefakt), die Befehle wären fehlgeschlagen; Sprache stand teils als eigene Zeile über dem Block | Reine URLs; alle Codeblöcke im Obsidian-Format mit Sprache direkt hinter den Backticks (````shell`, ````ini`, ````nginx`, ````sql`) |
| APT-Keys | Keys in `/etc/apt/trusted.gpg.d/` gelten für alle Repositories; Sury-Zeile ohne `signed-by` | `signed-by` pro Repo, Keyrings in `/usr/share/keyrings` bzw. `/etc/apt/keyrings`, nginx-Fingerprint-Prüfung, Pinning für nginx.org und Syncthing |
| Tailscale | `systemctl enable --now tailscale`: Unit heißt `tailscaled` | Korrigiert |
| Syncthing | Kanal `stable` | `stable-v2` (laut Syncthing der aktuelle Stable-Kanal) |
| PHP | Ursprünglich PHP 8.2 & 8.4 | Auf PHP 8.5 und PHP 8.6 aktualisiert, Migrationspfad (7.3) angepasst |
| Paketliste | `python3-pip` (auf trixie systemweit gesperrt), `apt-transport-https`, `lsb-release` unnötig | Entfernt; `unattended-upgrades` und `needrestart` ergänzt |
| Firewall | SSH weltweit offen; IPv6-Regeln nicht abgesichert | `IPV6=yes` erzwungen, `ufw limit` für SSH, danach SSH nur über `tailscale0` (Abschnitt 3) |
| SSH | Keine Härtung dokumentiert | Admin-User, Key-only, kein Root-Login, `AllowUsers` |
| MariaDB | `bind-address = 0.0.0.0`; `root` mit Passwort für `100.%` (jedes Tailnet-Gerät); Löschen direkt in `mysql.user` | Bindung an die Tailscale-IP, `root` nur per Socket, `dbadmin` pro Gerät, App-User pro Datenbank, `mariadb-secure-installation` |
| certbot | `--dns-desec` statt `--authenticator dns-desec`; kein Deploy-Hook; unbeschränkter Token | Korrekter Aufruf, Reload-Hook, Token auf `_acme-challenge`/TXT und Server-IP beschränkt, CAA-Records |
| nginx.org-Paket | Kein `sites-available`/`sites-enabled`, Worker laufen als `nginx` (PHP-FPM-Socket gehört `www-data`), `default.conf` aktiv | Basis-Konfiguration (6.1) |
| TLS | Keine Protokoll-/Cipher-Vorgaben, keine Security-Header | Globale TLS-Einstellungen, HSTS und weitere Header als Snippet |
| HTTP/2 | `listen 443 ssl http2` (veraltet seit nginx 1.25.1) | `http2 on;` |
| Default-Server | Fremde Hostnamen/IP-Anfragen landen beim ersten VHost | Catch-all mit `ssl_reject_handshake` |
| Grav-Regeln | Nur wenige Verzeichnisse gesperrt: `.md`/`.yaml`/`.twig` unter `/user/` direkt abrufbar; PHP in beliebigen Ordnern ausführbar; `/admin` öffentlich | Vollständige Regeln, PHP nur über `index.php`, `/admin` nur im Tailnet, Rate-Limit |
| Logs | Logrotate deckt die VHost-Unterverzeichnisse nicht ab | Eigene Logrotate-Regel mit kurzer Aufbewahrung |
| Rechte | Webroots `www-data`, Standardrechte | Modus `0750`, optionaler eigener PHP-FPM-Pool-User |
| Postfix | Installation ohne Vorgaben (Dialog im Skript unterdrückt) | Preseed „Local only", `loopback-only` erzwungen |
| Updates | Keine automatischen Security-Updates | `unattended-upgrades` (Debian-Security), Hinweis auf manuelle Updates der Drittanbieter-Repos |
| Dokumentation | Datei endet in Abschnitt 5.1 | Grav-VHost vervollständigt, Vorlage für interne VHosts, Leitplanken für die fehlenden Dienste, Abschluss-Checkliste |

---

## Navigation

- Previous: [uv für den user installieren](https://blog.ro011110ot.de/de/vps/3-scripte-einrichten.md)
- Next: [Debian](https://blog.ro011110ot.de/de/vps/fishnet-lichess.md)
