The problem in context
Consider a common homelab bind: a Raspberry Pi at home records a camera feed, runs a couple of cron jobs, and hosts a small dashboard. On the LAN it is fine. Then you travel, open a laptop, type the familiar ssh pi@home, and get a timeout. The instinct is to log into the router and forward port 22 — except there is no public IP to forward to. The ISP has quietly moved the connection behind CGNAT (carrier-grade NAT), the shared address space reserved in RFC 6598, which means the "public" IP is shared with hundreds of subscribers and you control none of it.
That is the real problem, and it is worth naming precisely because it explains why the usual fixes all fail: port forwarding is meaningless when the port lives on someone else's equipment. DDNS points at an address you cannot open, UPnP does nothing, and VPN appliances want a static IP you do not have. Every one of those approaches assumes you can accept an inbound connection at home. Behind CGNAT, you cannot — and no amount of router configuration changes that.
The principle
The principle here is that NAT blocks inbound connections but happily allows outbound ones, so you stop waiting for the world to knock and have the Pi knock on a server you own — then keep that door propped open. This is a reverse SSH tunnel, and it is the cleanest escape hatch behind CGNAT, a locked-down office network, or any router you would rather not touch.
Concretely: rent a tiny always-on box in the cloud with a real public IP, have the Pi open an SSH connection out to it, and use SSH remote port forwarding (-R) so a port on the cloud box tunnels straight back through that connection to port 22 on the Pi. You connect to the cloud box, hop through the tunnel, and you are on the Pi — no router config, no exposed ports at home. The architecture is three parts:
Your laptop (anywhere) --> EC2 bridge (public IP, :2222) --> reverse tunnel --> Raspberry Pi (:22, behind CGNAT)
-
EC2 bridge: Ubuntu on a
t2.micro(free-tier eligible); its only job is to be reachable and forward port 2222 back down the tunnel. - Raspberry Pi: initiates and holds the tunnel open, restarting automatically if the network hiccups.
-
The tunnel: an SSH remote port forward (
-R 2222:localhost:22) wrapped in a systemd service so it survives reboots and drops.
The -R flag is the whole trick — the ssh(1) man page defines it as forwarding connections to a given TCP port on the remote host back to the local side. There is a detailed treatment of the full reverse-SSH build with every security-group rule and connection method; the mechanics compress to a handful of config lines below.
Trade-offs
A reverse SSH tunnel is not the only way to reach a machine behind NAT, and it is worth being honest about where it wins and loses against the managed options:
| Approach | What it costs | What it buys | Best when |
|---|---|---|---|
| Reverse SSH tunnel | You run and own a cloud bridge | Depends on nothing but SSH + systemd; every hop is yours; no third-party control plane | You want to own every hop and understand exactly what carries your packets |
| Cloudflare Tunnel | Traffic rides a third-party control plane | Outbound-only cloudflared; firewall can block all inbound; least setup |
You want the shortest path and don't want to run infrastructure |
| Tailscale | A coordination service brokers peers | Clever STUN/ICE NAT traversal builds direct peer-to-peer links | You want a mesh across many devices with minimal fuss |
All three are good; the choice is about what you are optimizing for. Cloudflare Tunnel is the easier button. The reverse SSH tunnel wins on ownership: it depends on nothing but tools already on the Pi, it does not route traffic through anyone else's control plane, and the cloud bridge is a box you can rebuild in minutes. The cost you accept in exchange is running that bridge and securing an internet-facing port yourself — which is a real cost, and the section below is mostly about paying it correctly.
How to adopt
Build it in layers, proving each one before wrapping it in automation.
-
Prepare the bridge. Launch a
t2.micro, open port 22 to your own IP and 2222 to0.0.0.0/0, and set three lines in/etc/ssh/sshd_config:
GatewayPorts yes
ClientAliveInterval 60
ClientAliveCountMax 3
GatewayPorts yes is the line most people miss. As the sshd_config(5) man page notes, sshd binds remote forwardings to loopback by default, so without it port 2222 stays invisible from the internet — the tunnel looks healthy but is unreachable from your laptop. The keepalives let the server reap dead tunnels instead of leaving zombie forwards holding the port.
Create a dedicated, unprivileged
tunneluser on the bridge whose only reason to exist is holding this forward. Least privilege: if it is ever compromised, the blast radius is one useless shell.Prove a manual tunnel from the Pi with a fresh ED25519 key whose public half is in the
tunneluser'sauthorized_keys:
ssh -i ~/.ssh/id_tunnel -fN -R 2222:localhost:22 tunnel@YOUR-EC2-IP
On the bridge, sudo ss -tulpn | grep 2222 should show it LISTENing.
- Make it permanent with systemd, because a manual tunnel dies the moment the Pi reboots or Wi-Fi blinks:
[Unit]
Description=Reverse SSH Tunnel to EC2
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
ExecStart=/usr/bin/ssh -i /home/pi/.ssh/id_tunnel -o ServerAliveInterval=60 -o ExitOnForwardFailure=yes -o StrictHostKeyChecking=no -N -R 2222:localhost:22 tunnel@YOUR-EC2-IP
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
Each option earns its place: ExitOnForwardFailure=yes kills the process if the forward cannot bind, so systemd retries a clean tunnel; Restart=always — which the systemd.service(5) man page defines as restarting regardless of exit status — with RestartSec=10 recovers within ten seconds; and ServerAliveInterval=60 stops idle NAT timeouts from silently severing the link. StrictHostKeyChecking=no is required because a headless unit has no human to accept a fingerprint prompt.
-
Make it pleasant with a two-hop
ProxyCommandin your laptop's~/.ssh/configsossh rpitransparently connects through the bridge and onto the Pi:
Host rpi
HostName localhost
Port 2222
User pi
IdentityFile ~/.ssh/id_rpi
ProxyCommand ssh -i ~/.ssh/rpi-tunnel-key.pem -W localhost:2222 ubuntu@YOUR-EC2-IP
The non-negotiable part is the security posture. Opening 2222 to the world is reasonable only because it is key-gated: disable password auth on the Pi, add fail2ban on the bridge, and treat that as the same-day work, not a follow-up. The gotchas that cost real time all trace back to the config above — a forgotten GatewayPorts yes (tunnel connects but stays localhost-bound), a missing StrictHostKeyChecking=no (headless service hangs on the fingerprint prompt), and zombie forwards after a hard drop (cleared by the server keepalives plus ExitOnForwardFailure).
Two operating habits keep this honest. Verify monthly — systemctl is-active on the Pi, ss -tulpn | grep 2222 on the bridge, and ssh rpi 'uptime' end to end — because a tunnel you never check fails silently the day you need it, and journalctl -u shows the reason when it does. And treat the bridge as cattle, not a pet: its entire config is a few sshd_config lines and one user, so rebuilding takes minutes, and nothing irreplaceable lives on it, which means nothing irreplaceable is exposed on it.
Where this goes next
The immediate extension is that the same tunnel carries more than SSH: add a second -R 5901:localhost:5900 forward and the headless Pi becomes a full remote desktop over VNC, reusing everything above. Once you have internalized "the device dials out and multiplexes services back," the pattern generalizes to any port you want to reach — a dashboard, a database, a metrics endpoint — without ever exposing them at home.
The larger direction of travel is that this is a hand-rolled instance of a principle the whole industry has converged on: outbound-only connectivity as the default security posture. Cloudflare Tunnel, Tailscale, and the broader zero-trust and SASE movement all rest on the same idea that a device should reach out to a control plane rather than expose inbound ports, and that identity and keys, not network location, gate access. Building the reverse tunnel by hand is the best way to understand what those managed services are actually doing for you — and to decide, per deployment, whether owning every hop is worth running the bridge yourself. The mental model is the durable part; the tooling that implements it will keep getting easier.
Sources & further reading
-
OpenSSH
ssh(1)man page — the-Rremote port forwarding flag. -
OpenSSH
sshd_config(5)man page — theGatewayPortsoption. -
systemd.service(5)man page —Restart=andRestartSec=semantics. - RFC 6598 — IANA-Reserved IPv4 Prefix for Shared Address Space — the standard behind CGNAT.
- Cloudflare Tunnel docs and Tailscale's "How NAT traversal works" — the managed alternatives.
- A longer reference treatment of this reverse SSH tunnel build — the security-group rules, port reference, and troubleshooting matrix behind this framing.
Top comments (0)