Skip to main content Skip to sidebar

Reverse SSH Tunnels

Accessing remote hosts behind NAT or firewalls is a common infrastructure challenge. A reverse SSH tunnel solves this without requiring third-party VPN software - the remote host initiates an outbound SSH connection to a publicly reachable bastion server, creating a listening port on the bastion that forwards traffic back through the tunnel. Anyone with access to the bastion can then reach the remote host via that port. It relies on OpenSSH, is available on virtually every Linux system, and requires no additional software.

How It Works

flowchart LR
    subgraph NAT["Behind NAT / Firewall"]
        Client["Remote Host<br/>:22"]
    end
    subgraph Public["Public Network"]
        Bastion["Bastion Server<br/>localhost:21022"]
    end
    subgraph Admin["Administrator"]
        Operator["Operator"]
    end

    Client -- "Outbound SSH<br/>-R localhost:21022:localhost:22" --> Bastion
    Operator -- "ssh -p 21022<br/>localhost" --> Bastion
    Bastion -. "Forwarded through tunnel" .-> Client

The remote host opens a persistent SSH connection to the bastion with the -R flag. This binds a port on the bastion (e.g., 21022) and forwards any incoming connection on that port back through the tunnel to the remote host’s SSH daemon.

SSH Command

ssh -N -T \
    -o ServerAliveInterval=60 \
    -o ServerAliveCountMax=3 \
    -o StrictHostKeyChecking=no \
    -o ExitOnForwardFailure=yes \
    -o UserKnownHostsFile=/dev/null \
    -i /etc/ssh/ssh_host_ed25519_key \
    -R localhost:21022:localhost:22 \
    reverse-ssh@bastion.example.com
FlagPurpose
-NNo remote command execution - tunnel only
-TNo pseudo-terminal allocation
-R localhost:21022:localhost:22Bind port 21022 on the bastion, forward to local port 22
-o ServerAliveInterval=60Send keep-alive every 60 seconds to detect dead connections
-o ServerAliveCountMax=3Disconnect after 3 missed keep-alives (3 minutes)
-o ExitOnForwardFailure=yesExit immediately if the port binding fails (e.g., port already in use)
-i /etc/ssh/ssh_host_ed25519_keyAuthenticate using the host’s own SSH key

Using the machine’s host key (ssh_host_ed25519_key) instead of a user key simplifies key management - each host already has a unique key pair, and there’s no need to generate or distribute additional keys.

Note

If the host key is missing or needs to be regenerated:

sudo rm -f /etc/ssh/ssh_host_*
sudo ssh-keygen -A

This regenerates all host key types (RSA, ECDSA, ED25519). To regenerate only ED25519:

sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""

After regeneration, update the corresponding public key in the bastion’s authorized_keys.

Systemd Service

Using a systemd template unit (reverse-ssh@.service) allows running multiple tunnels to different bastions from a single unit file. The instance name (%i) identifies the bastion:

# /etc/systemd/system/reverse-ssh@.service
[Unit]
Description=Reverse SSH Tunnel to %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=SELF_REMOTE_HOST=%H
ExecStart=/usr/bin/ssh -N -T \
    -o ServerAliveInterval=60 \
    -o ServerAliveCountMax=3 \
    -o StrictHostKeyChecking=no \
    -o ExitOnForwardFailure=yes \
    -o UserKnownHostsFile=/dev/null \
    -o SendEnv=SELF_REMOTE_HOST \
    -i /etc/ssh/ssh_host_ed25519_key \
    -R localhost:21022:localhost:22 \
    reverse-ssh@%i
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

%i is the instance name (the part after @), and %H is the machine hostname. Enable tunnels to specific bastions:

systemctl daemon-reload
systemctl enable --now reverse-ssh@bastion-us.example.com.service
systemctl enable --now reverse-ssh@bastion-eu.example.com.service

Each instance runs independently - starting, stopping, and restarting without affecting other tunnels.

Monitoring

SSH tunnels can silently fail - the process may still be running but the TCP connection is dead. A simple monitoring script restarts any tunnel in a failed state:

#!/bin/bash
set -euo pipefail

for service in /etc/systemd/system/reverse-ssh@*.service; do
    [ ! -f "$service" ] && continue

    name=$(basename "$service")
    state=$(systemctl is-active "$name" || true)

    if [ "$state" = "failed" ]; then
        echo "Restarting failed service: $name"
        systemctl restart "$name"
    fi
done

Run this via a systemd timer every 10 minutes:

# /etc/systemd/system/reverse-ssh-monitor.timer
[Unit]
Description=Monitor reverse SSH tunnels

[Timer]
OnBootSec=2min
OnCalendar=*:0/10
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/reverse-ssh-monitor.service
[Unit]
Description=Monitor and restart failed reverse SSH tunnels

[Service]
Type=oneshot
ExecStart=/usr/local/bin/reverse-ssh-monitor.sh

Securing the Bastion

The bastion server should restrict what the reverse-ssh user can do. This is enforced entirely through authorized_keys options.

The restrict keyword (OpenSSH 7.2+) disables all capabilities at once: port forwarding, agent forwarding, X11 forwarding, PTY allocation, and ~/.ssh/rc execution. It is future-proof - any new restriction added in future OpenSSH versions is automatically included. Then selectively re-enable only what the tunnel needs with port-forwarding:

restrict,port-forwarding,command="/sbin/nologin",from="198.51.100.0/24,203.0.113.0/24",expiry-time="20261231Z",permitlisten="localhost:21022",permitopen="localhost:22" ssh-ed25519 AAAAC3Nza... host-name
OptionEffect
restrictDisables all capabilities (PTY, forwarding, agent, X11, user-rc)
port-forwardingRe-enables port forwarding disabled by restrict
command="/sbin/nologin"Blocks interactive shell access
from="198.51.100.0/24,203.0.113.0/24"Only accepts connections from these CIDR ranges
expiry-time="20261231Z"Key stops working after December 31, 2026 UTC
permitlisten="localhost:21022"Only allows binding port 21022 for -R
permitopen="localhost:22"Only allows forwarding to localhost:22 for -L
Tip
Always use restrict instead of listing individual no-pty, no-agent-forwarding, no-X11-forwarding options. The individual options can become stale when new features are added to OpenSSH, while restrict automatically covers everything.

Each host key gets its own line with a unique permitlisten port. This ensures that even if a host key is compromised, the attacker can only establish a tunnel on that specific port.

Multi-Bastion Redundancy

For high availability, each remote host can maintain tunnels to multiple bastions simultaneously:

flowchart LR
    subgraph NAT["Behind NAT"]
        Host["Remote Host"]
    end
    subgraph US["US Region"]
        B1["bastion-us<br/>:21022"]
    end
    subgraph EU["EU Region"]
        B2["bastion-eu<br/>:21022"]
    end

    Host -- "Tunnel 1" --> B1
    Host -- "Tunnel 2" --> B2

Each tunnel runs as an independent systemd service with its own bastion target and port assignment. If one bastion goes down, the host remains accessible through the other.

Port Allocation

When managing multiple hosts, maintain a port allocation table to prevent conflicts:

HostBastion PortLocal Port
web-server-12102222
db-server-12102322
iot-gateway2102422
monitoring2102522

Reserve port ranges by purpose:

  • 21000-21999 - reverse SSH tunnels to port 22
  • 22000-22999 - custom service tunnels (databases, web UIs, etc.)

The permitlisten port in the SSH key configuration must match the remote_port in the tunnel configuration. A mismatch results in: Error: remote port forwarding failed for listen port XXXXX.

Connecting Through the Tunnel

Once the tunnel is established, connect from the bastion:

ssh -p 21022 root@localhost

Or use ProxyJump from your workstation to reach the remote host in a single step:

ssh -J user@bastion.example.com -p 21022 root@localhost

Add this to ~/.ssh/config for convenience:

Host remote-host
    HostName localhost
    Port 21022
    User root
    ProxyJump user@bastion.example.com

Advanced Usage

SSH port forwarding flags follow the scheme [bind_addr]:[bind_port]:[target_addr]:[target_port]:

FlagListens onForwards toExample
-RRemote sideLocal side-R localhost:21022:localhost:22
-LLocal sideRemote side-L localhost:4505:localhost:4505

When bind_addr is omitted, it defaults to localhost. The shorter form [bind_port]:[target_addr]:[target_port] is equivalent:

-R 21022:localhost:22
-L 4505:localhost:4505

Multiple Ports in Both Directions

A single SSH connection can forward multiple ports simultaneously using -R (remote-to-local) and -L (local-to-remote) flags together. For example, to expose SSH, HTTP, and HTTPS from the remote host on the bastion, while also forwarding Salt master ports from the bastion back to the remote host:

ssh -N -T \
    -o ServerAliveInterval=60 \
    -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    -i /etc/ssh/ssh_host_ed25519_key \
    -R localhost:21022:localhost:22 \
    -R localhost:21080:localhost:80 \
    -R localhost:21443:localhost:443 \
    -L localhost:4505:localhost:4505 \
    -L localhost:4506:localhost:4506 \
    reverse-ssh@bastion.example.com
FlagDirectionWhat it does
-R localhost:21022:localhost:22bastion -> remoteSSH access to the remote host
-R localhost:21080:localhost:80bastion -> remoteHTTP on the remote host
-R localhost:21443:localhost:443bastion -> remoteHTTPS on the remote host
-L localhost:4505:localhost:4505remote -> bastionSalt master publish port
-L localhost:4506:localhost:4506remote -> bastionSalt master request port
Warning
-L binds a local listening port, and only one process can bind a given port at a time. If you run multiple tunnels (e.g., to bastion-us and bastion-eu) with the same -L port, the second tunnel will fail with bind: Address already in use. Unlike -R where each bastion has its own port namespace, -L ports compete on the local host. Use -L forwarding on only one tunnel, or assign different local ports per bastion.

The authorized_keys on the bastion must permit all forwarded ports:

command="/sbin/nologin",no-pty,no-agent-forwarding,no-X11-forwarding,permitlisten="localhost:21022",permitlisten="localhost:21080",permitlisten="localhost:21443",permitopen="localhost:4505",permitopen="localhost:4506" ssh-ed25519 AAAAC3Nza... host-name

The corresponding systemd template unit:

# /etc/systemd/system/reverse-ssh@.service
[Unit]
Description=Reverse SSH Tunnel to %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=SELF_REMOTE_HOST=%H
ExecStart=/usr/bin/ssh -N -T \
    -o ServerAliveInterval=60 \
    -o ServerAliveCountMax=3 \
    -o StrictHostKeyChecking=no \
    -o ExitOnForwardFailure=yes \
    -o UserKnownHostsFile=/dev/null \
    -o SendEnv=SELF_REMOTE_HOST \
    -i /etc/ssh/ssh_host_ed25519_key \
    -R localhost:21022:localhost:22 \
    -R localhost:21080:localhost:80 \
    -R localhost:21443:localhost:443 \
    -L localhost:4505:localhost:4505 \
    -L localhost:4506:localhost:4506 \
    reverse-ssh@%i
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

SOCKS Proxy with Dynamic Forwarding

The -D flag creates a local SOCKS5 proxy that dynamically forwards traffic through the SSH connection. Unlike -L which forwards a single port to a fixed destination, -D lets any application route traffic to any destination through the tunnel:

ssh -N -T -D 1080 \
    -i /etc/ssh/ssh_host_ed25519_key \
    reverse-ssh@bastion.example.com

This binds a SOCKS5 proxy on localhost:1080. Applications configured to use this proxy will have their traffic routed through the bastion. Useful for accessing multiple internal services without mapping each port individually:

# curl through the SOCKS proxy
curl --proxy socks5h://localhost:1080 http://internal-service.local:8080

# or set the environment variable
export ALL_PROXY=socks5h://localhost:1080

The socks5h:// scheme resolves DNS on the remote side, preventing DNS leaks. Use socks5:// if you want local DNS resolution instead.

-D can be combined with -R in the same connection:

ssh -N -T \
    -R localhost:21022:localhost:22 \
    -D 1080 \
    -i /etc/ssh/ssh_host_ed25519_key \
    reverse-ssh@bastion.example.com
Warning
Like -L, the -D port binds locally and can only be used by one tunnel at a time. If you run multiple tunnels with the same -D port, the second one will fail with bind: Address already in use.
Note

Dynamic forwarding requires permitopen in authorized_keys to allow outbound connections. Use permitopen="any:any" to allow the proxy to reach any destination, or list specific hosts to restrict it:

restrict,port-forwarding,command="/sbin/nologin",permitopen="internal-service.local:8080",permitopen="db.local:5432" ssh-ed25519 AAAAC3Nza... host-name

Conclusion

Reverse SSH tunnels provide a lightweight way to access hosts behind NAT or firewalls using only OpenSSH and systemd - no additional software, no VPN overhead. The key building blocks covered in this post:

  • Persistent tunnels via systemd template units with automatic restart
  • Bastion security through authorized_keys restrictions (permitlisten, permitopen, no-pty)
  • Monitoring with a timer-based script that catches silently failed connections
  • Multi-bastion redundancy for high availability across regions
  • Bidirectional port forwarding combining -R and -L in a single connection

For environments where you need full subnet routing or UDP support, consider WireGuard. But for TCP port forwarding to a manageable number of hosts, reverse SSH tunnels are hard to beat in simplicity and reliability.