Hardened Dokploy deployment on Hetzner — zero public ports, SSH via Tailscale, all traffic via Cloudflare Tunnel.
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.
| 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 |
- 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)
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/*.shAll 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.
Logged in as root
bash ~/setup/01_base.shPrompts 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 promptThen switch to that user for all remaining scripts — root login will be disabled by Script 2.
Logged in as deploy user (SSH into the new user first — don't use root from here on)
sudo bash ~/setup/02_network.shInstalls 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:
- console.hetzner.cloud → Firewalls → Create
- Name:
zero-public-ports - Inbound rules — only these:
| Protocol | Port | Source | Purpose |
|---|---|---|---|
| UDP | 41641 | Any | Tailscale WireGuard |
| ICMP | — | Any | Ping |
- Apply to your server
Note: Docker Swarm bypasses UFW via iptables. The Hetzner firewall operates at the hypervisor level — Docker cannot bypass it.
Logged in as deploy user
sudo bash ~/setup/03_dokploy.shInstalls 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.
Logged in as deploy user
sudo bash ~/setup/04_cloudflared.shThe script walks through this interactively. Full detail for each step below.
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.
- one.dash.cloudflare.com → Networks → Connectors → Create a tunnel
- Type: Cloudflared → name it (e.g.
dokploy-prod) → Save - The wizard shows the tunnel token if you select Docker image (
eyJ...) — copy and save it for step 4d - Click Next to reach the Public Hostnames page
- Add the wildcard route:
| Subdomain | Domain | Service |
|---|---|---|
* |
yourdomain.com |
HTTP → dokploy-traefik:80 |
- 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.
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.
Cloudflare dashboard → your domain → SSL/TLS → set to Full (not Full Strict — Traefik has no certificate; the tunnel itself handles encryption).
- Dokploy → Project → + Create Service → Compose
- Select Provider → Raw Input
- Paste the config below, replacing
YOUR_TUNNEL_TOKENwith 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- Save → Deploy
The script also generates and displays this compose config with your token already filled in.
Dokploy → Settings → WebServer → Server Domain → dokploy.yourdomain.com
Enter the bare domain — no
https://prefix. Dokploy uses this to construct webhook URLs for GitHub auto-deploy.
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: Emails →
your@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.
Logged in as deploy user
sudo bash ~/setup/05_verify.shChecks everything: Tailscale, SSH, UFW, Swarm, Dokploy, cloudflared, kernel hardening, and swap. Offers to delete setup scripts when done.
Dashboard: https://dokploy.yourdomain.com — protected by Cloudflare Zero Trust (email login required)
SSH: ssh <user>@<tailscale-ip> — Tailscale only
Deploy a new app:
- Dokploy → create app, add domain
app.yourdomain.com— enter the bare domain, nohttps://, HTTP only, set container port - With the wildcard tunnel hostname already in place, that's it — no DNS changes needed
- Set timezone: app → Environment tab → add
TZ=Europe/Budapest(containers don't inherit the host timezone) - Live at
https://app.yourdomain.com
Next.js: For new versions, set environment variable
NIXPACKS_NODE_VERSION=20before 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
| 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 |
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.
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.
- Connect GitHub: Dokploy → Settings → Git → Git Providers → GitHub
- Create app: Project → + Create Service → Application → GitHub → pick repo → pick branch
- Environment settings: add
NIXPACKS_NODE_VERSION=20— required for new builds to succeed, andTZ=Europe/Budapest— containers don't inherit the host timezone - Configure domain: Domains tab →
app.yourdomain.com, port3000, nohttps:// - Deploy — visit
https://app.yourdomain.com. Future pushes rebuild automatically if Auto-Deploy is on.
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.
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.