Wie das aussieht
Der Server macht keine Verbindung nach außen auf. cloudflared
wählt sich bei Cloudflare ein. Besucher kommen nur über Cloudflare rein – und
erst nach dem Login.
deploy.ps1 / rsync → Server (per SSH, am besten übers Tailnet)projekt.seb4u.comcloudflared auf dem Server (Verbindung ging vom Server raus)web: nginx (statische Seiten) + je App ein Container. Firewall: nur SSH, sonst alles zu.- Keine Server-IP im DNS – der Record ist ein CNAME (Canonical Name) auf den Tunnel. Damit ist auch das „IP (Internet Protocol) ändert sich beim Neuaufbau“-Problem weg.
- Kein offener Port 80/443 – nichts zum Scannen, kein Weg am Login vorbei.
- Cloudflare Access ist gratis bis 50 Nutzer. TLS (Transport Layer Security) macht Cloudflare am Rand.
- Voraussetzung: seb4u.com liegt schon als Full-Zone bei Cloudflare (Anleitung Domain zu Cloudflare umziehen).
1 Kleiner Server, Ports zu
Für ein paar Projekt-Demos reicht wenig. Realistisch planst du mit
cpx. Ein cx/cax (billiger) nur, wenn gerade frei. Konto,
hcloud CLI (Command-Line Interface) und SSH-Key setzt die
Server-Basis
voraus – hier ist die Firewall besonders: nur SSH, weil alles über den Tunnel läuft.
# Firewall: NUR SSH rein. 80/443 braucht der Tunnel nicht.
hcloud firewall create --name host-fw
hcloud firewall add-rule host-fw --direction in --protocol tcp --port 22 --source-ips 0.0.0.0/0
hcloud server create --name projekthost --type cax11 --image debian-12 `
--ssh-key laptop --firewall host-fw --user-data-from-file cloud-init.host.yaml
Nur eine einzige Firewall-Regel – und das ist der ganze Witz dieser Anleitung:
| Schalter | gehört zu | bewirkt |
|---|---|---|
hcloud firewall create --name host-fw |
zuerst | Eine Hetzner-Firewall ist ein eigenständiges Objekt, kein Bestandteil des Servers: erst anlegen, dann Regeln hineinschreiben, dann an einen oder mehrere Server hängen. Sie wirkt im Netz vor dem Server – anders als ufw, das auf dem Server selbst läuft. Frisch angelegt enthält sie keine Regel und lässt damit nichts herein. |
nur --port 22, kein 80/443 |
add-rule | Der Kern des Aufbaus: die Dienste sind öffentlich erreichbar, ohne dass ein einziger Web-Port offen steht. Der Tunnel baut die Verbindung von innen nach außen auf – ein Portscan von außen findet nichts außer SSH. Deshalb gibt es hier auch keinen Grund, sich um TLS-Zertifikate auf dem Server zu kümmern. |
--direction in--source-ips 0.0.0.0/0 |
add-rule | Hetzner-Firewalls filtern nur eingehend; ausgehend bleibt offen – und genau darauf ist der Tunnel angewiesen. 0.0.0.0/0 heißt „von jeder IPv4-Adresse“; wer SSH weiter einschränken will, setzt hier die eigene Adresse mit /32 ein. IPv6 fehlt bewusst: es ist die einzige Regel, und ein zusätzlicher Weg hinein wäre hier kein Gewinn. |
--type cax11 |
server create | ARM statt x86 (cx/cpx sind Intel bzw. AMD). Deutlich günstiger und für diesen Zweck völlig ausreichend – aber Container-Images müssen für arm64 vorliegen. Die großen offiziellen Images tun das; bei kleineren Projekten lohnt ein Blick, sonst scheitert der Start mit exec format error. |
--firewall host-fw |
server create | Hängt die eben angelegte Firewall schon bei der Erstellung an. Deshalb steht sie oben: nachträglich angehängt stünde der Server erst ein paar Minuten ungeschützt im Netz. |
--user-data-from-file cloud-init.host.yaml |
server create | Übergibt die cloud-init-Datei. Sie läuft einmal beim allerersten Start; ein späterer Neustart führt sie nicht erneut aus. Ohne den Schalter entsteht ein leeres Debian ohne Docker und ohne Tunnel. |
--ssh-key laptop |
server create | Der Name des bei Hetzner hinterlegten öffentlichen Schlüssels – nicht ein Pfad auf der eigenen Platte. Ohne ihn schickt Hetzner ein Root-Passwort per Mail, und die Anmeldung per Schlüssel entfällt. |
` am Zeilenende |
PowerShell | Zeilenfortsetzung – das Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in zwei unvollständige Teile. |
#cloud-config
package_update: true
packages: [ ca-certificates, curl, fail2ban, unattended-upgrades, rsync ]
runcmd:
- curl -fsSL https://get.docker.com | sh
- useradd -m -s /bin/bash -G docker deploy
- install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
- cp /root/.ssh/authorized_keys /home/deploy/.ssh/ && chown deploy:deploy /home/deploy/.ssh/authorized_keys
- sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/;s/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
- systemctl restart ssh
- |
printf '%s\n' '{ "log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"} }' > /etc/docker/daemon.json
- systemctl restart docker
- docker network create web
- install -d -o deploy -g deploy /opt/sites /opt/stacks
write_files:
- path: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
cloud-init läuft einmal, beim allerersten Start, als root.
runcmd wird der Reihe nach abgearbeitet; ein Fehler darin bricht den Rest
still ab – nachsehen lässt sich das nur in
/var/log/cloud-init-output.log.
| Zeile | Zweck | bewirkt |
|---|---|---|
#cloud-config |
Zeile 1 | Kein Kommentar, sondern die Kennung, an der cloud-init die Datei erkennt. Fehlt sie oder steht etwas davor, wird die ganze Datei wortlos ignoriert – und man sucht den Fehler bei Hetzner. |
packages: [ …, rsync ] |
Pakete | rsync steht hier, weil der Deploy-Weg dieser Anleitung darauf aufbaut – es muss auf beiden Seiten vorhanden sein, sonst bricht der Upload mit einer Meldung ab, die nach einem SSH-Problem aussieht. fail2ban sperrt Adressen nach fehlgeschlagenen Anmeldungen, unattended-upgrades installiert Sicherheitsupdates von selbst. |
curl -fsSL … | sh |
Docker | Vier Schalter, die zusammengehören: -f lässt curl bei HTTP-Fehlern abbrechen, statt die Fehlerseite in die Shell zu leiten (der wichtigste), -s schaltet die Fortschrittsanzeige ab, -S holt echte Fehlermeldungen trotz -s zurück, -L folgt Weiterleitungen. |
useradd -m -s /bin/bash -G docker deploy |
Benutzer | -m legt das Heimatverzeichnis an, -s setzt die Anmelde-Shell, -G docker nimmt ihn in die docker-Gruppe auf. Letzteres ist gleichbedeutend mit Root-Rechten – wer Container starten darf, kann das Wirtsystem einhängen. Der Gewinn ist trotzdem echt: die tägliche Arbeit läuft nicht als root, und SSH als root ist gleich darunter abgeschaltet. |
install -d -m 700 -o deploy -g deploy … |
SSH | install statt mkdir, weil es Rechte und Eigentümer in einem Aufruf mitsetzt. Bei .ssh ist 700 Pflicht: SSH verweigert den Schlüssel, wenn andere hineinsehen dürfen. Dieselbe Zeile taucht unten noch einmal für /opt/sites und /opt/stacks auf – dort ohne -m, weil die Vorgaberechte genügen. |
cp /root/.ssh/authorized_keys … |
SSH | Übernimmt den bei Hetzner hinterlegten Schlüssel für den neuen Benutzer. Die Reihenfolge ist entscheidend: erst kopieren, dann in der nächsten Zeile die Root-Anmeldung abschalten. Umgekehrt sperrt man sich aus – und der einzige Weg zurück ist die Hetzner-Konsole im Browser. |
sed -i 's/^#*PermitRootLogin.*/…/;s/^#*PasswordAuthentication.*/…/' |
SSH | Zwei Ersetzungen in einem Aufruf, getrennt durch das Semikolon. -i schreibt direkt in die Datei. Das ^#* ist der Kniff: es trifft die Zeile auch dann, wenn sie auskommentiert ist – und genau so steht sie in der Vorgabekonfiguration. Ohne das #* fände sed nichts, meldete keinen Fehler, und der Server bliebe für Root-Anmeldung mit Passwort offen. |
printf '%s\n' '{ … }' > /etc/docker/daemon.json |
Log-Grenze | printf statt echo, weil echo je nach Shell mit Anführungszeichen und Backslashes unterschiedlich umgeht. Die einfachen Anführungszeichen um das JSON sind Pflicht, damit die doppelten darin unberührt bleiben. |
"max-size":"10m","max-file":"3" |
Log-Grenze | Ohne diese Begrenzung wachsen Container-Logs unbegrenzt, bis die Platte voll ist – die häufigste Ursache für einen Server, der nach Monaten „einfach so“ stehenbleibt. Die Einstellung gilt erst für neu erstellte Container, deshalb der Neustart des Dienstes gleich danach. |
docker network create web |
Netz | Das gemeinsame Netz, in das sich alle Stacks hängen – so erreicht der Tunnel-Container die Dienste, ohne dass sie Ports auf dem Wirt veröffentlichen. Genau darauf beruht dieser Aufbau: kein offener Web-Port, und trotzdem erreichbar. Es muss existieren, bevor der erste Stack startet. |
write_files: |
Auto-Updates | Schreibt Dateien, bevor runcmd läuft – unabhängig von der Reihenfolge in der Datei. Die zwei Zeilen schalten die unbeaufsichtigten Sicherheitsupdates ein; das Paket allein tut ohne sie nichts. |
Genau diese Datei liegt als scripts/cloud-init.host.yaml bei – nicht
aus dem Browser abtippen. Beim hcloud server create dann
--user-data-from-file scripts\cloud-init.host.yaml (bzw. den Pfad, unter dem du
das Repo ausgecheckt hast). Nach dem ersten Boot kommt der Server aus dem Snapshot, die
Datei wird nicht mehr gebraucht.
CAX11 (2 vCPU / 4 GB, ~3,8 €/Mon) reicht für
statische Seiten und ein paar kleine Dienste. Ein Java-/Node-Backend dazu →
CAX21 / CPX32. Später
hcloud server change-type… --keep-disk.
2 Cloudflare Tunnel
cloudflared läuft als Container und hält eine ausgehende
Verbindung zu Cloudflare. Die Hostnamen konfigurierst du bequem im Dashboard.
- Zero Trust-Dashboard: one.dash.cloudflare.com → beim ersten Mal ein Team-Namen wählen (frei, z. B. dein Nachname).
-
→ Name
projekthost→ Save. - Cloudflare zeigt einen Token (langer String). Den brauchst du gleich –
nicht die vorgeschlagene Install-Zeile, nur den Token-Wert aus
--token XXXX.
TUNNEL_TOKEN=eyJhIjoi… # der Token aus dem Dashboard
services:
cloudflared:
image: cloudflare/cloudflared:2025.8.0
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
networks: [ web ]
networks:
web:
name: web
external: true
cd /opt/stacks/cloudflared && docker compose up -d
docker compose logs -f cloudflared # "Registered tunnel connection" = ok
Zurück im Dashboard, Reiter Public Hostnames des Tunnels – pro Projekt eine Zeile (Beispiel folgt in Schritt 5/6):
| Subdomain | Domain | Service |
|---|---|---|
blog | seb4u.com | http://web-blog:80 |
api | seb4u.com | http://api:8080 |
Der Service-Name ist der Container-Name im web-Netz. Cloudflare
legt den DNS-CNAME automatisch an. TLS: erledigt Cloudflare, der Server spricht intern
einfach HTTP (Hypertext Transfer Protocol).
3 Zugriffsschutz: Cloudflare Access
Eine Application pro Hostname (oder ein Wildcard), eine Policy, wer rein darf. Login läuft komplett bei Cloudflare – auf dem Server liegt kein Passwort.
- Application domain: Subdomain
*+ Domain seb4u.com – gilt dann für alle Subdomains. (Oder pro Projekt eine eigene App mit fester Subdomain.) - Session Duration: z. B. 24 h oder 1 Woche.
- Policy hinzufügen: Action = Allow, Include =
Emails →
du@example.com(mehrere möglich, oder Emails ending in@deine-firma.de). - Login methods: One-time PIN (Code per E-Mail) ist ohne Einrichtung da. Optional Google/GitHub als Identity Provider ergänzen ().
- Save. Ab jetzt: Aufruf einer geschützten Subdomain → Cloudflare-Login → Code eingeben → durch.
Jeder Request wird am Cloudflare-Rand geprüft, bevor er den Tunnel erreicht. Kein gültiges Access-Cookie → der Server sieht die Anfrage nie. Die einzelnen Apps brauchen selbst keine Auth mehr – für ein Demo reicht das. (Sensible Daten trotzdem zusätzlich in der App absichern.)
Access schützt nur, was über den Tunnel läuft. Deshalb keine Ports am Server öffnen und keinen A-Record auf die Server-IP setzen – beides wäre ein Weg an Access vorbei.
4 Projekt: statische Seite
Angular-/Vite-/Hugo-Build, Doku, Landingpage – ein nginx-Container, der einen Ordner ausliefert.
services:
web-blog:
image: nginx:1.27-alpine
restart: unless-stopped
volumes:
- /opt/sites/blog:/usr/share/nginx/html:ro
networks: [ web ]
networks:
web: { name: web, external: true }
mkdir -p /opt/sites/blog
cd /opt/stacks/site-blog && docker compose up -d
npm run build # erzeugt ./dist
rsync -az --delete ./dist/ deploy@projekthost:/opt/sites/blog/
Dashboard → Tunnel → Public Hostnames → Add:
blog . seb4u.com →
http://web-blog:80. Fertig – https://blog.seb4u.com
fragt nach dem Login und zeigt dann die Seite.
5 Projekt: App (Compose)
Ein Dienst mit eigenem Prozess (Node, Spring, Python…) – ganz
normales Compose-Stack, nur ans web-Netz gehängt, ohne ports:.
services:
api:
build: . # oder image: ghcr.io/<user>/api:latest
restart: unless-stopped
environment:
NODE_ENV: production
networks: [ web, internal ]
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db
volumes: [ db:/var/lib/postgresql/data ]
networks: [ internal ]
secrets: [ db ]
volumes: { db: {} }
networks:
web: { name: web, external: true }
internal: {}
secrets:
db: { file: ./db_password.txt }
rsync -az --delete --exclude node_modules --exclude .git ./ deploy@projekthost:/opt/stacks/api/
ssh deploy@projekthost "cd /opt/stacks/api && docker compose up -d --build"
Public Hostname: api . seb4u.com →
http://api:8080 (Port des Containers). Die DB hängt nur im
internal-Netz – von außen unerreichbar, auch über den Tunnel nicht.
6 deploy.ps1
Ein Projekt hochschieben – statischer Ordner oder Compose-Stack, in einem
Befehl. Liegt im cloud-host-Ordner.
.\scripts\deploy.ps1 blog # ./dist -> /opt/sites/blog (statisch)
.\scripts\deploy.ps1 blog -Source ./build # anderer Quellordner
.\scripts\deploy.ps1 api -Stack # ./ -> /opt/stacks/api + docker compose up -d --build
.\scripts\deploy.ps1 api -Stack -DryRun # nur zeigen, was uebertragen wuerde
.\scripts\deploy.ps1 blog -Logs # docker compose logs -f
Oben in der Datei: $Server (SSH-Ziel), $SitesDir,
$StacksDir. Nutzt rsync wenn vorhanden, sonst tar
über SSH (beides ist unter Windows 10 als ssh.exe/tar.exe
dabei. rsync gibt es per winget install cwRsync oder Git-Bash).
7 SSH absichern
- Passwort-Login und Root-Login sind per cloud-init schon aus, Key-only.
- Besser noch: Tailscale
auf Server und Laptop, dann in der Cloud-Firewall Port 22 ganz zu – SSH
nur noch übers Tailnet (
ssh deploy@projekthostüber die Tailscale-IP/den MagicDNS-Namen). - Oder SSH auch durch den Tunnel: im Dashboard einen Public Hostname
ssh.seb4u.com→ssh://localhost:22, eine Access-Policy dazu, und lokalcloudflared access ssh --hostname ssh.seb4u.comalsProxyCommand. Dann ist auch Port 22 in der Firewall entbehrlich. fail2banläuft aus der cloud-init (falls 22 doch offen bleibt).
Am Ende ist in der Hetzner-Firewall gar nichts offen. Der Server ist von außen unsichtbar. Alles läuft über ausgehende Verbindungen (Tunnel, Tailscale).
8 Hardening, Backup, Kosten
Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.
Hardening
- Image-Tags pinnen (
nginx:1.27-alpine, nichtlatest).unattended-upgradesläuft, Kernel-Reboots planen. - Access-Logs: – wer sich wann eingeloggt hat.
- Nichts Vertrauliches allein auf Access verlassen: eine App, die echte Nutzerdaten hält, braucht trotzdem eigene Anmeldung.
Backup
# statische Sites: einfach der Ordner. App-DBs: Dump.
tar czf /opt/backups/sites-$(date +%F).tgz -C /opt/sites .
docker exec api-db-1 pg_dump -U postgres app | gzip > /opt/backups/api-$(date +%F).sql.gz
rsync -a /opt/backups/ user@u123.your-storagebox.de:host/
| Posten | ~€/Tag | ~€/Mon |
|---|---|---|
Server CAX11 (4 GB ARM) | ~0,13 | ~3,8 |
| Cloudflare Tunnel + Access (bis 50 Nutzer) | 0 | 0 |
| Cloudflare DNS (Free) | 0 | 0 |
| Hetzner Storage Box BX11 (1 TB, Backups) – optional | ~0,13 | ~3,8 |
| Minimal | ~0,13 | ~3,8 |
+ APIs ohne Browser-Login
Ein Skript / CI (Continuous Integration) / Webhook kann sich nicht durch den Login klicken. In Access:
→ Token erzeugen. In der Policy der App eine zweite Regel:
Include → Service Token → <dein Token>. Der Aufrufer schickt dann
die Header CF-Access-Client-Id und CF-Access-Client-Secret mit –
Browser-Nutzer weiter per Login, Automaten per Token.
+ Ohne Cloudflare Access
Wenn du Access nicht willst, aber trotzdem Schutz:
- Tunnel + Basic Auth: statt
cloudflareddirekt auf die Apps,cloudflared→ Traefik, und in Traefik einebasicauth-Middleware auf alle Router. Ein Passwort für alle, im Browser gespeichert. - Nur Tailnet: kein Tunnel, kein öffentlicher Zugriff – Server + alle Geräte im Tailscale-Netz, Zugriff nur von dort. Am dichtesten, aber nicht „online über die Domain“ für Geräte ohne Tailscale.
- Traefik öffentlich + Firewall auf Cloudflare-IPs + Cloudflare-Proxy: klassisch, aber Port 443 ist offen und die Absicherung hängt an korrekten Firewall-Regeln. Der Tunnel ist der ruhigere Weg.