Mid-development or mid-ops, I often need a local resource — a dev server, a tool, some other piece of software — reachable by a colleague or a client over the internet. Sometimes for ten minutes, sometimes for good.
I almost always reach for one of these options:
- a NAT rule on the router — a MikroTik example below;
- an SSH reverse tunnel — through my own server, or a free relay like pinggy / localhost.run / serveo;
- an nftables / iptables rule on a Linux server;
- a ready-made tunnel — localtunnel, cloudflared, ngrok.
Which one depends on what you have: a router you control, a box with a public IP, root on a remote server, or nothing but a laptop behind CGNAT. Here is each, with the one detail that usually trips it up.
How it works
Under the hood all four are the same trick. The addresses in the packet are rewritten so a request aimed at a public address and port lands on your private service: first the destination is swapped (DNAT), then on the way out the source (SNAT / MASQUERADE), and the reply retraces the same path. conntrack holds that pair in memory, so you only have to write the rule once.
1. On the router
If you control the router and the service should stay reachable, do it there. On a consumer box it is a Port Forwarding / Virtual Server form. On MikroTik it is a NAT rule — forward external 8080 to a service inside:
1/ip firewall nat add chain=dstnat protocol=tcp dst-port=8080 \2 in-interface=ether1 action=dst-nat to-addresses=192.168.88.10 to-ports=80
The in-interface matters — without it the rule catches internal traffic too. Check it actually fires by reading the counters; a zero means traffic never reaches the rule, so the rule is not your problem:
1/ip firewall nat print stats
Works from outside, not from inside
A classic. From your own LAN the packet hits the router, gets DNAT’d to the server — also on the LAN — and the server replies directly to you, bypassing the router. You wait for a reply from the external IP, get one from a local address, and drop it. Add a hairpin (loopback) NAT rule so the reply returns through the router:
1/ip firewall nat add chain=srcnat protocol=tcp dst-address=192.168.88.10 \2 dst-port=80 src-address=192.168.88.0/24 action=masquerade
Same broken return path as the rule above — the forward direction was fine all along.
2. An SSH reverse tunnel
No router access, or you need to expose a service for ten minutes? You can use SSH’s own port-forwarding mechanism. The -R option opens a port on a remote machine with a public IP and pipes it back to your localhost:
1ssh -R 3000:localhost:3000 user@server
That is the whole trick when you have your own VPS. One thing worth knowing:
The port only listens on localhost
By default sshd binds the forwarded port to 127.0.0.1 and ignores the bind_address you pass — protection against publishing a tunnel by accident. Flip it on the server:
1# /etc/ssh/sshd_config on the server, then: systemctl restart sshd2GatewayPorts clientspecified
no(default) — always 127.0.0.1;yes— always 0.0.0.0, every interface;clientspecified— the client picks the address (the sane one).
With clientspecified you can bind to exactly one interface — e.g. hand a local service to the containers on the server without putting it on the internet:
1ssh -N -R 172.22.0.1:8086:127.0.0.1:8080 user@server
The tunnel dies with the session; wrap it in autossh or a systemd unit with Restart=always if it needs to stay up.
Two more options from the same family: only -R exposes your localhost, -L pulls a remote port to you, and -D opens a local SOCKS proxy:
1ssh -L 5432:127.0.0.1:5432 user@server # pull a remote-only port to you2ssh -D 1080 user@server # a local SOCKS proxy
No server of your own? Use a public relay
You don’t need a server of your own. The same ssh -R works against public relays:
1# Pinggy2ssh -p 443 -R0:localhost:3000 free.pinggy.io34# localhost.run5ssh -R 80:localhost:3000 [email protected]67# Serveo8ssh -R 80:localhost:3000 serveo.net
All three need nothing but ssh: no client to install, no account to create. Pinggy hands you an HTTPS link immediately, raw TCP needs the host prefixed as [email protected], and a free tunnel lasts an hour. On localhost.run the nokey@ prefix skips the SSH key check, and free domains rotate every few hours. Serveo also offers WireGuard and a browser extension if ssh isn’t handy. Good for showing a colleague a webhook or a preview.
3. A lasting rule with nftables / iptables
If the Linux box is itself the gateway, do it with kernel rules. First let it pass transit traffic:
1sysctl -w net.ipv4.ip_forward=12echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-forward.conf
On current distributions (Debian 10+, Ubuntu 20.10+, RHEL 8+) nftables is the standard and iptables is a shim on top of it. For anything new:
1table ip nat {2 chain prerouting {3 type nat hook prerouting priority dstnat;4 tcp dport 8080 dnat to 192.168.99.10:805 }6 chain postrouting {7 type nat hook postrouting priority srcnat;8 ip saddr 192.168.99.0/24 oifname "eth0" masquerade9 }10}
Older articles and forum threads usually show the iptables version instead. It takes three rules, and all three are load-bearing:
1# 1. swap the destination on the way in2iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 \3 -j DNAT --to-destination 192.168.99.10:8045# 2. permit forwarding — DNAT grants nothing by itself6iptables -A FORWARD -p tcp -d 192.168.99.10 --dport 80 -j ACCEPT78# 3. swap the source so the reply comes back through us9iptables -t nat -A POSTROUTING -d 192.168.99.10 -p tcp --dport 80 -j MASQUERADE
- Without #1 the packet never finds the service.
- Without #2 the filter drops it — DNAT changes an address, it does not grant permission.
- Without #3 the service replies to the client directly and the reply is lost. The broken return path again.
You can drop #3 only when the server already routes replies back through this machine. Rules live until reboot — iptables-persistent, or nft list ruleset > /etc/nftables.conf.
4. Ready-made tunnels
Behind CGNAT, with no public IP and no server of your own? A hosted tunnel dials out from your machine and gives you a public URL back. You configure nothing on the network side.
localtunnel — one npx away:
1npx localtunnel --port 3000
Cloudflare’s tunnel — a quick one needs no account:
1cloudflared tunnel --url http://localhost:3000
ngrok — the classic; a free token, set once:
1ngrok config add-authtoken $YOUR_AUTHTOKEN2ngrok http 3000
or the same through Python’s uv, nothing to install:
1# nothing to install if you already have uv2uvx pyngrok config add-authtoken $YOUR_AUTHTOKEN3uvx pyngrok http 3000
The fastest option, and the least private: your traffic crosses someone else’s edge, and the URL is public the moment it prints. Fine for demos and webhooks, but keep real data off it.
When it doesn’t connect
Don’t rewrite the rule — follow the packet, top down:
ss -tulpn | grep 8080— is it even listening, and on 0.0.0.0? A 127.0.0.1 bind is unreachable from outside, no matter the rules.nc -vz host 8080— does the port answer locally at all?tcpdump -ni eth0 port 8080— do packets even reach the gateway? Silence means upstream: ISP, cloud security group, a router above.iptables -t nat -nvL --line-numbers— read the packet counters; a zero means the rule never matched.conntrack -L | grep host— an empty table under sustained traffic means nothing arrived, whatever the control plane claims.
Security, and picking one
- A tunnel URL or an open port is reachable by anyone who finds it. Bind to a specific interface and put auth in front before you expose a dev service to the world.
- Restrict the source when you can —
-s 203.0.113.7on the rule; most tunnel CLIs take a basic-auth flag. - Mind Docker:
-p 8080:80writes its own NAT rule ahead of UFW, so the port is public even when the firewall says closed. Use-p 127.0.0.1:8080:80. - Turn UPnP off — it lets any app on the network open itself a port to the outside, silently.
Pick by what you’ve got: a router you own → a rule on it; your own VPS → ssh -R; a Linux gateway → nftables; nothing but a laptop behind CGNAT → a hosted tunnel or an ssh relay. The last two dial out from your side, which is the only thing that works when there’s no public port to open.



