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-networkddie 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(meisteth0, auf manchen Imagesens3).- IPv4-Gateway:
ip -4 route show default(Wert fürGatewayabgleichen).
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 voncloud-initmuss daher zuerst deaktiviert werden.
1.1 Cloud-Init Netzwerkverwaltung deaktivieren
# 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
micro /etc/network/interfaces.d/60-custom-network.cfg
# 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
# 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
# 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
# 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)
systemctl reboot
Nach dem Neustart die Erreichbarkeit erneut bestätigen:
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:
# 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).
micro setup.sh
#!/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 "===================================================="
chmod +x setup.sh
./setup.sh
Prüfen, ob die gepinnten Repositories tatsächlich gewinnen:
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
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.
adduser DEIN_ADMIN_USER
usermod -aG sudo DEIN_ADMIN_USER
Auf dem Client (Key hinterlegen, solange der Passwort-Login noch aktiv ist):
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.
micro /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers DEIN_ADMIN_USER
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
sshd -t && systemctl reload ssh
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowusers'
3.4 SSH nur noch über Tailscale erlauben
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:
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.
micro /etc/fail2ban/jail.d/10-local.conf
[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
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.
tailscale ip -4
micro /etc/mysql/mariadb.conf.d/99-hardening.cnf
[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:
systemctl edit mariadb
[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):
mariadb-secure-installation
Danach in der MariaDB-Shell (sudo mariadb):
-- 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
# 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.oderdash.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).
- In der deSEC-Weboberfläche einen neuen Token anlegen. „Domains anlegen/löschen" und „Tokens verwalten" bleiben aus.
- Mit einem Token, der
perm_manage_tokensbesitzt, die Einschränkungen per API setzen:
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:
install -m 0600 /dev/null /etc/letsencrypt/desec.ini
micro /etc/letsencrypt/desec.ini
Inhalt:
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).
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.
micro /etc/letsencrypt/renewal-hooks/deploy/10-reload-services.sh
#!/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
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:
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:
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:
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
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
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
# 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
# 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
/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,.twigusw. unter/user/sind nicht mehr abrufbar, sonst wären z. B. unveröffentlichte Seiten direkt lesbar. - PHP wird nur für
/index.phpausgeführt. Ein hochgeladenes.php-File etwa inimages/bleibt wirkungslos. /administ nur aus dem Tailnet erreichbar. Wer den Grav-Admin auch von außen braucht, entfernt denlocation ^~ /admin-Block (und aktiviert dafür 2FA).http2als eigene Direktive (listen ... http2ist seit nginx 1.25.1 veraltet).
micro /etc/nginx/sites-available/EXAMPLE.TLD
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:
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.
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.undpgp.sowie die Abschnitte zu Postfix-Relay, Fishnet, Syncthing, Rclone und Python/uv fehlten.mta-sts.undpgp.(Web Key Directory) müssen öffentlich erreichbar bleiben und gehören nicht hintertailnet-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).
micro /etc/fail2ban/jail.d/20-nginx.conf
[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
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.
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.
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
[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:
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:
- Grav und alle Plugins/Themes auf die neueste Version bringen und die Kompatibilität mit PHP 8.6 in den Release-Notes prüfen.
- Pool-Datei nach
/etc/php/8.6/fpm/pool.d/grav.confkopieren, den Socket aufphp8.6-fpm-grav.sockändern. - In der VHost-Datei
fastcgi_passanpassen,nginx -t && systemctl reload nginx, Seite und/admintesten. - Wenn alles läuft:
apt purge 'php8.5*'und insetup.shsowie 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_passwdmitchmod 600und anschließendpostmap. - SPF, DKIM und DMARC für
EXAMPLE.TLDsetzen; MTA-STS-Policy über den öffentlichen VHostmta-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.confmitchmod 600; Backups offsite nur verschlüsselt über eincrypt-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 mitsystemd-analyze security DIENSTprüfen:
[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
uvunter einem unprivilegierten User installieren, nicht alsrootund nicht percurl | shauf dem Server (stattdessen Release-Archiv laden und Prüfsumme vergleichen).- Auf Debian 13 ist
pipsystemweit gesperrt (PEP 668); Projekte laufen in eigenen virtuellen Umgebungen (uv venv).
9. Abschluss-Checkliste
Auf dem Server:
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:
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 |