VPS-Setup Guide: Debian 13 (Trixie)
Dokumentation für die Einrichtung eines VPS mit Git-basiertem Deployment für eine Astro.js-Webseite im Hybrid-Modus (statisch + optionales SSR) mit Node.js-Adapter, systemd-Service und Caddy als Reverse Proxy.
Der Ansatz läuft bewusst ohne Container — keine Docker-Schicht, kein Compose-File, keine Orchestrierung. Prozesse laufen direkt als systemd-Services auf dem OS. Das hält das Setup einfach und den administrativen Aufwand gering: Mit Docker müssten zusätzlich Base-Images und die Docker Engine selbst aktuell gehalten werden. Hier übernimmt unattended-upgrades die OS-Pakete automatisch — mehr gibt es nicht zu pflegen.
Alternativ könnte man auf eine Managed-Plattform wie Netlify, Vercel oder Cloudflare Pages setzen — dort entfällt das Server-Management komplett, Deployments sind per git push sofort live und ein globales CDN ist inklusive. Für rein statische Seiten ist das oft die pragmatischste Wahl. Der VPS-Weg ist keine technisch überlegene Alternative, sondern eine bewusste Entscheidung: Man versteht was passiert, kann jeden Schritt nachvollziehen — SSH, Firewall, Reverse Proxy, TLS — und hat volle Kontrolle über die eigene Infrastruktur. Auf Kosten von etwas mehr Aufwand und Eigenverantwortung.
Inhaltsverzeichnis
- VPS Grundeinrichtung
- SSH-Zugang absichern
- Firewall einrichten (UFW)
- Automatische Updates & Sicherheit
- Node.js installieren
- Caddy installieren
- Git Bare Repository einrichten
- Post-receive Hook konfigurieren
- Astro Hybrid-Modus & Node.js-Adapter
- systemd Service einrichten
- Caddy konfigurieren
- Lokales Deployment einrichten
- Deployment-Workflow
1. VPS Grundeinrichtung
Als root einloggen
ssh root@YOUR_SERVER_IP
System aktualisieren
apt update && apt upgrade -y
Neuen Benutzer anlegen
Root sollte nicht für den täglichen Betrieb verwendet werden. Einen neuen Benutzer mit sudo-Rechten anlegen:
adduser deploy
usermod -aG sudo deploy
Als neuer Benutzer einloggen
su - deploy
2. SSH-Zugang absichern
SSH-Key auf dem lokalen Rechner generieren (falls noch nicht vorhanden)
# Lokal ausführen
ssh-keygen -t ed25519 -C "youremail@example.com"
Public Key auf den Server kopieren
Optional (Hetzner): Bei der Servereinrichtung in der Hetzner Cloud Console kann unter
SSH-Keysder Public Key direkt hinterlegt werden. Er wird dann automatisch für denroot-User eingerichtet — der manuelle Schritt unten entfällt in diesem Fall.
# Lokal ausführen
ssh-copy-id deploy@YOUR_SERVER_IP
Oder manuell:
# Auf dem Server als deploy
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# Public Key einfügen (.pub Datei)
chmod 600 ~/.ssh/authorized_keys
SSH-Konfiguration härten
sudo nano /etc/ssh/sshd_config
Folgende Werte setzen oder anpassen:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
SSH-Dienst neu starten:
sudo systemctl restart ssh
Wichtig: Vor dem Ausloggen in einem zweiten Terminal testen, ob der Login mit dem SSH-Key funktioniert.
Tipp — SSH Config: Mit einer lokalen
~/.ssh/configlässt sich der Server bequem per Kurzname ansprechen:Host meinvps HostName YOUR_SERVER_IP User deploy IdentityFile ~/.ssh/id_ed25519Danach genügt im Terminal:
ssh meinvps
3. Firewall einrichten (UFW)
Zwei Ebenen — warum beide sinnvoll sind
Viele VPS-Anbieter wie Hetzner bieten eine vorgelagerte Firewall auf Netzwerk-Ebene an. Diese filtert Traffic bereits im Rechenzentrum, bevor er den Server überhaupt erreicht — ohne CPU-Overhead auf dem VPS selbst.
UFW (Uncomplicated Firewall) läuft dagegen direkt auf dem Server (Host-Ebene, via iptables). Beide Ebenen ergänzen sich:
| Hetzner Firewall | UFW | |
|---|---|---|
| Ebene | Netzwerk (vor dem Server) | Host (auf dem Server) |
| Schützt gegen | Externe Angriffe | Auch interne Angriffe im Rechenzentrum |
| Anbieterabhängig | Ja | Nein |
| CPU-Overhead | Keiner | Minimal |
Empfehlung: Beide aktivieren. Die Hetzner Firewall als erste Verteidigungslinie, UFW als zusätzliche Absicherung — unabhängig vom Anbieter und portabel auf jeden anderen VPS.
Hetzner Cloud Firewall einrichten (Netzwerk-Ebene)
In der Hetzner Cloud Console unter Firewalls → Firewall erstellen folgende Regeln für eingehenden Traffic (Inbound) setzen:
| Protokoll | Port | Quelle | Beschreibung |
|---|---|---|---|
| TCP | 22 | 0.0.0.0/0, ::/0 | SSH |
| TCP | 80 | 0.0.0.0/0, ::/0 | HTTP |
| TCP | 443 | 0.0.0.0/0, ::/0 | HTTPS |
Ausgehender Traffic (Outbound) kann auf „Alle erlauben” belassen werden.
Die Firewall anschließend dem VPS zuweisen unter Firewall → Server zuweisen.
Tipp: SSH auf Port 22 nur für die eigene IP zu beschränken (
Meine IPin der Hetzner Console) erhöht die Sicherheit deutlich, sofern die eigene IP-Adresse statisch ist.
UFW installieren und konfigurieren (Host-Ebene)
sudo apt install ufw -y
# Grundregeln
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH erlauben (wichtig: zuerst, sonst sperrt man sich aus)
sudo ufw allow OpenSSH
# HTTP und HTTPS für Caddy
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Firewall aktivieren
sudo ufw enable
# Status prüfen
sudo ufw status verbose
4. Automatische Updates & Sicherheit
Automatische Sicherheitsupdates (unattended-upgrades)
Debian bringt unattended-upgrades mit, das Sicherheitsupdates automatisch einspielt — ohne manuellen Eingriff.
sudo apt install unattended-upgrades apt-listchanges -y
# Konfiguration aktivieren
sudo dpkg-reconfigure --priority=low unattended-upgrades
Den Dialog mit Yes bestätigen. Damit werden automatisch Updates aus dem Debian-Security-Repository eingespielt.
Die Konfigurationsdatei liegt unter:
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
Die wichtigsten Optionen dort:
// Nur Sicherheitsupdates (Standard, empfohlen)
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename},label=Debian-Security";
};
// E-Mail bei Fehlern (optional)
// Unattended-Upgrade::Mail "you@example.com";
// Pakete die nicht automatisch aktualisiert werden sollen
// Unattended-Upgrade::Package-Blacklist { };
Automatischen Neustart prüfen und konfigurieren
Nach Kernel-Updates ist manchmal ein Neustart nötig. Ob automatische Reboots aktiviert sind, lässt sich so prüfen:
grep "Automatic-Reboot" /etc/apt/apt.conf.d/50unattended-upgrades
Wenn die Zeile auskommentiert ist (//) oder fehlt, werden keine automatischen Neustarts durchgeführt. Um sie zu aktivieren:
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
Folgende Zeilen einkommentieren bzw. setzen:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
Hinweis: Automatische Reboots sind auf einem Produktivserver eine Abwägung — sie halten den Server sicher, können aber kurze Ausfallzeiten verursachen. Für eine einfache statische Webseite ist das in der Regel akzeptabel.
Dry-Run zum Testen
sudo unattended-upgrades --dry-run --debug
Fail2ban (Schutz gegen Brute-Force)
Fail2ban überwacht Log-Dateien und sperrt IPs automatisch, die zu viele fehlgeschlagene Login-Versuche erzeugen — besonders wichtig für SSH.
sudo apt install fail2ban -y
Lokale Konfiguration anlegen
Fail2ban-Konfiguration nie direkt in jail.conf bearbeiten — diese wird bei Updates überschrieben. Stattdessen eine lokale Überschreibung anlegen:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Den [sshd]-Block anpassen:
[sshd]
enabled = true
port = ssh
maxretry = 5
findtime = 10m
bantime = 1h
| Option | Bedeutung |
|---|---|
maxretry | Anzahl fehlgeschlagener Versuche bevor eine IP gesperrt wird |
findtime | Zeitfenster in dem maxretry erreicht werden muss |
bantime | Wie lange die IP gesperrt bleibt (1h, 1d, -1 = permanent) |
Fail2ban starten und aktivieren
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Status prüfen
sudo systemctl status fail2ban
# Gesperrte IPs anzeigen
sudo fail2ban-client status sshd
5. Node.js installieren
Astro benötigt Node.js für den Build-Prozess. Es wird empfohlen, Node.js über nvm (Node Version Manager) zu installieren, um flexibel zwischen Versionen wechseln zu können.
nvm installieren
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# Shell neu laden
source ~/.bashrc
Node.js installieren
# Aktuelle LTS-Version installieren
nvm install --lts
# Version prüfen
node -v
npm -v
6. Caddy installieren
Caddy installieren
Auf Debian 13 (Trixie) ist Caddy in den offiziellen Paketquellen enthalten:
sudo apt update
sudo apt install caddy -y
Hinweis: Die Caddy-Dokumentation beschreibt alternativ die Installation über ein Cloudsmith-Repository — das liefert immer die aktuellste Upstream-Version, ist aber für die meisten Anwendungsfälle nicht nötig.
Caddy-Status prüfen
sudo systemctl status caddy
Caddy startet automatisch als Systemdienst und verwaltet TLS-Zertifikate (Let’s Encrypt) selbstständig.
7. Git Bare Repository einrichten
Ein Bare Repository auf dem Server dient als remote, in das vom lokalen Rechner gepusht wird. Ein Post-receive Hook löst dann automatisch den Build und das Deployment aus.
Verzeichnisstruktur anlegen
# Bare Repo und Webroot unter /var/www anlegen
sudo mkdir -p /var/www/yourdomain.com.git
sudo mkdir -p /var/www/yourdomain.com
# deploy-User als Eigentümer setzen
sudo chown -R deploy:deploy /var/www/yourdomain.com.git /var/www/yourdomain.com
Bare Repository initialisieren
cd /var/www/yourdomain.com.git
git init --bare
8. Post-receive Hook konfigurieren
Der Hook wird nach jedem Push automatisch ausgeführt. Er checkt den Code aus, installiert Abhängigkeiten, baut das Astro-Projekt und kopiert den Output in den Webroot.
Hook-Datei erstellen
nano /var/www/yourdomain.com.git/hooks/post-receive
Inhalt:
#!/usr/bin/env bash
set -euo pipefail
# --- Konfiguration ---
REPO_DIR="/var/www/yourdomain.com.git"
WORK_DIR="/tmp/yourdomain.com-build"
WEB_ROOT="/var/www/yourdomain.com"
BRANCH="main"
LOG_FILE="/var/log/deploy-yourdomain.com.log"
# nvm im non-interaktiven Shell-Kontext laden
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
echo "===> Deployment gestartet: $(date)"
# Arbeitsverzeichnis vorbereiten
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
# Code auschecken
echo "---> Code wird ausgecheckt..."
git --work-tree="$WORK_DIR" --git-dir="$REPO_DIR" checkout -f "$BRANCH"
cd "$WORK_DIR"
# Abhängigkeiten installieren
echo "---> pnpm install..."
pnpm install --frozen-lockfile
# Astro Build
echo "---> Astro Build..."
pnpm run build
# Webroot atomar aktualisieren
echo "---> Dateien werden deployed..."
rsync -a --delete dist/ "$WEB_ROOT/"
# Service neu starten
echo "---> Service wird neu gestartet..."
sudo -n systemctl restart yourdomain.com
# Deployment protokollieren
echo "$(date): deployed $(git --git-dir="$REPO_DIR" rev-parse HEAD)" >> "$LOG_FILE"
echo "===> Deployment abgeschlossen: $(date)"
Hook ausführbar machen
chmod +x /var/www/yourdomain.com.git/hooks/post-receive
Hinweis:
pnpm install --frozen-lockfileinstalliert exakt die Versionen aus derpnpm-lock.yaml— deterministisch und ohne ungewollte Updates. Entsprichtnpm cibei npm-Projekten.
9. Astro Hybrid-Modus & Node.js-Adapter
Wie Astro’s Output-Modi funktionieren
Astro unterstützt drei Output-Modi:
| Modus | output in astro.config.mjs | Verhalten |
|---|---|---|
| Static | 'static' (Standard) | Alle Seiten werden zur Build-Zeit gerendert |
| Hybrid | nicht gesetzt + Node-Adapter | Standard statisch, einzelne Seiten können SSR sein |
| Server | 'server' | Alle Seiten werden zur Laufzeit gerendert |
Das hier verwendete Setup läuft im Hybrid-Modus: der Node.js-Adapter ist installiert, output ist nicht explizit auf server gesetzt — damit bleiben alle Seiten standardmäßig statisch vorgerendert.
Einzelne Seiten auf SSR umstellen
Durch export const prerender = false im Frontmatter einer Seite wird diese zur Laufzeit serverseitig gerendert — alle anderen Seiten bleiben statisch:
---
// src/pages/api/kontakt.astro
export const prerender = false
// Diese Seite wird bei jedem Request gerendert
---
Node.js-Adapter installieren
# Lokal im Projektverzeichnis
npx astro add node
Das fügt @astrojs/node automatisch zur astro.config.mjs hinzu:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({
mode: 'standalone',
}),
});
Der Build erzeugt dann neben den statischen Dateien in dist/client/ auch einen Server-Einstiegspunkt unter dist/server/entry.mjs.
10. systemd Service einrichten
Der Node.js-Prozess muss dauerhaft auf dem Server laufen, damit Caddy Anfragen an ihn weiterleiten kann. systemd übernimmt dabei das Starten, Neustarten bei Absturz und den Autostart nach einem Server-Reboot.
Service-Datei erstellen
sudo nano /etc/systemd/system/yourdomain.com.service
Inhalt:
[Unit]
Description=Astro Node.js Server – yourdomain.com
After=network.target
[Service]
User=deploy
WorkingDirectory=/var/www/yourdomain.com
EnvironmentFile=/var/www/yourdomain.com/.env
ExecStart=node ./dist/server/entry.mjs
Restart=always
RestartSec=5
Environment=HOST=127.0.0.1
Environment=PORT=4321
[Install]
WantedBy=multi-user.target
| Option | Bedeutung |
|---|---|
User | Prozess läuft nicht als root |
EnvironmentFile | Lädt Umgebungsvariablen aus .env |
Restart=always | Neustart bei Absturz oder manuellem Stop |
RestartSec=5 | Wartet 5 Sekunden vor Neustart |
HOST=127.0.0.1 | Node lauscht nur lokal — Caddy ist der einzige Zugang |
PORT=4321 | Port auf dem Node.js erreichbar ist |
Service aktivieren und starten
# systemd neu laden
sudo systemctl daemon-reload
# Service beim Booten automatisch starten
sudo systemctl enable yourdomain.com
# Service jetzt starten
sudo systemctl start yourdomain.com
# Status prüfen
sudo systemctl status yourdomain.com
Service-Restart im Hook erlauben
Der Post-receive Hook startet den Service nach dem Build automatisch neu (via sudo -n systemctl restart). Damit deploy das ohne Passwort darf, eine sudoers-Regel anlegen:
sudo visudo -f /etc/sudoers.d/deploy-restart
Inhalt:
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart yourdomain.com
Wichtig — Dateiname ohne Punkte: Dateien in
/etc/sudoers.d/werden vonsudostillschweigend ignoriert, wenn der Dateiname einen Punkt enthält./etc/sudoers.d/adrian-altner.dewürde also nie geladen — kein Fehler, einfach keine Wirkung. Der Dateiname muss punktfrei sein (z.B.deploy-restart). Der Service-Name in der Regel selbst (yourdomain.com) darf dagegen Punkte enthalten.
11. Caddy konfigurieren
Caddy fungiert als Reverse Proxy — er nimmt eingehende HTTPS-Anfragen entgegen und leitet sie an den lokal laufenden Node.js-Prozess weiter. TLS-Zertifikate werden automatisch über Let’s Encrypt bezogen und erneuert.
Caddyfile bearbeiten
sudo nano /etc/caddy/Caddyfile
Inhalt:
yourdomain.com {
reverse_proxy 127.0.0.1:4321
}
www.yourdomain.com {
redir https://yourdomain.com{uri} permanent
}
reverse_proxyleitet alle Anfragen an den Node.js-Server weiterwww.yourdomain.comwird per permanentem Redirect (301) auf die Hauptdomain umgeleitet
Caddy neu laden
sudo systemctl reload caddy
Konfiguration validieren
sudo caddy validate --config /etc/caddy/Caddyfile
TLS-Zertifikat
Caddy bezieht automatisch ein Let’s Encrypt Zertifikat, sobald die Domain auf die Server-IP zeigt und Port 80/443 erreichbar sind — kein manueller Schritt nötig.
Voraussetzung — DNS-Einträge: Damit das funktioniert, müssen beim Domainanbieter (z.B. Cloudflare, Namecheap, IONOS) die DNS-Einträge auf die Server-IP zeigen:
Typ Name Wert A@IPv4-Adresse des Servers AAAA@IPv6-Adresse des Servers (falls vorhanden) AwwwIPv4-Adresse des Servers AAAAwwwIPv6-Adresse des Servers (falls vorhanden) A*IPv4-Adresse des Servers (optional, für weitere Subdomains) AAAA*IPv6-Adresse des Servers (optional, für weitere Subdomains) DNS-Änderungen können bis zu 24 Stunden brauchen bis sie weltweit propagiert sind. Mit
dig yourdomain.comlässt sich prüfen, ob der Eintrag bereits aktiv ist.
12. Lokales Deployment einrichten
Remote zum lokalen Git-Repository hinzufügen
# Lokal im Projektverzeichnis ausführen
git remote add vps deploy@YOUR_SERVER_IP:/var/www/yourdomain.com.git
# Prüfen
git remote -v
Tipp: Wenn die SSH Config aus Schritt 2 eingerichtet ist, kann der Kurzname direkt verwendet werden:
git remote add vps meinvps:/var/www/yourdomain.com.git
13. Deployment-Workflow
Nach der einmaligen Einrichtung ist das Deployment denkbar einfach:
# Änderungen committen
git add .
git commit -m "Update: ..."
# Auf den VPS deployen
git push vps main
Der Post-receive Hook wird automatisch ausgeführt und die Seite ist nach wenigen Sekunden live.
Logs beobachten
# Caddy Logs
sudo journalctl -u caddy -f
# Systemd allgemein
sudo journalctl -f
Referenz: Nützliche Befehle
| Befehl | Beschreibung |
|---|---|
sudo systemctl status caddy | Caddy-Status prüfen |
sudo systemctl reload caddy | Caddy-Konfiguration neu laden |
sudo systemctl restart caddy | Caddy neu starten |
sudo systemctl status yourdomain.com | Node.js-Service prüfen |
sudo systemctl restart yourdomain.com | Node.js-Service neu starten |
sudo journalctl -u yourdomain.com -f | Node.js-Service Logs live |
sudo ufw status verbose | Firewall-Regeln anzeigen |
sudo fail2ban-client status sshd | Gesperrte IPs anzeigen |
git push vps main | Deployment auslösen |
sudo journalctl -u caddy -f | Caddy Logs live verfolgen |
Verzeichnisstruktur (Übersicht)
/home/deploy/
├── repos/
│ └── yourdomain.com.git/ # Bare Git Repository
│ └── hooks/
│ └── post-receive # Deployment Hook
└── tmp/
└── yourdomain.com-build/ # Temporäres Build-Verzeichnis
/var/www/
└── yourdomain.com/ # Webroot (Astro Build-Output)
├── dist/
│ ├── client/ # Statische Assets
│ └── server/
│ └── entry.mjs # Node.js Einstiegspunkt
└── .env # Umgebungsvariablen