Exposing Local Microservices Safely: Ngrok vs Cloudflared vs Custom SSH Tunnels
How ngrok, Cloudflare Tunnel and a self-hosted SSH reverse tunnel each put a local service on a public URL, what each one trusts, and the settings that keep a webhook test from becoming an open door.
Sooner or later a service on your laptop needs to be reachable from the internet: a payment provider has to deliver a webhook, or a teammate needs to try a branch. Opening a port on the router is rarely possible and never wise. Outbound tunnels solve this: a client on your machine connects out to a relay, and the relay forwards public requests back down that connection.
Three approaches cover most needs: ngrok, Cloudflare Tunnel (the cloudflared client), and a reverse SSH tunnel to a server you control. They differ mainly in who terminates TLS, who can see your traffic and how much you operate yourself.
What you'll learn
- How outbound tunnels work, and a side-by-side comparison to pick one quickly.
- Safe setups for each: authentication at the edge, narrow scope, short-lived credentials.
- A hardened SSH reverse tunnel: restricted account,
GatewayPortsoff, verified host keys, automatic reconnects. - The risks and failure modes that apply to all three.
How outbound tunnels work
internet client --HTTPS--> relay (public) ==tunnel==> agent on your machine --> localhost:8080
^ |
+---- outbound connection -+
The agent opens an outbound connection to the relay, so no inbound firewall rule is needed. The relay accepts public requests for a hostname and sends them down the tunnel. The agent forwards them to the local port.
The consequence: anything that reaches the public hostname reaches your local service. The tunnel bypasses your router and firewall by design, so authentication has to happen at the relay or in the application.
The comparison
| Feature | ngrok | Cloudflare Tunnel | SSH reverse tunnel |
|---|---|---|---|
| What you run | ngrok agent | cloudflared daemon | ssh or autossh, plus a relay server you operate |
| Account needed | Yes (authtoken) | Yes for named tunnels; quick tunnels need none | No third party; you need a server with a public address |
| Inbound ports opened | None | None | None at home; 22 and 443 on the relay |
| Public hostname | Provider subdomain, or your own domain on some plans | Your domain on Cloudflare DNS, or a random quick-tunnel name | Any domain you point at the relay |
| Who terminates public TLS | ngrok's edge | Cloudflare's edge | Your relay (for instance NGINX with your certificate) |
| Who can see decrypted traffic | ngrok | Cloudflare | Only you |
| Edge authentication | Traffic policy actions (OAuth, basic auth, IP rules); features vary by plan | Cloudflare Access policies in front of the hostname | Whatever you configure on the relay: basic auth, OAuth2 Proxy, client certificates |
| Protocols | HTTP(S), TCP, TLS | HTTP(S) publicly; other TCP needs cloudflared or WARP on the client | Any TCP |
| Inspection tools | Built-in request inspector and replay | Logs in the dashboard | Your own logs |
| Operating burden | Lowest | Low | Highest: patch, monitor and secure the relay |
| Good fit | Quick demos and webhook testing | Longer-lived internal tools on your own domain | Strict data control, unusual protocols, no third-party edge |
Plan limits and prices for ngrok and Cloudflare change often, so check the current terms before you design around a feature.
Option 1: ngrok
Install the agent from ngrok's site or package repository, then add your authtoken once:
ngrok config add-authtoken <YOUR_AUTHTOKEN>
The token is stored in ngrok's configuration file. Anyone who has it can open tunnels under your account, so revoke it in the dashboard if a laptop is lost.
A quick tunnel to port 8080:
ngrok http 8080
That prints a public HTTPS URL and leaves the local service open to anyone who finds it. Add authentication at ngrok's edge with a traffic policy file. This one requires a Google login and then denies anyone whose email is not on your domain:
on_http_request:
- actions:
- type: oauth
config:
provider: google
- expressions:
- "!actions.ngrok.oauth.identity.email.endsWith('@example.com')"
actions:
- type: deny
ngrok http 8080 --url https://api-dev.example.com --traffic-policy-file policy.yml
Version notes: these commands are for the ngrok v3 agent. Recent v3 releases use --url, which replaced --domain; older agents need --domain api-dev.example.com instead. Using your own domain requires adding it in the ngrok dashboard and a plan that allows it; without one, omit --url and use the address ngrok assigns. Traffic policy is ngrok's current mechanism for edge rules; older guides use flags such as --oauth, which still exist on some versions but are being phased out. Check ngrok http --help on your agent.
A webhook sender cannot log in through OAuth. Leave that path open at the edge and verify the provider's request signature in your application instead; most providers sign each request with a shared secret.
Option 2: Cloudflare Tunnel with cloudflared
Quick tunnels, for testing only
cloudflared tunnel --url http://localhost:8080
That prints a random trycloudflare.com address, no account needed. Cloudflare positions quick tunnels for testing only, with usage limits, no uptime guarantee and no way to add authentication. Never point one at real data.
Named tunnels on your own domain
A named tunnel is tied to your Cloudflare account and a domain whose DNS Cloudflare hosts:
cloudflared tunnel login
cloudflared tunnel create api-dev
cloudflared tunnel route dns api-dev api-dev.example.com
create writes a credentials file named after the tunnel's UUID into ~/.cloudflared/. That file lets anyone run the tunnel, so keep it mode 600 and out of version control. route dns creates a CNAME for api-dev.example.com that points at the tunnel.
Then describe what the tunnel serves in ~/.cloudflared/config.yml:
tunnel: <TUNNEL-UUID>
credentials-file: /home/dev/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: api-dev.example.com
service: http://localhost:8080
- hostname: hooks-dev.example.com
path: ^/webhooks/
service: http://localhost:9090
- service: http_status:404
Rules are matched top to bottom, and the last rule must be a catch-all with no hostname. Ending with http_status:404 means a request for any hostname or path you did not list is refused at the tunnel, instead of falling through to a local port. Check the file before running it, then test which rule a URL hits:
cloudflared tunnel ingress validate
cloudflared tunnel ingress rule https://hooks-dev.example.com/webhooks/payments
cloudflared tunnel run api-dev
To survive reboots, sudo cloudflared service install sets it up as a system service, using the first configuration with tunnel credentials it finds in its standard directories; keeping both files in /etc/cloudflared/, owned by root, avoids surprises. Tunnels created in the Cloudflare dashboard run with cloudflared tunnel run --token <TOKEN>, and that token is a credential too.
Put Cloudflare Access in front
A named tunnel on its own is public. In the Cloudflare Zero Trust dashboard, add a self-hosted Access application for api-dev.example.com with a policy that allows, for instance, emails ending in @example.com signed in through your identity provider. Requests without a valid Access session are stopped at Cloudflare and never reach the tunnel. For the webhook hostname, use a narrower bypass rule that covers only the webhook path, and verify signatures in the application.
The local service can also validate the signed JWT that Access adds in the Cf-Access-Jwt-Assertion header, against your team's public keys, which stops anyone who finds another route to the port.
Option 3: An SSH reverse tunnel to your own relay
Here the relay is a small Linux server you control, reachable at relay.example.com (203.0.113.10). The design keeps the forwarded port on the relay's loopback interface and puts NGINX in front for TLS and authentication. Nothing on the relay listens publicly except SSH and NGINX.
browser --HTTPS 443--> NGINX on relay (TLS + auth) --> 127.0.0.1:9000 on relay
|| SSH -R
laptop: localhost:8080
A restricted account on the relay
Create an account that can do nothing except hold one remote forward:
sudo useradd --create-home --shell /usr/sbin/nologin tunnel
sudo install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh
Add the laptop's public key to /home/tunnel/.ssh/authorized_keys with options that restrict it to one listen address:
restrict,port-forwarding,permitlisten="127.0.0.1:9000" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... dev-laptop-2026-10
restrict turns off every optional feature (PTY, agent and X11 forwarding, user rc files, and port forwarding); port-forwarding turns forwarding back on, and permitlisten limits remote forwards to 127.0.0.1:9000. The permitlisten option needs OpenSSH 7.8 or later on the relay.
Back it up in /etc/ssh/sshd_config on the relay, so the restriction holds even if someone adds a key without options:
Match User tunnel
AllowTcpForwarding remote
PermitListen 127.0.0.1:9000
GatewayPorts no
X11Forwarding no
AllowAgentForwarding no
PermitTTY no
PasswordAuthentication no
ForceCommand /usr/sbin/nologin
Check the file and the effective settings for that user before reloading:
sudo sshd -t
sudo sshd -T -C user=tunnel,host=laptop,addr=198.51.100.25 | grep -Ei 'gatewayports|permitlisten|allowtcpforwarding'
sudo systemctl reload ssh
The service is named ssh on Debian and Ubuntu and sshd on RHEL-family systems.
Why GatewayPorts stays off
GatewayPorts decides which address a remote forward binds to on the relay:
no(the default): remote forwards bind to loopback only, whatever the client asks for.yes: they bind to all interfaces, so port 9000 is open to the internet with no TLS and no authentication.clientspecified: the client chooses the bind address.
With no, the only way in from outside is through NGINX, where TLS and authentication live. Setting GatewayPorts yes to skip the proxy is how these setups usually end up exposing a development server to everyone.
The client side
Verify the relay's host key before the first connection. On the relay's console, print its fingerprint with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub, then compare it with what the laptop sees. Never turn off host key checking for a tunnel: an unattended reconnect loop is exactly where a man-in-the-middle would go unnoticed.
Put the tunnel in ~/.ssh/config on the laptop:
Host relay-tunnel
HostName relay.example.com
User tunnel
IdentityFile ~/.ssh/tunnel_ed25519
IdentitiesOnly yes
RemoteForward 127.0.0.1:9000 localhost:8080
ExitOnForwardFailure yes
ServerAliveInterval 30
ServerAliveCountMax 3
StrictHostKeyChecking yes
ExitOnForwardFailure makes ssh quit if the relay refuses the forward (for instance, an old session still holds the port), so a supervisor retries instead of keeping a connection that forwards nothing. ServerAliveInterval 30 with ServerAliveCountMax 3 detects a dead connection after about 30 x 3 = 90 seconds. Check the resolved settings with ssh -G relay-tunnel.
Use a dedicated key, generated with ssh-keygen -t ed25519 -f ~/.ssh/tunnel_ed25519. An unattended tunnel usually means no passphrase, which is acceptable only because the relay restricts what the key can do. Rotate it when the project ends or the laptop changes hands.
Keep it running
autossh restarts ssh when it exits. With -M 0, it relies on the ServerAlive settings above instead of its own monitoring port:
AUTOSSH_GATETIME=0 autossh -M 0 -N relay-tunnel
AUTOSSH_GATETIME=0 makes autossh retry even if the very first connection fails. On a machine with systemd, a user service does the same job, and plain ssh with Restart=always works just as well as autossh:
[Unit]
Description=Reverse SSH tunnel to relay
[Service]
ExecStart=/usr/bin/ssh -N relay-tunnel
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
Save it as ~/.config/systemd/user/relay-tunnel.service and start it with systemctl --user enable --now relay-tunnel. User services cannot order themselves after system targets such as network-online.target, which is why the unit simply retries every 10 seconds. They also stop when you log out unless lingering is on (loginctl enable-linger).
NGINX on the relay
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name dev.example.com;
ssl_certificate /etc/nginx/tls/dev.example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/tls/dev.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
auth_basic "dev";
auth_basic_user_file /etc/nginx/htpasswd-dev;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
The map block belongs in the http context, which is where files in /etc/nginx/conf.d/ are included. The upgrade headers let WebSocket connections through, which many development servers use for live reload. Create the password file with htpasswd -B -c /etc/nginx/htpasswd-dev alice (from the apache2-utils package) for bcrypt hashes, then make it readable by the NGINX worker user only: chown root:www-data and chmod 640 on Debian and Ubuntu. If the workers cannot read it, every request fails with a 500 rather than a 401. For team access, swap basic auth for an identity-aware proxy, as described in our zero-trust access guide.
Risks that apply to every option
- Hostnames are not secrets. Every publicly trusted certificate is published in Certificate Transparency logs, and scanners watch them, so expect probes soon after a certificate for a new hostname is issued.
- Development servers are not hardened. Debug pages, stack traces and interactive debug consoles that are harmless on localhost can expose source code or allow code execution once public. Turn debug modes off before exposing a service.
- Expose one service, not a machine. Point the tunnel at one port of one process. Never forward SSH, a database, or a Docker socket this way.
- Third-party edges see plaintext. ngrok and Cloudflare terminate TLS for HTTP tunnels, so they can see request contents. That is often fine for test data and not fine for customer data.
- Company policy. Tunnels bypass firewall controls on purpose. On a work network, get approval first.
- Credentials outlive projects. ngrok authtokens,
cloudflaredcredentials and tunnel SSH keys should be scoped to one use, recorded and revoked when the work ends.
Failure modes and troubleshooting
| Symptom | Likely cause and fix |
|---|---|
ssh reports "remote port forwarding failed for listen port 9000" | An earlier session still holds the port on the relay, or permitlisten does not match. Wait for the old session to time out or end it on the relay; check sudo ss -tlnp there. |
| Tunnel connects but the relay returns 502 | Nothing is listening on the laptop's port 8080, or the service listens on a different interface than localhost. |
| Port 9000 reachable from the internet | GatewayPorts is yes or clientspecified. Set it back to no and check with ss -tln that 9000 is bound to 127.0.0.1. |
| autossh loops quickly with "Host key verification failed" | The relay's host key changed or was never verified. Confirm the new fingerprint on the relay's console before updating known_hosts. |
cloudflared returns 404 for a hostname | No ingress rule matches. Test with cloudflared tunnel ingress rule <URL>. |
| Cloudflare error 1033 | The tunnel is not running or not connected. Check cloudflared tunnel info api-dev. |
| Webhooks rejected after adding edge auth | The provider cannot log in. Bypass auth for the webhook path only and verify signatures in the application. |
Checklist
- Authentication is enforced before traffic reaches the local service: an ngrok traffic policy, a Cloudflare Access policy, or NGINX auth on the relay.
- Webhook paths are the only exceptions, and the application verifies their signatures.
- The tunnel points at one port of one service, with debug modes off.
cloudflaredingress ends with ahttp_status:404catch-all.- On the relay: a dedicated account,
restrictandpermitlistenon the key,GatewayPorts no, password login off. - The relay's host key was verified out of band; host key checking stays on.
- Tunnel credentials are mode 600, out of version control, recorded, and revoked when the work ends.
- The tunnel is stopped when it is no longer needed.
Closing
Pick ngrok for quick, inspectable tests, Cloudflare Tunnel for longer-lived tools on your own domain, and an SSH relay when traffic must not pass through anyone else's edge. Whichever you choose, put authentication in front of it on day one, not after the first scan.