Logo

Reverse SSH tunnel: reach a server behind NAT

Reverse SSH tunnel: reach a server behind NAT

Access a server, Raspberry Pi or client machine behind NAT, a firewall, or a residential router, without configuring port forwarding. The server initiates the connection to a bastion VPS, and you connect through the bastion.

Introduction

You have a server, client machine, Raspberry Pi or NAS at a client / at home behind a router you can't configure (locked-down ISP box, dynamic IP, carrier-grade NAT). How do you administer it remotely?

Classic solution: port forwarding on the router. Impossible when you don't have control.

Modern solution: reverse SSH tunnel. The target machine connects itself to a public VPS you control (the "bastion"). Once the reverse SSH session is established, you connect to the bastion and bounce to the target.

Architecture:

[You] ──SSH──▶ [Bastion VPS] ◀──Reverse SSH── [Target behind NAT]
                (public IP)                    (10.0.0.42)

Prerequisites

  • A public VPS with active SSH (= the bastion). A small VeryCloud VPS is enough.
  • A target Linux machine with SSH client (Raspberry Pi, NAS, server behind NAT...)
  • Root or sudo access on both

Step 1: Prepare the bastion

On the bastion VPS, create a dedicated tunnel user:

sudo adduser tunnel
sudo usermod -s /bin/false tunnel  # No interactive shell (security)

Disable unnecessary functions in /etc/ssh/sshd_config:

Match User tunnel
    PasswordAuthentication no
    PermitTTY no
    AllowAgentForwarding no
    AllowTcpForwarding yes
    X11Forwarding no
    PermitOpen any
    ForceCommand /bin/false
sudo systemctl reload sshd

Allow listening port opening (GatewayPorts):

sudo nano /etc/ssh/sshd_config

Add:

GatewayPorts clientspecified
sudo systemctl reload sshd

Step 2: Generate SSH key on target

On the target machine (behind NAT):

sudo ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_tunnel

Get the public key:

cat /root/.ssh/id_tunnel.pub

Step 3: Install the key on bastion

On the bastion, add the public key to tunnel user's authorized_keys:

sudo mkdir -p /home/tunnel/.ssh
sudo nano /home/tunnel/.ssh/authorized_keys

Paste the public key. Permissions:

sudo chown -R tunnel:tunnel /home/tunnel/.ssh
sudo chmod 700 /home/tunnel/.ssh
sudo chmod 600 /home/tunnel/.ssh/authorized_keys

Step 4: Test the tunnel manually

On the target:

sudo ssh -i /root/.ssh/id_tunnel \
    -N -R 2222:localhost:22 \
    tunnel@BASTION_IP

Decoded:

  • -N: no command execution
  • -R 2222:localhost:22: opens port 2222 on bastion, redirected to port 22 (SSH) on target

From your workstation, connect to bastion then bounce:

ssh user@BASTION_IP
ssh -p 2222 target_user@localhost

Or directly in one command from your workstation (no intermediate step):

ssh -J tunnel@BASTION_IP -p 2222 target_user@localhost

If that works, you're connected to the target via the bastion.

Step 5: systemd service for persistent tunnel

Manual tunnel dies on reboot or any disconnect. We'll make it persistent.

On the target:

sudo nano /etc/systemd/system/reverse-tunnel.service
[Unit]
Description=Reverse SSH tunnel to bastion
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
ExecStart=/usr/bin/ssh \
    -NT \
    -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    -o StrictHostKeyChecking=accept-new \
    -i /root/.ssh/id_tunnel \
    -R 2222:localhost:22 \
    tunnel@BASTION_IP
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable:

sudo systemctl daemon-reload
sudo systemctl enable --now reverse-tunnel
sudo systemctl status reverse-tunnel

Check:

sudo journalctl -u reverse-tunnel -f

On bastion:

ss -tlnp | grep 2222
# Must show listening on 127.0.0.1:2222

Step 6: With autossh (more robust than native ssh)

autossh detects disconnects and reconnects faster. Install on target:

sudo apt install -y autossh

Edit the service:

sudo nano /etc/systemd/system/reverse-tunnel.service
[Service]
ExecStart=/usr/bin/autossh \
    -M 0 -NT \
    -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    -i /root/.ssh/id_tunnel \
    -R 2222:localhost:22 \
    tunnel@BASTION_IP
sudo systemctl daemon-reload
sudo systemctl restart reverse-tunnel

Step 7: Multiple tunnels for multiple targets

If you have 5 Raspberry Pis at 5 clients, assign a distinct port on bastion:

ClientBastion portTarget
ClientA2201rpi-a:22
ClientB2202rpi-b:22
ClientC2203rpi-c:22

On each target, adapt the port in -R 22XX:localhost:22.

To connect:

ssh -J tunnel@BASTION_IP -p 2202 pi@localhost

Step 8: Expose ports other than SSH

The reverse tunnel isn't limited to SSH. You can expose a local web service:

# On target
ssh -R 8080:localhost:80 tunnel@BASTION_IP

On bastion, port 8080 redirects to target's web server (port 80).

To make it publicly accessible (not just from bastion), use 0.0.0.0:

ssh -R 0.0.0.0:8080:localhost:80 tunnel@BASTION_IP

⚠️ Requires GatewayPorts clientspecified or yes on bastion side (done in step 1).

Step 9: Multi-port reverse tunnel

To expose multiple services in a single tunnel:

ssh -N \
    -R 2222:localhost:22 \
    -R 8080:localhost:80 \
    -R 9090:localhost:9090 \
    tunnel@BASTION_IP

Step 10: Nginx as front reverse-proxy

Instead of exposing a random port 8080, route the tunnel through Nginx with HTTPS and subdomain. On bastion:

server {
    listen 443 ssl http2;
    server_name rpi-clienta.verycloud.fr;
    
    ssl_certificate /etc/letsencrypt/live/rpi-clienta.verycloud.fr/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rpi-clienta.verycloud.fr/privkey.pem;
    
    location / {
        proxy_pass http://127.0.0.1:8080;  # tunnel port
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

You now access client A's Raspberry Pi via https://rpi-clienta.verycloud.fr.

Step 11: Additional security

Restrict exposable ports

In authorized_keys of tunnel user, prefix the key with options:

restrict,port-forwarding,permitlisten="2222",no-pty ssh-ed25519 AAAAC3Nz... mathys@laptop

This client can only create tunnels on port 2222 of bastion. Useful for per-client limiting.

Firewall on bastion

Block external access to tunnel ports:

sudo ufw deny 2222:2299/tcp

So to connect, you MUST first go through SSH on bastion (port 22), no direct access from internet.

Tunnel monitoring

On target:

# Status
sudo systemctl status reverse-tunnel

# Live logs
sudo journalctl -u reverse-tunnel -f

On bastion, check active connections:

ss -tnp | grep tunnel

Step 12: Modern alternative — Tailscale

For the same problem in SaaS / zero-config mode, see the Tailscale Mesh VPN guide. Less manual config but dependency on third-party service.

Reverse SSH is:

  • ✅ 100% self-hosted
  • ✅ Minimalist (just SSH)
  • ❌ More manual
  • ❌ Requires managing ports/Nginx

Tailscale is:

  • ✅ Zero-config (auth key and it works)
  • ✅ Mesh (each machine sees each machine)
  • ❌ Third-party dependency (coordination server)
  • ❌ More software layers

Troubleshooting

Tunnel doesn't hold

Enable keepalives (already in systemd service). Also check NAT/firewall on target side which may cut idle connections. ServerAliveInterval=30 sends a packet every 30s.

"Could not request local forwarding"

Port already used on bastion or GatewayPorts misconfigured. Change port or check sshd_config.

"Connection refused" from bastion

Tunnel is not active. On target:

sudo systemctl status reverse-tunnel
sudo journalctl -u reverse-tunnel -n 30

"Host key verification failed"

First connection to bastion: SSH asks to validate fingerprint. With StrictHostKeyChecking=accept-new in the service, it's auto on first pass. If it fails, delete the bastion line in /root/.ssh/known_hosts.

Degraded performance

SSH encrypts everything. For big transfers, use fast ciphers:

ssh -c [email protected] ...

Useful commands

# See all active tunnels on bastion
ss -tlnp | grep sshd

# tunnel user connections
who | grep tunnel

# Test bastion connectivity from target
sudo -u root ssh -i /root/.ssh/id_tunnel tunnel@BASTION_IP exit
echo $?  # 0 = OK

# Force reconnect
sudo systemctl restart reverse-tunnel

# List exposed ports from bastion
ss -tlnp | grep '127.0.0.1\|0.0.0.0'

# Kill a zombie tunnel (on bastion)
sudo pkill -u tunnel

Conclusion

The reverse SSH tunnel is the minimalist tool to reach any machine behind NAT:

  • No port forwarding on client/box side
  • 100% encrypted (SSH)
  • Persistent via systemd + autossh
  • Multi-tunnel possible with a single bastion

Going further:

  • Combine with Nginx + Let's Encrypt to expose HTTPS
  • Use Cloudflare Tunnel as SaaS alternative
  • Migrate to Tailscale or WireGuard for mesh VPN

Resources

Join our Discord community server

For any questions, suggestions, or just to chat with the community, join us on Discord!

900+Members