An SSH tunnel carries other network traffic inside an encrypted SSH connection. It’s the simplest secure way to reach a database or admin interface that isn’t exposed to the internet — no VPN, no firewall changes, nothing to install beyond SSH.
There are three kinds:
| Type | Flag | Direction | Typical use |
|---|---|---|---|
| Local | -L |
Your computer → service on/near the server | Reach a private database or admin panel |
| Remote | -R |
Server → service on your computer | Show a local dev site to someone; reach a machine behind NAT |
| Dynamic | -D |
Any destination via the server (SOCKS proxy) | Browse through the server’s network/IP |
Local forwarding (-L)
Syntax: -L [local_addr:]local_port:destination_host:destination_port
Reach MySQL that listens only on the server’s localhost:
ssh -N -L 3307:127.0.0.1:3306 [email protected]
Connect your client (DBeaver, TablePlus, mysql -h 127.0.0.1 -P 3307) to 127.0.0.1:3307. The destination 127.0.0.1 is resolved on the server, so it means the server’s own localhost.
-N— don’t open a shell; just forward.- Add
-fto send it to the background after authentication.
Reach a host on the server’s private network:
ssh -N -L 8443:10.0.0.20:443 [email protected]
Now https://localhost:8443 on your computer reaches 10.0.0.20:443 via the bastion.
Admin panels on localhost (Portainer, Nginx Proxy Manager, Netdata, phpMyAdmin bound to 127.0.0.1):
ssh -N -L 9443:127.0.0.1:9443 [email protected]
This pattern lets you keep management interfaces completely off the internet.
Remote forwarding (-R)
Syntax: -R [remote_addr:]remote_port:destination_host:destination_port
Make a service on your laptop reachable from the server:
ssh -N -R 8080:127.0.0.1:3000 [email protected]
On the server, curl http://127.0.0.1:8080 reaches port 3000 on your laptop.
By default the remote port binds to the server’s localhost only. To expose it publicly (e.g. to demo a dev site via the server’s IP), set GatewayPorts clientspecified in the server’s sshd config and bind explicitly: -R 0.0.0.0:8080:127.0.0.1:3000. Think twice — you’re publishing your laptop’s service to the internet. For regular use, a reverse proxy or a tool like frp or a Cloudflare Tunnel is more robust.
Reach a machine behind NAT (e.g. a home server): from that machine, keep a reverse tunnel to your VPS:
ssh -N -R 2222:127.0.0.1:22 [email protected]
Then from the VPS: ssh -p 2222 [email protected]. Use autossh or a systemd service to keep it alive.
Dynamic forwarding (-D): a SOCKS proxy
ssh -N -D 1080 [email protected]
Configure your browser (or curl --socks5-hostname 127.0.0.1:1080) to use the SOCKS5 proxy at 127.0.0.1:1080. All traffic goes out from the server’s IP — handy for testing how a site looks from another region, or reaching internal web interfaces by their private names. For everyday privacy, a real VPN such as WireGuard is better.
Save tunnels in ~/.ssh/config
Host db-tunnel
HostName 203.0.113.10
User deploy
LocalForward 3307 127.0.0.1:3306
LocalForward 9443 127.0.0.1:9443
ExitOnForwardFailure yes
ServerAliveInterval 30
ssh -N db-tunnel
ExitOnForwardFailure makes SSH quit if a port can’t be bound (instead of silently connecting without the tunnel). See the SSH config file guide.
Keep tunnels alive
sudo apt install autossh
autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -L 3307:127.0.0.1:3306 [email protected]
For permanent tunnels, wrap autossh in a systemd service with Restart=always.
Tunnels through a jump host
ssh -N -J alice@bastion -L 5433:127.0.0.1:5432 [email protected]
-J (ProxyJump) hops through the bastion; the forward is set up on the final host.
Security considerations
- On the server, restrict forwarding where it isn’t needed:
AllowTcpForwarding no(globally or perMatch User),PermitOpento limit destinations,GatewayPorts no(default). - Keys used only for tunnels can be limited in
authorized_keys:permitopen="127.0.0.1:3306",no-pty,command="/bin/false" ssh-ed25519 …. - Local forwards bind to
127.0.0.1by default — keep it that way (-L 0.0.0.0:…would expose the tunnel to your whole local network).
Troubleshooting
bind [127.0.0.1]:3307: Address already in use— pick another local port, or stop what’s using it.channel 2: open failed: administratively prohibited— the server disallows forwarding (AllowTcpForwarding).connect failed: Connection refused— the destination isn’t listening where you said (checkss -tlnpon the server).- Tunnel drops when idle — add
ServerAliveInterval.
Related: MySQL remote access, PostgreSQL remote connections and SSH into your VPS.