← Übersicht  ·  Skripte & Dateien  ·  cloud-host · Betrieb

Server-Hardening

Einen öffentlich erreichbaren Hetzner-Server absichern – was wirklich zählt, in Reihenfolge: SSH (Secure Shell) härten, automatische Updates, nftables am Server (und die Docker-Firewall-Falle), CrowdSec statt fail2ban, sysctl, Nutzer & sudo, Docker-Hardening, auditd, und mit Lynis prüfen. Plus: was Übertreibung ist.

Stand: 30. August 2026 Debian / Ubuntu 0 €

Platzhalter einsetzen – nur im Browser

Was zählt wirklich

Die Server-Basis und die Tailscale-Anleitung erledigen schon das Meiste (Key-only SSH, Cloud-Firewall, kein offener Port). Diese Anleitung ist für den Fall, dass der Server doch öffentlich sein muss (Web auf 80/443).

PrioMaßnahmeAufwand
1Angriffsfläche klein: nur 22 (auf IP/Tailnet) + 80/443 offen, sonst nichtsklein
2Automatische Sicherheitsupdates + Auto-Reboot nachtsklein
3SSH: kein Root, keine Passwörter, AllowUsers, moderne Kryptoklein
4CrowdSec (blockt Scanner & Brute-Force automatisch)mittel
5nftables am Server (Verteidigung in der Tiefe) – Achtung Dockermittel
6sysctl-Hardening, Docker-daemon.json, auditdmittel
Die Anwendungen aktuell halten und app-seitige Auth (starke Passwörter, 2FA) – hier passieren die meisten echten Einbrüchelaufend

1 SSH härten

Datei · /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
# moderne Krypto (Mozilla "modern") -- ssh-audit prueft das
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
Server
sshd -t && systemctl reload ssh
# nur schwache Host-Keys entfernen (ed25519 + rsa behalten)
rm -f /etc/ssh/ssh_host_ecdsa_key* /etc/ssh/ssh_host_dsa_key*
systemctl restart ssh
Nicht aussperren

In einer zweiten SSH-Sitzung neu einloggen und prüfen, bevor du die erste schließt. Rettung sonst nur über die Hetzner-Konsole (Rescue/VNC).

  • Port ändern bringt wenig gegen gezielte Angriffe, aber viel Ruhe in den Logs. Wenn, dann in 99-hardening.conf und in der Firewall.
  • 2FA für SSH: am elegantesten über Tailscale-SSH im Check-Modus (erzwingt Browser-Re-Auth). Sonst libpam-google-authenticator.

2 Automatische Updates

Server
apt-get install -y unattended-upgrades needrestart apt-listchanges
dpkg-reconfigure -plow unattended-upgrades      # "Yes"
Datei · /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:12";
Unattended-Upgrade::MailReport "on-change";
Unattended-Upgrade::Mail "du@example.com";   # braucht SMTP-Relay -- oder weglassen und per Log/Monitoring pruefen
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
  • Debian zusätzlich ${distro_id}:${distro_codename}-updates in Allowed-Origins aufnehmen.
  • needrestart startet nach Bibliotheks-Updates die betroffenen Dienste neu (NEEDRESTART_MODE=a in /etc/needrestart/needrestart.conf für unbeaufsichtigt).
  • Kernel-Live-Patching (Ubuntu Livepatch, Debian nicht offiziell) spart Reboots – für einen Hobby-Server ist der 04:12-Reboot ok.
  • Überwachen: der Netdata-Alarm „Reboot nötig“ bzw. „Pakete veraltet“.

3 nftables & die Docker-Falle

Hinter der Hetzner-Cloud-Firewall – als zweite Schicht, und weil die Cloud-Firewall keine Regeln zwischen Prozessen kennt.

Datei · /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif lo accept
    ip protocol icmp accept
    ip6 nexthdr ipv6-icmp accept
    tcp dport 22 accept
    tcp dport { 80, 443 } accept
    # Rest wird verworfen (policy drop)
  }
}
Server
nft -c -f /etc/nftables.conf && systemctl enable --now nftables
Docker umgeht die Firewall

Ein ports: ["8080:8080"] in compose.yaml ist von außen offen – egal was in nftables input steht. Docker schreibt eigene DNAT-/FORWARD-Regeln. Drei Auswege:

1. Nie Ports veröffentlichen – alles hinter einen Reverse Proxy im Docker-Netz (so machen es die cloud-host-Guides). Dann ist nur Traefik/nginx auf 80/443.
2. An localhost binden: 127.0.0.1:8080:8080.
3. In die DOCKER-USER-Kette filtern (nftables: iifname "eth0" ip saddr != <vertraut> drop in einer docker-user-Chain hooken).

4 CrowdSec

Wertet Logs aus (SSH, nginx/Traefik), erkennt Brute-Force und Scans, und ein Bouncer sperrt die IP (Internet Protocol) in nftables. Plus: die Community-Blocklist bekannter bösartiger IPs (Internet-Protocol-Adressen).

Server
curl -s https://install.crowdsec.net | sh
apt-get install -y crowdsec crowdsec-firewall-bouncer-nftables

cscli collections install crowdsecurity/sshd crowdsecurity/nginx \
  crowdsecurity/http-cve crowdsecurity/base-http-scenarios
systemctl reload crowdsec

# optional: kostenloses Dashboard + Community-Blocklist
cscli console enroll <enroll-key>
Server · im Blick behalten
cscli metrics          # was wurde geparst, was ausgeloest
cscli decisions list   # aktuell gesperrte IPs
cscli alerts list

fail2ban tut es auch (/etc/fail2ban/jail.local[sshd] + [recidive], bantime.increment=true). CrowdSec ist moderner, kann HTTP-Angriffe und teilt Bedrohungsdaten – für einen Web-Server die bessere Wahl.

5 Kernel & sysctl

Datei · /etc/sysctl.d/99-hardening.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 1
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0
Server
sysctl --system

Docker/k8s brauchen net.ipv4.ip_forward = 1 – das setzt Docker selbst, nicht überschreiben. rp_filter ggf. auf 2 (loose), wenn asymmetrisches Routing (mehrere Interfaces / Tailscale-Subnet-Router) sonst Pakete verwirft.

6 Nutzer, sudo, Rechte

  • Arbeiten als Nicht-Root (deploy), sudo nur wo nötig. NOPASSWD ausschließlich für skriptbare Einzelbefehle (/etc/sudoers.d/deploy: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app).
  • Ungenutzte Konten sperren: usermod -L -e 1 <user>. Der cloud-init-Default-User bleibt (Hetzner braucht ihn), aber Passwort-Login ist ja aus.
  • Dienst-Konten ohne Shell: usermod -s /usr/sbin/nologin <svc>.
  • Falls du lokale Nutzer mit Passwort hast: Alterung in /etc/login.defs (PASS_MAX_DAYS 365), starke Hashes (yescrypt ist Default auf Debian 12).
  • umask 027 in /etc/profile.d/, /root auf 700.

7 Docker/Podman härten

Datei · /etc/docker/daemon.json
{
  "no-new-privileges": true,
  "icc": false,
  "userland-proxy": false,
  "live-restore": true,
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}
  • Im Compose je Service: security_opt: ["no-new-privileges:true"], cap_drop: ["ALL"] (+ cap_add nur was nötig), read_only: true mit tmpfs für Schreibpfade.
  • Nie --privileged, nie /var/run/docker.sock in einen Container (= Root auf dem Host). Ausnahmen (Traefik, Watchtower) read-only und nur wenn wirklich nötig, besser ein Socket-Proxy.
  • Images: Tags und Digests pinnen, regelmässig docker scout cves <image> oder trivy image <image>.
  • Rootless: Podman rootless (die container-host-Anleitung) oder Docker rootless-Modus – ein Container-Ausbruch landet dann nicht als Root auf dem Host.
  • docker-bench-security gibt eine Checkliste mit Punkten.

8 auditd & Integrität

Datei · /etc/audit/rules.d/hardening.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k root_cmd
-e 2
Server
apt-get install -y auditd
augenrules --load && auditctl -l
ausearch -k sshd -ts today        # wer hat sshd_config angefasst
Was die Regeln bedeuten

auditd hat eine eigene, sehr knappe Syntax. Sie zerfällt in zwei Sorten: Watches (-w) auf Dateien und Rules (-a) auf Systemaufrufe.

SchalterSortebewirkt
apt-get install -y auditd Server auditd ist der Protokolldienst des Linux-Kernels – er schreibt nach /var/log/audit/audit.log, unabhängig von syslog und journald. Das -y beantwortet die Rückfrage im Voraus mit Ja, damit der Befehl in einem Skript nicht auf eine Eingabe wartet.
/etc/audit/rules.d/<name>.rules Dateiname Die Regeln stehen nicht in einer einzigen Datei, sondern in einem Verzeichnis – jede .rules-Datei darin ist ein Baustein, und augenrules setzt sie in alphabetischer Reihenfolge zusammen. Deshalb lässt sich eigenes Regelwerk hinzufügen, ohne das mitgelieferte anzufassen. Die Reihenfolge ist wichtig, weil -e 2 am Ende alles einfriert.
-w /etc/passwd Watch Überwacht einen Pfad. Ein abschließender Schrägstrich wie bei /etc/sudoers.d/ bedeutet: das ganze Verzeichnis samt neu angelegter Dateien – sonst entkäme genau der Trick, eine zusätzliche Datei danebenzulegen statt die bekannte zu ändern.
-p wa Watch Welche Zugriffe protokolliert werden: w = schreiben, a = Attribute (Rechte, Eigentümer). Bewusst ohne r – Lesezugriffe auf /etc/passwd passieren ständig und im Sekundentakt, das Log wäre danach unbrauchbar.
-k identity beide Ein frei wählbares Schlüsselwort, das an jeden Log-Eintrag angehängt wird. Nur dadurch funktioniert ausearch -k sshd weiter unten – ohne -k müsste man das Log nach Pfaden durchsuchen.
-a always,exit Rule „Immer protokollieren, und zwar beim Verlassen des Systemaufrufs.“ Erst dort steht fest, ob er erfolgreich war – beim Eintritt wäre das noch offen.
-F arch=b64 Rule Gilt für 64-Bit-Aufrufe. Die Angabe ist Pflicht, weil Systemaufrufe je Architektur andere Nummern tragen. Auf einem System, das auch 32-Bit-Programme ausführen kann, gehört dieselbe Zeile ein zweites Mal mit arch=b32 dazu – sonst bleibt genau dieser Weg unbeobachtet.
-S execve Rule Der Systemaufruf, mit dem jedes Programm gestartet wird. Diese eine Zeile protokolliert also jeden Befehl, der die folgenden Bedingungen erfüllt – samt Argumenten.
-F euid=0 Rule Nur Aufrufe, die effektiv als root laufen. Das trifft auch alles hinter sudo, denn dort ist die effektive Kennung bereits 0, während die ursprüngliche erhalten bleibt.
-F auid>=1000
-F auid!=-1
Rule Die Audit-UID ist die Kennung, mit der sich jemand ursprünglich angemeldet hat; sie bleibt über sudo und su hinweg unverändert. >=1000 filtert auf echte Menschen (Systemkonten liegen darunter), !=-1 wirft die Prozesse ohne Anmeldung heraus – also die Dienste, die beim Systemstart hochkommen. Zusammen bedeutet die Zeile: protokolliere jeden Befehl, den ein angemeldeter Mensch als root ausführt, und ignoriere das Grundrauschen des Systems. In manchen älteren Beispielen steht dafür auid!=4294967295 – dieselbe Zahl, nur unsigniert geschrieben.
-e 2 Abschluss Friert das Regelwerk ein: bis zum nächsten Neustart lässt sich keine Regel mehr ändern oder löschen – auch nicht von root. Das ist der Punkt der ganzen Übung, denn ein Angreifer mit Root-Rechten würde sonst zuerst die Protokollierung abschalten. Der Preis: jede eigene Änderung an den Regeln braucht ebenfalls einen Neustart. Beim Einrichten deshalb zuletzt hinzufügen; -e 1 lässt das Regelwerk offen.
augenrules --load Server Setzt alle Dateien aus /etc/audit/rules.d/ zusammen und lädt sie. Der direkte Weg auditctl -R würde diese eine Datei laden und die übrigen übergehen. auditctl -l danach zeigt, was tatsächlich aktiv ist – nach -e 2 die einzige Kontrolle, die bleibt.
ausearch -k sshd -ts today Server Sucht über das Schlüsselwort statt über Pfade. -ts („time start“) versteht neben today auch recent, this-week oder einen Zeitpunkt – ohne die Angabe durchsucht es das gesamte Log.
  • Datei-Integrität: debsums -c (leicht, prüft Paketdateien) – oder AIDE (gründlich, aber Pflege: nach jedem Update aide --update).
  • Die audit-Logs in den Monitoring-Kanal (Netdata liest /var/log/audit/audit.log), oder wöchentlich per Cron aureport --auth --summary ansehen.

9 Prüfen: Lynis, ssh-audit

Server
apt-get install -y lynis
lynis audit system            # "Hardening index" 0-100, Ziel ~70+, plus konkrete Vorschlaege
Laptop / von außen
ssh-audit dienste            # SSH-Krypto bewerten (pip install ssh-audit oder docker)
nmap -Pn -p- dienste          # von aussen -> nur 22/80/443 (oder nur 80/443 mit Tailscale)

+ Was Übertreibung ist

Für einen 1–3-Personen-Server holen diese Dinge fast nichts mehr raus, kosten aber viel Pflege – erst machen, wenn ein konkreter Grund da ist:

  • Eigene AppArmor/SELinux (Security-Enhanced Linux)-Profile schreiben (die Distro-Defaults anlassen reicht).
  • Der volle CIS-Benchmark Punkt für Punkt.
  • Ein HIDS/IDS außer CrowdSec (Wazuh, OSSEC, Suricata) – Rauschen, Pflege, kaum Mehrwert bei einem Server.
  • Strenges Egress-Filtering (raus-Regeln in nftables) – bricht ständig Updates/Container und schützt wenig.
  • Verschlüsselte Root-Partition – auf einem Cloud-Server ohne physischen Zugriff wenig sinnvoll, Hetzner sieht die Platte ohnehin nicht (aber Backups verschlüsseln – ja!).
Die 90 %

Key-only SSH · wenig offene Ports · automatische Updates · CrowdSec · app-seitige Auth stark und die Anwendungen aktuell. Der Rest ist Feinschliff.

+ Hardening ab cloud-init

Alles Obige einmal in die Basis-cloud-init, dann startet jeder neue Server gehardened.

Zusatz zu cloud-init.yaml (Server-Basis, Abschnitt 7)
packages: [ unattended-upgrades, needrestart, nftables, auditd, lynis ]
write_files:
  - path: /etc/ssh/sshd_config.d/99-hardening.conf
    content: |
      PermitRootLogin no
      PasswordAuthentication no
      KbdInteractiveAuthentication no
      MaxAuthTries 3
  - path: /etc/sysctl.d/99-hardening.conf
    content: |
      kernel.kptr_restrict = 2
      kernel.dmesg_restrict = 1
      fs.protected_regular = 2
  - path: /etc/nftables.conf
    content: |
      #!/usr/sbin/nft -f
      flush ruleset
      table inet filter { chain input {
        type filter hook input priority 0; policy drop;
        ct state established,related accept ; iif lo accept ; ct state invalid drop
        ip protocol icmp accept ; ip6 nexthdr ipv6-icmp accept
        tcp dport { 22, 80, 443 } accept
      } }
runcmd:
  - systemctl reload ssh
  - sysctl --system
  - systemctl enable --now nftables
  - curl -s https://install.crowdsec.net | sh
  - apt-get install -y crowdsec crowdsec-firewall-bouncer-nftables

Danach einmal lynis audit system und einen Snapshot – ab dem up bootet jeder Server so.

⌂ Cockpit