Adrian Altner

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

  1. VPS Grundeinrichtung
  2. SSH-Zugang absichern
  3. Firewall einrichten (UFW)
  4. Automatische Updates & Sicherheit
  5. Node.js installieren
  6. Caddy installieren
  7. Git Bare Repository einrichten
  8. Post-receive Hook konfigurieren
  9. Astro Hybrid-Modus & Node.js-Adapter
  10. systemd Service einrichten
  11. Caddy konfigurieren
  12. Lokales Deployment einrichten
  13. 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-Keys der Public Key direkt hinterlegt werden. Er wird dann automatisch für den root-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/config lässt sich der Server bequem per Kurzname ansprechen:

Host meinvps
    HostName YOUR_SERVER_IP
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Danach 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 FirewallUFW
EbeneNetzwerk (vor dem Server)Host (auf dem Server)
Schützt gegenExterne AngriffeAuch interne Angriffe im Rechenzentrum
AnbieterabhängigJaNein
CPU-OverheadKeinerMinimal

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:

ProtokollPortQuelleBeschreibung
TCP220.0.0.0/0, ::/0SSH
TCP800.0.0.0/0, ::/0HTTP
TCP4430.0.0.0/0, ::/0HTTPS

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 IP in 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
OptionBedeutung
maxretryAnzahl fehlgeschlagener Versuche bevor eine IP gesperrt wird
findtimeZeitfenster in dem maxretry erreicht werden muss
bantimeWie 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-lockfile installiert exakt die Versionen aus der pnpm-lock.yaml — deterministisch und ohne ungewollte Updates. Entspricht npm ci bei npm-Projekten.


9. Astro Hybrid-Modus & Node.js-Adapter

Wie Astro’s Output-Modi funktionieren

Astro unterstützt drei Output-Modi:

Modusoutput in astro.config.mjsVerhalten
Static'static' (Standard)Alle Seiten werden zur Build-Zeit gerendert
Hybridnicht gesetzt + Node-AdapterStandard 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
OptionBedeutung
UserProzess läuft nicht als root
EnvironmentFileLädt Umgebungsvariablen aus .env
Restart=alwaysNeustart bei Absturz oder manuellem Stop
RestartSec=5Wartet 5 Sekunden vor Neustart
HOST=127.0.0.1Node lauscht nur lokal — Caddy ist der einzige Zugang
PORT=4321Port 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 von sudo stillschweigend ignoriert, wenn der Dateiname einen Punkt enthält. /etc/sudoers.d/adrian-altner.de wü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
}

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:

TypNameWert
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.com lä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

BefehlBeschreibung
sudo systemctl status caddyCaddy-Status prüfen
sudo systemctl reload caddyCaddy-Konfiguration neu laden
sudo systemctl restart caddyCaddy neu starten
sudo systemctl status yourdomain.comNode.js-Service prüfen
sudo systemctl restart yourdomain.comNode.js-Service neu starten
sudo journalctl -u yourdomain.com -fNode.js-Service Logs live
sudo ufw status verboseFirewall-Regeln anzeigen
sudo fail2ban-client status sshdGesperrte IPs anzeigen
git push vps mainDeployment auslösen
sudo journalctl -u caddy -fCaddy 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