← Übersicht  ·  Skripte & Dateien  ·  cloud-host · privater Host

Geschützter Projekt-Host

Ein kleiner Hetzner-Server, auf den du deine Projekte per rsync hochlädst und die dann unter Subdomains von seb4u.com erreichbar sind – aber hinter einem Login (Cloudflare Access) und über einen Cloudflare Tunnel, also ohne einen einzigen offenen Port am Server. Keine Server-IP im DNS (Domain Name System), kein Port-Scan-Ziel, kein Direktzugriff vorbei am Login.

Stand: 30. August 2026 Cloudflare Tunnel + Access (Free) ab ~4 €/Mon

Platzhalter einsetzen – nur im Browser

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.

Laptopdeploy.ps1 / rsync → Server (per SSH, am besten übers Tailnet)
BesucherBrowser → projekt.seb4u.com
CloudflareAccess: Login per E-Mail-Code / Google – wer nicht in der Policy steht, kommt nicht weiter
TunnelCloudflare → cloudflared auf dem Server (Verbindung ging vom Server raus)
ServerDocker-Netz 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.

Laptop · PowerShell
# 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
Was die Schalter bedeuten

Nur eine einzige Firewall-Regel – und das ist der ganze Witz dieser Anleitung:

Schaltergehört zubewirkt
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.
Datei · cloud-init.host.yaml
#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";
Was die Zeilen bedeuten

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.

ZeileZweckbewirkt
#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.
Im Repo

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.

Größe

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.

  1. Zero Trust-Dashboard: one.dash.cloudflare.com → beim ersten Mal ein Team-Namen wählen (frei, z. B. dein Nachname).
  2. Networks Tunnels Create a tunnel Cloudflared → Name projekthostSave.
  3. Cloudflare zeigt einen Token (langer String). Den brauchst du gleich – nicht die vorgeschlagene Install-Zeile, nur den Token-Wert aus --token XXXX.
Datei · /opt/stacks/cloudflared/.env (chmod 600)
TUNNEL_TOKEN=eyJhIjoi…   # der Token aus dem Dashboard
Datei · /opt/stacks/cloudflared/compose.yaml
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
Server · als deploy
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):

SubdomainDomainService
blogseb4u.comhttp://web-blog:80
apiseb4u.comhttp://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.

  1. Access Applications Add an application Self-hosted
  2. Application domain: Subdomain * + Domain seb4u.com – gilt dann für alle Subdomains. (Oder pro Projekt eine eigene App mit fester Subdomain.)
  3. Session Duration: z. B. 24 h oder 1 Woche.
  4. Policy hinzufügen: Action = Allow, Include = Emailsdu@example.com (mehrere möglich, oder Emails ending in @deine-firma.de).
  5. Login methods: One-time PIN (Code per E-Mail) ist ohne Einrichtung da. Optional Google/GitHub als Identity Provider ergänzen (Settings Authentication).
  6. Save. Ab jetzt: Aufruf einer geschützten Subdomain → Cloudflare-Login → Code eingeben → durch.
Was das abdeckt

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.)

Wichtig

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.

Datei · /opt/stacks/site-blog/compose.yaml
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 }
Server
mkdir -p /opt/sites/blog
cd /opt/stacks/site-blog && docker compose up -d
Laptop · Build hochladen
npm run build         # erzeugt ./dist
rsync -az --delete ./dist/ deploy@projekthost:/opt/sites/blog/

Dashboard → Tunnel → Public Hostnames → Add: blog . seb4u.comhttp://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:.

Datei · /opt/stacks/api/compose.yaml
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 }
Laptop · Code + Build hochladen
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.comhttp://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.

Laptop · PowerShell
.\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.comssh://localhost:22, eine Access-Policy dazu, und lokal cloudflared access ssh --hostname ssh.seb4u.com als ProxyCommand. Dann ist auch Port 22 in der Firewall entbehrlich.
  • fail2ban läuft aus der cloud-init (falls 22 doch offen bleibt).
Ziel

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, nicht latest). unattended-upgrades läuft, Kernel-Reboots planen.
  • Access-Logs: 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

Server · nachts per cron
# 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)00
Cloudflare DNS (Free)00
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: Access Service Auth Service Tokens → 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 cloudflared direkt auf die Apps, cloudflaredTraefik, und in Traefik eine basicauth-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.
⌂ Cockpit