How to Self-Host ngrok on a $4 Hetzner Box (Full Ansible Playbook Walkthrough)
Self-host ngrok on a $4/month Hetzner CX22 with rustunnel-server: a complete Ansible-driven walkthrough covering DNS, Cloudflare DNS-01, nginx, systemd, and a feature-parity comparison vs ngrok, cloudflared, and frp.
This is the playbook we use to spin up a new rustunnel edge region. It's the same recipe behind eu.edge.rustunnel.com, us.edge.rustunnel.com, and ap.edge.rustunnel.com — three production tunnels running on $4–$8/month Hetzner Cloud boxes. By the end of this post you'll have your own self-hosted ngrok alternative on a domain you own, with a wildcard TLS cert from Let's Encrypt, systemd-managed restarts, and metrics on :9090.
We'll cover:
- A side-by-side comparison vs ngrok, cloudflared, and frp (so you know what you're trading).
- The Hetzner box, DNS, and Cloudflare prerequisites (~10 minutes).
- The Ansible playbook layout (so you can re-use it for any number of edges).
- The actual one-command provisioning run.
- Day-2: monitoring, certificate renewal, and what breaks.
If you'd rather start from a managed rustunnel — same binary, $0/month at idle — go to the quickstart and skip everything below.
Feature parity: rustunnel (self-hosted) vs ngrok vs cloudflared vs frp
Before we dig into the playbook, here's the honest comparison. Self-hosting trades convenience for sovereignty — and the convenience gap is smaller than people assume.
| Feature | ngrok | cloudflared | frp | rustunnel (self-hosted) |
|---|---|---|---|---|
| HTTP / HTTPS tunnels | ✅ | ✅ | ✅ | ✅ |
| Custom subdomain | Paid | ✅ | ✅ | ✅ |
| Wildcard TLS via Let's Encrypt | n/a (managed) | n/a (Cloudflare) | DIY | ✅ (Cloudflare DNS-01 baked in) |
| TCP tunnels | Paid | Limited | ✅ | ✅ |
| UDP tunnels | ❌ | ❌ | ✅ | ✅ |
| P2P tunnels (no exit node) | ❌ | ❌ | ✅ | ✅ |
| Group load balancing + health checks | ❌ | ❌ | ✅ | ✅ |
| Request capture / inspector | ✅ | ❌ | ❌ | ✅ |
| MCP server (AI agents) | ❌ | ❌ | ❌ | ✅ |
| Pricing at low-volume | $8/mo flat | Free (gated to Cloudflare) | Free (DIY) | $4–$8/mo VPS, no tunnel-count limit |
| Open-source license | ❌ | Source-available | Apache-2.0 | AGPL-3.0 |
| Single-binary install | ✅ | ✅ | ✅ | ✅ |
Bottom line: if you're already happy with ngrok and don't mind the per-tunnel pricing, stay there. If you want frp's feature surface but can't be bothered to operate it (and never wrote a TLS automation script in your life), this post is for you.
For a deeper dive into the rustunnel-vs-frp angle, see rustunnel vs FRP — A Managed-Cloud frp Alternative Built in Rust.
Prerequisites
You need:
- A Hetzner Cloud account (accounts.hetzner.com) — a CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) is €3.79/month. CX32 if you want headroom for ~10k concurrent tunnels.
- A domain you own, with DNS managed by Cloudflare. The Cloudflare DNS API is what makes the wildcard TLS automation work without HTTP-01 dance.
- A Cloudflare API token scoped to
Zone.DNS:Editfor the zone you'll use (Cloudflare → Profile → API Tokens → Create Token → "Edit zone DNS" template). - Local Ansible 2.15+ (
brew install ansibleon macOS,apt install ansibleon Debian). - An SSH key registered in your Hetzner project so the playbook can land on the box.
DNS we'll set up:
tunnel.example.com— A record pointing at the Hetzner box's IPv4*.tunnel.example.com— A record (wildcard) pointing at the same IPv4
Both records can be DNS-only (gray cloud) — the wildcard cert handles TLS termination on the box itself. If you proxy through Cloudflare's orange cloud, the WebSocket upgrade for the tunnel control channel needs a Cloudflare plan that supports it.
Step 1 — Create the box
Hetzner Cloud Console → New Project → New Server. Pick:
- Location: nearest to your developers (FSN1 / NBG1 / Falkenstein for EU; ASH for US east; HEL1 for north EU).
- Image: Ubuntu 24.04.
- Type: CX22 (or CX32).
- SSH key: pick the one whose private key your Ansible host has access to.
- Networking: enable IPv4 (the playbook assumes v4; v6-only adds an evening of yak-shaving).
Note the public IPv4 — we'll use it as tunnel_host_ip in the inventory.
Step 2 — Point DNS at the box
In Cloudflare:
A tunnel.example.com <hetzner-ip> DNS only (gray cloud)
A *.tunnel.example.com <hetzner-ip> DNS only (gray cloud)
Verify with dig +short tunnel.example.com and dig +short anything.tunnel.example.com — both should return the IP within ~30 seconds.
Step 3 — Lay out the Ansible playbook
The full playbook lives in rustunnel-private/ansible/ (sensitive ops repo) — public-safe excerpts are in docs/guides/self-hosting. The structure is:
ansible/
├── inventory/
│ └── tunnels.ini # one host per region
├── group_vars/
│ └── tunnels.yml # shared vars (binary version, ports)
├── host_vars/
│ ├── eu-edge.yml # per-region overrides (FQDN, region tag)
│ ├── us-edge.yml
│ └── ap-edge.yml
├── roles/
│ ├── base/ # ufw, fail2ban, unattended-upgrades
│ ├── rustunnel-server/ # binary, config, systemd unit
│ ├── nginx/ # reverse proxy in front of :8443 dashboard
│ └── tls-certbot/ # Cloudflare DNS-01 wildcard cert
└── site.yml # play invocation
The roles do the boring work:
base—ufw allow 22,80,443,4040,9090, fail2ban, unattended-upgrades, a non-roottunnelduser, swap (Hetzner CX22 has no swap by default).rustunnel-server— pulls the latest release tarball fromgithub.com/joaoh82/rustunnel, installs/usr/local/bin/rustunnel-server, drops/etc/rustunnel/server.toml, and writes a hardened systemd unit (NoNewPrivileges,ProtectSystem=strict,ReadWritePaths=/var/lib/rustunnel).tls-certbot— installs certbot + thecertbot-dns-cloudflareplugin, drops/etc/letsencrypt/cloudflare.iniwith the API token (mode 600), runscertbot certonly --dns-cloudflare -d 'tunnel.example.com' -d '*.tunnel.example.com', sets up acertbot renewsystemd timer.nginx— terminates TLS on:443, reverse-proxies/dashboardto127.0.0.1:8443, and HTTP-redirects everything else to HTTPS.
The rustunnel-server config that ends up on the box:
# /etc/rustunnel/server.toml
[server]
control_addr = "0.0.0.0:4040"
http_addr = "0.0.0.0:80"
https_addr = "0.0.0.0:443"
dashboard_addr = "127.0.0.1:8443" # nginx fronts this
metrics_addr = "127.0.0.1:9090"
[tls]
cert = "/etc/letsencrypt/live/tunnel.example.com/fullchain.pem"
key = "/etc/letsencrypt/live/tunnel.example.com/privkey.pem"
[domains]
base = "tunnel.example.com"
[auth]
token_db = "/var/lib/rustunnel/tokens.db"The systemd unit reloads on SIGHUP so certbot renew can swap the cert without dropping live tunnels.
Step 4 — Run the playbook
Edit inventory/tunnels.ini:
[tunnels]
eu-edge ansible_host=<hetzner-ip> region=eu domain=tunnel.example.comDrop the Cloudflare API token into Ansible Vault:
ansible-vault encrypt_string \
--vault-password-file ~/.ansible-vault-pass \
'cf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--name 'cloudflare_api_token' \
>> group_vars/tunnels.ymlThen:
ansible-playbook \
-i inventory/tunnels.ini \
--vault-password-file ~/.ansible-vault-pass \
site.ymlFirst run takes ~6 minutes on a CX22 — the slow steps are Let's Encrypt issuance (DNS-01 propagation) and the first apt-get update. Subsequent re-runs are under 60 seconds thanks to Ansible's idempotency.
Step 5 — Smoke-test from your laptop
# Install the rustunnel client
brew install joaoh82/rustunnelcli/rustunnel
# Generate a token on the server, copy it locally
ssh root@<hetzner-ip> 'rustunnel-server token create my-laptop'
# Open a tunnel
rustunnel http 3000 \
--server tunnel.example.com:4040 \
--token <generated-token> \
--subdomain test
# In another shell
curl https://test.tunnel.example.comYou should see the response from your local :3000 come through TLS-terminated by the Hetzner box. If that works, you have a self-hosted ngrok.
Day-2 — monitoring and what breaks
Metrics. Prometheus scrape target: http://<hetzner-ip>:9090/metrics (firewalled to your Tailscale / VPN). Key series:
rustunnel_active_tunnels— should match what you expect.rustunnel_http_requests_total— per-tunnel request counter.rustunnel_bytes_transferred_total— useful for billing visibility if you re-sell tunnels.process_resident_memory_bytes— should hover around 30–50 MB. A creeping line means a leak — file an issue with a heap snapshot.
Cert renewal. The certbot systemd timer fires twice daily and renews when the cert has under 30 days remaining before expiry. The post-hook reloads rustunnel-server via systemctl kill -s SIGHUP rustunnel. If you change the domain list, run certbot certonly manually with the new -d flags before the next renewal — the timer doesn't know about new SANs.
What actually breaks. From three production regions, the only recurring issues have been:
- Cloudflare API token rotation. When we rotated tokens for compliance, we forgot to update
/etc/letsencrypt/cloudflare.ini. The renewal failed silently for 25 days. Set up an alert oncertbot renew --dry-runfailing. - Disk fill from
/var/log/rustunnel/. The default is 100 MB rotation, but a chatty webhook tunnel can rotate faster. We set uplogrotatewith daily compression. - Hetzner network maintenance. Occasional 30s blips that drop ALL tunnels. The control channel reconnects automatically; data tunnels need to reconnect too — we ship retry-with-jitter logic in the client by default, but a custom integration that reuses the same connection ID can stall.
Costs at a glance
| Item | Monthly |
|---|---|
| Hetzner CX22 | €3.79 |
| Cloudflare DNS | $0 (free plan) |
Domain (example.com) | ~$1 (yearly amortized) |
| Total | ~$5 |
For comparison, ngrok's lowest tier with a custom subdomain and TCP is $8/month — and you don't own the relay.
What's next
You have a working self-hosted tunnel relay. From here:
- Add a second region. Copy
host_vars/eu-edge.ymltohost_vars/us-edge.yml, point a second Hetzner box at it, re-runsite.yml. The clients pick the closest region via geo-DNS or just a hard-coded--server. - Run the dashboard at
https://tunnel.example.com/dashboard— request capture, replay, and per-tunnel metrics. - Drop a token in your CI so ephemeral tunnels can be opened and closed by GitHub Actions for previewing PRs.
The full Ansible playbook (with secrets stripped) is in docs/guides/self-hosting. The architecture deep-dive is at docs/guides/architecture. And if you want the same binary as a managed service — billed only for what you transfer, $0 at idle — sign up at rustunnel.com/register.
TL;DR. A Hetzner CX22 at €3.79/mo, an Ansible run, and a Cloudflare token are everything you need to self-host a tunnel relay with feature parity to ngrok and frp. The whole walkthrough fits in 6 minutes of ansible-playbook time, and the day-2 ops surface is small enough that one person can run all three rustunnel production regions without a pager.
→ Try the managed rustunnel cloud (same binary, $0 at idle, $0.10/GB after the $3 monthly minimum) → Read the architecture deep-dive → Star us on GitHub