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
| Flag | Purpose |
|---|---|
-N | No remote command execution - tunnel only |
-T | No pseudo-terminal allocation |
-R localhost:21022:localhost:22 | Bind port 21022 on the bastion, forward to local port 22 |
-o ServerAliveInterval=60 | Send keep-alive every 60 seconds to detect dead connections |
-o ServerAliveCountMax=3 | Disconnect after 3 missed keep-alives (3 minutes) |
-o ExitOnForwardFailure=yes | Exit immediately if the port binding fails (e.g., port already in use) |
-i /etc/ssh/ssh_host_ed25519_key | Authenticate 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.
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
| Option | Effect |
|---|---|
restrict | Disables all capabilities (PTY, forwarding, agent, X11, user-rc) |
port-forwarding | Re-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 |
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:
| Host | Bastion Port | Local Port |
|---|---|---|
| web-server-1 | 21022 | 22 |
| db-server-1 | 21023 | 22 |
| iot-gateway | 21024 | 22 |
| monitoring | 21025 | 22 |
Reserve port ranges by purpose:
21000-21999- reverse SSH tunnels to port 2222000-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]:
| Flag | Listens on | Forwards to | Example |
|---|---|---|---|
-R | Remote side | Local side | -R localhost:21022:localhost:22 |
-L | Local side | Remote 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
| Flag | Direction | What it does |
|---|---|---|
-R localhost:21022:localhost:22 | bastion -> remote | SSH access to the remote host |
-R localhost:21080:localhost:80 | bastion -> remote | HTTP on the remote host |
-R localhost:21443:localhost:443 | bastion -> remote | HTTPS on the remote host |
-L localhost:4505:localhost:4505 | remote -> bastion | Salt master publish port |
-L localhost:4506:localhost:4506 | remote -> bastion | Salt master request port |
-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
-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.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_keysrestrictions (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
-Rand-Lin 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.