Skip to content

marton-harangi/Hetzner-Zero-Trust-VPS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hetzner Zero Trust VPS

Hardened Dokploy deployment on Hetzner — zero public ports, SSH via Tailscale, all traffic via Cloudflare Tunnel.

Platform Arch Shell License

A set of opinionated bash scripts that turn a fresh Hetzner VPS into a production-ready, zero-trust deployment platform. No open ports. No exposed SSH. No plain-HTTP dashboards. Everything is locked down from the start.

Internet ──► Cloudflare ──► Tunnel ──► Traefik ──► Your apps (domain configured)
                                               └──► dokploy.yourdomain.com (Zero Trust)

    You ──► Tailscale (100.x.x.x) ──► SSH only
                                  └──► Your apps (no domain set — Tailscale-only)

Apps without a domain set in Dokploy are not publicly reachable. Traefik has no routing rule for them, and all public TCP is blocked at the Hetzner Cloud Firewall. They are only accessible via http://<tailscale-ip>:<port> from your Tailscale network — useful for internal tools.


What It Does

Layer How
SSH access Tailscale only — public SSH port never opened
App traffic Cloudflare Tunnel → no inbound firewall rules needed
Dashboard Cloudflare Zero Trust Access — email-gated login
Webhooks /api/deploy bypass rule — GitHub auto-deploy still works
Kernel Hardened sysctl, restricted shared memory
Docker Swarm mode, Hetzner Cloud Firewall blocks all public TCP

Prerequisites

  • Hetzner VPS with Ubuntu 24.04 (fresh install, SSH key configured) — x86_64 or ARM64
  • Tailscale account and client on your local machine
  • Cloudflare account with your domain's DNS managed by Cloudflare
  • GitHub account (for repo deployments and auto-deploy webhooks)

Setup

SSH in as root and clone the repo:

ssh root@<YOUR_VPS_IP>
git clone https://github.com/marton-harangi/Hetzner-Zero-Trust-VPS.git ~/setup
chmod +x ~/setup/*.sh

All scripts require root privileges. Script 1 runs directly as root. Scripts 2–5 are run as your deploy user using sudo, which grants the same root privileges for the duration of the script.


1. Base system

Logged in as root

bash ~/setup/01_base.sh

Prompts for username, hostname, timezone, and swap size. Updates the system, applies kernel hardening, creates swap, and creates a non-root sudo user with your SSH keys. Moves the setup scripts to /home/<user>/setup/.

After: open a new terminal and verify SSH works as the new user before continuing:

ssh <user>@<YOUR_VPS_IP>
sudo apt update   # should succeed without a password prompt

Then switch to that user for all remaining scripts — root login will be disabled by Script 2.


2. Network lockdown

Logged in as deploy user (SSH into the new user first — don't use root from here on)

sudo bash ~/setup/02_network.sh

Installs Tailscale, authenticates, hardens SSH (Tailscale-only, no root, no passwords), and sets up UFW. Pauses to verify Tailscale SSH works before locking down.

After: set up the Hetzner Cloud Firewall:

  1. console.hetzner.cloud → Firewalls → Create
  2. Name: zero-public-ports
  3. Inbound rules — only these:
Protocol Port Source Purpose
UDP 41641 Any Tailscale WireGuard
ICMP Any Ping
  1. Apply to your server

Note: Docker Swarm bypasses UFW via iptables. The Hetzner firewall operates at the hypervisor level — Docker cannot bypass it.


3. Install Dokploy

Logged in as deploy user

sudo bash ~/setup/03_dokploy.sh

Installs Docker, Docker Swarm, and Dokploy. Also re-applies your server timezone to all Dokploy services — Docker containers do not inherit the host timezone automatically.

Dashboard is accessible at http://<TAILSCALE_IP>:3000 for initial setup. After script 4 it will move to https://dokploy.yourdomain.com.

After: open the dashboard, create your admin account, and skip Let's Encrypt setup.

Note: Ports bind to 0.0.0.0 (Docker Swarm limitation). The Hetzner Cloud Firewall blocks all public TCP, so only Tailscale peers can reach them.


4. Cloudflare Tunnel

Logged in as deploy user

sudo bash ~/setup/04_cloudflared.sh

The script walks through this interactively. Full detail for each step below.

4a — Create tunnel and add wildcard route

The Cloudflare tunnel wizard shows the token page before the public hostnames page. Complete the full wizard in one go — add the route before saving and coming back to the script.

  1. one.dash.cloudflare.com → Networks → Connectors → Create a tunnel
  2. Type: Cloudflared → name it (e.g. dokploy-prod) → Save
  3. The wizard shows the tunnel token if you select Docker image (eyJ...) — copy and save it for step 4d
  4. Click Next to reach the Public Hostnames page
  5. Add the wildcard route:
Subdomain Domain Service
* yourdomain.com HTTP → dokploy-traefik:80
  1. Save the tunnel — this route must exist before configuring Zero Trust Access Applications

Tunnel ID: After saving, click the connector to find the Tunnel ID (UUID). You need this for the wildcard DNS record.

4b — Wildcard DNS record

In Cloudflare DNS for your domain, add:

Type Name Target Proxy status
CNAME * <tunnel-id>.cfargotunnel.com Proxied

Replace <tunnel-id> with the Tunnel ID from step 4a. This routes every subdomain through the tunnel automatically — new apps only need their domain set in Dokploy, no further DNS changes needed.

4c — SSL mode

Cloudflare dashboard → your domain → SSL/TLS → set to Full (not Full Strict — Traefik has no certificate; the tunnel itself handles encryption).

4d — Deploy cloudflared in Dokploy

  1. Dokploy → Project → + Create Service → Compose
  2. Select Provider → Raw Input
  3. Paste the config below, replacing YOUR_TUNNEL_TOKEN with your actual token:
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token YOUR_TUNNEL_TOKEN
    networks:
      - dokploy-network

networks:
  dokploy-network:
    external: true
  1. Save → Deploy

The script also generates and displays this compose config with your token already filled in.

4e — Set Dokploy server domain

Dokploy → Settings → WebServer → Server Domaindokploy.yourdomain.com

Enter the bare domain — no https:// prefix. Dokploy uses this to construct webhook URLs for GitHub auto-deploy.

4f — Zero Trust: secure the dashboard

dokploy.yourdomain.com is publicly reachable because GitHub needs /api/deploy for webhook auto-deploy. Lock down everything else with two Access Applications.

Application 1 — Protect the dashboard

Zero Trust → Access → Applications → Add → Self-hosted:

  • Application domain: dokploy.yourdomain.com
  • Policy name: Owner only
  • Action: Allow
  • Include rule: Emailsyour@example.com

Application 2 — Bypass for webhooks

Zero Trust → Access → Applications → Add → Self-hosted:

  • Application domain: dokploy.yourdomain.com/api/deploy
  • Policy name: Webhook bypass
  • Action: Bypass
  • Include rule: Everyone

Cloudflare evaluates more specific paths first, so /api/deploy bypasses authentication while the rest of the dashboard requires email login.


5. Verify

Logged in as deploy user

sudo bash ~/setup/05_verify.sh

Checks everything: Tailscale, SSH, UFW, Swarm, Dokploy, cloudflared, kernel hardening, and swap. Offers to delete setup scripts when done.


Day-to-Day

Dashboard: https://dokploy.yourdomain.com — protected by Cloudflare Zero Trust (email login required)

SSH: ssh <user>@<tailscale-ip> — Tailscale only

Deploy a new app:

  1. Dokploy → create app, add domain app.yourdomain.com — enter the bare domain, no https://, HTTP only, set container port
  2. With the wildcard tunnel hostname already in place, that's it — no DNS changes needed
  3. Set timezone: app → Environment tab → add TZ=Europe/Budapest (containers don't inherit the host timezone)
  4. Live at https://app.yourdomain.com

Next.js: For new versions, set environment variable NIXPACKS_NODE_VERSION=20 before deploying, otherwise the build will fail.

Auto-deploy on push: connect GitHub in Dokploy (Settings → Git Providers → GitHub), create app from repo, toggle Auto Deploy on. GitHub webhooks reach Dokploy through dokploy.yourdomain.com via the tunnel (bypassing Zero Trust on /api/deploy). Push to your branch → auto rebuild.

Update Dokploy: curl -sSL https://dokploy.com/install.sh | sh -s update


File Reference

Script Run as What it does
01_base.sh root System hardening, swap, user creation
02_network.sh deploy user Tailscale, SSH lockdown, UFW
03_dokploy.sh deploy user Docker, Swarm, Dokploy, timezone
04_cloudflared.sh deploy user Cloudflare Tunnel setup
05_verify.sh deploy user Security audit + cleanup
Path Purpose
/etc/dokploy/ Dokploy config
/etc/dokploy/traefik/traefik.yml Traefik config
/etc/ssh/sshd_config.d/99-hardening.conf SSH hardening
/etc/sysctl.d/99-security.conf Kernel hardening

Design Notes

Why no fail2ban? SSH is Tailscale-only. Port scanners can't reach it.

Why 0.0.0.0 for Dokploy? Docker Swarm can't bind to specific IPs. Hetzner Cloud Firewall blocks all inbound TCP at the hypervisor — Docker can't bypass it.

Why no Docker SSH exceptions? Dokploy uses the Docker socket directly. It doesn't SSH into the host like Coolify does.

Why dokploy-traefik:80 instead of localhost:80? cloudflared and Traefik share Docker's dokploy-network. They communicate via Docker DNS — cleaner than host networking.

Why SSL Full, not Full Strict? cloudflared connects to Traefik over plain HTTP inside Docker. No cert on the origin. The tunnel itself is encrypted end-to-end.


Troubleshooting

Locked out? Hetzner VNC console → log in as your user → sudo tailscale status

Dashboard not loading? docker service ls — check Dokploy and cloudflared are running. If cloudflared is down, the tunnel is broken and dokploy.yourdomain.com won't respond.

502 through Cloudflare? Port mismatch in Dokploy domain config, or app listening on 127.0.0.1 instead of 0.0.0.0.

Tunnel unhealthy? Check cloudflared logs in Dokploy. Verify it's on dokploy-network.

Wrong timezone in containers? Containers don't inherit the host timezone. Set TZ=Europe/Budapest (or your timezone) in each app's Environment tab in Dokploy.


Example: Deploying a Next.js App

  1. Connect GitHub: Dokploy → Settings → Git → Git Providers → GitHub
  2. Create app: Project → + Create Service → Application → GitHub → pick repo → pick branch
  3. Environment settings: add NIXPACKS_NODE_VERSION=20 — required for new builds to succeed, and TZ=Europe/Budapest — containers don't inherit the host timezone
  4. Configure domain: Domains tab → app.yourdomain.com, port 3000, no https://
  5. Deploy — visit https://app.yourdomain.com. Future pushes rebuild automatically if Auto-Deploy is on.

Disclaimer

This project is provided "as is", without warranty of any kind, express or implied.

The author(s) of this repository take no responsibility for any damage, data loss, security breaches, service outages, costs, or any other issues arising from the use, misuse, or inability to use these scripts. This includes but is not limited to: misconfigured firewalls, locked-out servers, exposed services, or data corruption.

By using these scripts, you acknowledge that:

  • You have reviewed and understood the code before running it on any system
  • You are solely responsible for validating that the configuration meets your security requirements
  • Running scripts as root on a live server carries inherent risk
  • Third-party services (Hetzner, Tailscale, Cloudflare, Dokploy) have their own terms, pricing, and availability — the author is not affiliated with any of them

Use at your own risk. Always test in a non-production environment first.


License

GNU Affero General Public License v3.0 (AGPL-3.0)

You are free to use, modify, and distribute this project, but any modified version — including one run as a service — must also be released under AGPL-3.0 with its source code made publicly available.

About

Hardened Dokploy on Hetzner - zero public ports, SSH via Tailscale, all traffic via Cloudflare Tunnel.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages