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.

João Henrique··9 min read

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:

  1. A side-by-side comparison vs ngrok, cloudflared, and frp (so you know what you're trading).
  2. The Hetzner box, DNS, and Cloudflare prerequisites (~10 minutes).
  3. The Ansible playbook layout (so you can re-use it for any number of edges).
  4. The actual one-command provisioning run.
  5. 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.

Featurengrokcloudflaredfrprustunnel (self-hosted)
HTTP / HTTPS tunnels
Custom subdomainPaid
Wildcard TLS via Let's Encryptn/a (managed)n/a (Cloudflare)DIY✅ (Cloudflare DNS-01 baked in)
TCP tunnelsPaidLimited
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 flatFree (gated to Cloudflare)Free (DIY)$4–$8/mo VPS, no tunnel-count limit
Open-source licenseSource-availableApache-2.0AGPL-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:

  1. 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.
  2. 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.
  3. A Cloudflare API token scoped to Zone.DNS:Edit for the zone you'll use (Cloudflare → Profile → API Tokens → Create Token → "Edit zone DNS" template).
  4. Local Ansible 2.15+ (brew install ansible on macOS, apt install ansible on Debian).
  5. 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:

  • baseufw allow 22,80,443,4040,9090, fail2ban, unattended-upgrades, a non-root tunneld user, swap (Hetzner CX22 has no swap by default).
  • rustunnel-server — pulls the latest release tarball from github.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 + the certbot-dns-cloudflare plugin, drops /etc/letsencrypt/cloudflare.ini with the API token (mode 600), runs certbot certonly --dns-cloudflare -d 'tunnel.example.com' -d '*.tunnel.example.com', sets up a certbot renew systemd timer.
  • nginx — terminates TLS on :443, reverse-proxies /dashboard to 127.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.com

Drop 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.yml

Then:

ansible-playbook \
  -i inventory/tunnels.ini \
  --vault-password-file ~/.ansible-vault-pass \
  site.yml

First 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.com

You 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:

  1. 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 on certbot renew --dry-run failing.
  2. Disk fill from /var/log/rustunnel/. The default is 100 MB rotation, but a chatty webhook tunnel can rotate faster. We set up logrotate with daily compression.
  3. 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

ItemMonthly
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.yml to host_vars/us-edge.yml, point a second Hetzner box at it, re-run site.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