Short answer: an SSH tunnel carries another network connection inside an encrypted SSH session. You connect to a server you can already log in to, and SSH forwards a port through that connection: ssh -L brings a port of the server (or of a host behind it) to your computer, ssh -R does the reverse and publishes a port of your computer on the server, and ssh -D turns the connection into a SOCKS proxy. Nothing but the SSH port has to be open in the firewall.
# local: your 127.0.0.1:3307 -> the server's 127.0.0.1:3306
ssh -N -L 3307:127.0.0.1:3306 you@203.0.113.10
# remote: the server's 127.0.0.1:8080 -> your 127.0.0.1:3000
ssh -N -R 8080:127.0.0.1:3000 you@203.0.113.10
# dynamic: a SOCKS proxy on your 127.0.0.1:1080, exiting at the server
ssh -N -D 1080 you@203.0.113.10What is an SSH tunnel?
An SSH connection is an encrypted channel between your computer and a server, usually on TCP port 22. Besides a shell, the protocol can carry other TCP connections through that same channel. That is port forwarding, and the forwarded connection is what people call an SSH tunnel: an application talks to a port on one end, and SSH delivers the bytes to a port on the other end.
Three things follow from that, and they explain why tunnels are so common on a VPS:
- A service can stay on localhost. A database, an admin panel or a remote desktop that listens on
127.0.0.1only cannot be reached from the internet at all, yet you can use it from your own machine through the tunnel. - The traffic is encrypted between your computer and the server, even if the protocol inside (plain HTTP, VNC, an old database driver) is not.
- Only the SSH port is exposed. Your firewall keeps everything else closed, and access to the service is as strong as your SSH login — keys rather than passwords, as in our SSH hardening guide.
The tunnel only exists while the SSH session does, and only for the port you forwarded. It is not a VPN: the rest of your computer’s traffic is not affected.
Local port forwarding with ssh -L
Local forwarding is the one you will use most. It opens a port on your computer, and whatever connects to it is carried to the server and handed on from there:
ssh -L [bind_address:]local_port:destination:destination_port user@serverThe important detail is that destination is resolved on the server. So 127.0.0.1 means the server itself, and a private address such as 10.0.0.5 means a machine the server can reach and you cannot. A MariaDB server that listens on the VPS’s loopback interface only:
ssh -N -L 3307:127.0.0.1:3306 you@203.0.113.10
# in a second terminal, or in your database client:
mysql -h 127.0.0.1 -P 3307 -u appuser -p-Ntells ssh not to start a shell: the session exists only for the tunnel. Add-fto send it to the background once you have logged in.- Port
3307on your side is arbitrary. Pick one that is free; reusing3306fails if a database runs on your computer too. - Use
127.0.0.1rather thanlocalhostin the client. Many MySQL and MariaDB clients treatlocalhostas “use the local socket file” and never touch the tunnel. - The forwarded port listens on your loopback interface only. Other machines on your network can use it only if you name a bind address in front (
-L 0.0.0.0:3307:…) — rarely what you want.
What people tunnel to on a VPS
| Service | Port on the server | Example | Guide |
|---|---|---|---|
| MariaDB / MySQL | 3306 | -L 3307:127.0.0.1:3306 | Install MariaDB or MySQL |
| PostgreSQL | 5432 | -L 5433:127.0.0.1:5432 | Install PostgreSQL |
| phpMyAdmin | 80 | -L 8080:127.0.0.1:80 | phpMyAdmin, SSH-only |
| Windows Remote Desktop | 3389 | -L 13389:127.0.0.1:3389 | RDP behind SSH |
| xrdp (Linux desktop) | 3389 | -L 3390:127.0.0.1:3389 | xrdp on Ubuntu and Debian |
| VNC | 5901 | -L 5901:127.0.0.1:5901 | RDP vs VNC |
| Monero node RPC | 18081 | -L 18081:127.0.0.1:18081 | Monero node on a VPS |
| FiveM txAdmin | 40120 | -L 40120:127.0.0.1:40120 | FiveM server |
| Rust RCON | 28016 | -L 28016:127.0.0.1:28016 | Rust server hosting |
| 7 Days to Die web dashboard | 8080 | -L 8080:127.0.0.1:8080 | 7 Days to Die server |
You can forward several ports in one session by repeating -L, for example -L 3307:127.0.0.1:3306 -L 8080:127.0.0.1:80.
A shell on a machine behind the server: ssh -J
When you want to log in to a host that only the server can reach, not just use one of its ports, let SSH hop through the server with -J (ProxyJump, OpenSSH 7.3 and later). The inner connection is encrypted end to end inside the outer one, and your private key never leaves your computer:
ssh -J you@203.0.113.10 admin@10.0.0.5In ~/.ssh/config, the same hop is a ProxyJump you@203.0.113.10 line under the inner host. It rides on the same mechanism as -L, so the jump server has to allow local forwarding (see what the server has to allow).
Remote port forwarding with ssh -R (reverse SSH tunnel)
Remote forwarding turns the direction around. The server opens a port, and connections to it travel back through the session to your computer — or to a machine your computer can reach:
ssh -R [bind_address:]server_port:destination:destination_port user@serverHere destination is resolved on your side. Two typical uses: reaching a machine that sits behind NAT or a firewall that allows no incoming connections, and showing a web app that runs on your laptop to someone else.
# publish this machine's SSH (port 22) as port 2222 on the VPS
ssh -N -R 2222:127.0.0.1:22 you@203.0.113.10
# later, on the VPS:
ssh -p 2222 user@127.0.0.1By default sshd binds a remote forward to the server’s loopback interface, so only people logged in to the server can use it. To make the port reachable from outside, the server needs GatewayPorts set in /etc/ssh/sshd_config: clientspecified lets the client choose with -R 0.0.0.0:8080:127.0.0.1:3000, yes binds every remote forward to all interfaces. Open that port in the server’s firewall too, and think about who can reach it: whatever you publish this way is on the internet.
Dynamic port forwarding: ssh -D as a SOCKS proxy
With -D, SSH does not forward one fixed port. It runs a SOCKS proxy on your computer, and every connection an application makes through that proxy leaves from the server, to whatever address the application asked for:
ssh -N -D 1080 you@203.0.113.10
# test it: the address shown is the server's, not yours
curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.mePoint a browser at the proxy — in Firefox under Settings, Network Settings, “Manual proxy configuration”, SOCKS host 127.0.0.1, port 1080, SOCKS v5 — and tick “Proxy DNS when using SOCKS v5” so that name lookups go through the tunnel as well. In curl, --socks5-hostname does the same, where --socks5 would resolve names locally. With the proxy set, What is my IP should show the server’s address instead of yours.
Put tunnels in ~/.ssh/config
Typing forwarding options every time gets old. The same three kinds have config directives, so a short host alias opens the whole set:
Host db-tunnel
HostName 203.0.113.10
User you
IdentityFile ~/.ssh/id_ed25519
LocalForward 3307 127.0.0.1:3306
LocalForward 8080 127.0.0.1:80
# RemoteForward 2222 127.0.0.1:22
# DynamicForward 1080
ServerAliveInterval 30
ServerAliveCountMax 3
ExitOnForwardFailure yesThen ssh -N db-tunnel is all it takes. If your server listens on a port other than 22, add a Port line — our SSH port guide shows how to find out which one it uses.
To check what an alias will forward before you rely on it, let ssh print its resolved configuration. Nothing connects:
ssh -G db-tunnel | grep -E '^(localforward|exitonforwardfailure) 'exitonforwardfailure yes
localforward 3307 [127.0.0.1]:3306
localforward 8080 [127.0.0.1]:80Keep a tunnel open: ServerAliveInterval, autossh and systemd
An idle tunnel dies when a NAT router or a firewall forgets the connection. ServerAliveInterval 30 sends a keep-alive through the encrypted channel every 30 seconds, and ServerAliveCountMax 3 ends the session after three unanswered ones instead of leaving a dead tunnel in place. ExitOnForwardFailure yes makes ssh quit when it cannot set up the forward at all, rather than sitting there without it.
For a tunnel that has to come back on its own after a network outage, use autossh. It starts ssh and restarts it whenever it exits:
sudo apt install -y autossh
autossh -M 0 -N \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes \
-L 3307:127.0.0.1:3306 you@203.0.113.10-M 0 switches off autossh’s own monitoring port and lets the keep-alives above detect a dead connection. A tunnel that has to survive a reboot belongs in a systemd unit, with a key that needs no passphrase and is used for nothing else:
[Unit]
Description=SSH tunnel to the database server
After=network-online.target
Wants=network-online.target
[Service]
User=tunnel
Environment=AUTOSSH_GATETIME=0
ExecStart=/usr/bin/autossh -M 0 -N \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes -o StrictHostKeyChecking=yes \
-i /home/tunnel/.ssh/id_ed25519 \
-L 3307:127.0.0.1:3306 tunnel@203.0.113.10
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now db-tunnel
systemctl status db-tunnelAUTOSSH_GATETIME=0 keeps autossh retrying even if the very first connection fails, which is what you want at boot. Create the key as that user, install it on the server with ssh-copy-id, and log in once by hand, so the server’s host key is in the user’s known_hosts. More on writing units in our systemd service guide.
Stop a tunnel running in the background
A tunnel in the foreground ends with Ctrl+C. One started with -f has no window to close. On Linux and macOS, give it a control socket when you start it:
ssh -f -N -M -S ~/.ssh/db-tunnel.sock -L 3307:127.0.0.1:3306 you@203.0.113.10-M makes this ssh the master of the socket named by -S. Through that socket you can ask whether the tunnel is still up, and end it:
ssh -S ~/.ssh/db-tunnel.sock -O check you@203.0.113.10
ssh -S ~/.ssh/db-tunnel.sock -O exit you@203.0.113.10The first answers Master running (pid=…), the second Exit request sent. A tunnel started without a socket is an ordinary process: pgrep -af 'ssh.*-L 3307' shows its process ID for kill.
SSH tunnels on Windows: OpenSSH and PuTTY
Windows 10 and 11 ship the OpenSSH client, so every command on this page works as it is in PowerShell or the command prompt: ssh -N -L 3307:127.0.0.1:3306 you@203.0.113.10. Your config file lives in C:\Users\you\.ssh\config.
In PuTTY, a tunnel is part of a saved session:
- Enter the server under Session as usual.
- Go to Connection → SSH → Tunnels. For a local forward, type the local port under Source port (
3307) and127.0.0.1:3306under Destination, leave Local selected and click Add. For a reverse tunnel pick Remote; for a SOCKS proxy pick Dynamic and leave the destination empty. - Back under Session, click Save, then Open. The tunnel lives as long as that PuTTY window.
PuTTY’s command-line companion does the same without a window: plink -N -L 3307:127.0.0.1:3306 you@203.0.113.10.
What the server has to allow
On a stock OpenSSH server, local and remote forwarding are allowed and remote forwards bind to loopback. These sshd_config settings change that:
| Setting | Default | What it does |
|---|---|---|
| AllowTcpForwarding | yes | yes / all, local, remote or no: which kinds of forwarding users may open |
| GatewayPorts | no | Whether remote forwards may listen on other addresses than loopback (yes, clientspecified) |
| PermitOpen | any | Destinations a local forward may connect to, e.g. 127.0.0.1:3306 |
| PermitListen | any | Ports a remote forward may listen on (OpenSSH 7.8 and later) |
For a user that exists only to hold a tunnel — the tunnel user of the systemd example — lock the account down to exactly that:
Match User tunnel
AllowTcpForwarding local
PermitOpen 127.0.0.1:3306
X11Forwarding no
AllowAgentForwarding no
PermitTTY no
ForceCommand /bin/falsesudo sshd -t && sudo systemctl reload ssh # the unit is sshd on Rocky and AlmaWhen the tunnel does not work
bind [127.0.0.1]:3307: Address already in use— something on your computer already listens on that local port, often an earlier tunnel. Pick another port, or find the process as in Address already in use.channel 2: open failed: connect failed: Connection refused— the tunnel is up, but nothing on the server listens at the destination you gave. Check the address and port withsudo ss -tlnpon the server: a database bound to the public IP only is not reachable at127.0.0.1.open failed: administratively prohibited: open failed— the server’sAllowTcpForwardingorPermitOpenforbids this forward.Warning: remote port forwarding failed for listen port 8080— that port is already taken on the server, orPermitListendoes not allow it.- The tunnel opens but every page stays blank — you connected the client to the wrong end. With
-Lthe client talks to127.0.0.1on your computer, on the local port. - No SSH connection at all — that is not a tunnel problem yet. Start with SSH connection refused.
Frequently asked
What is an SSH tunnel?
A network connection carried inside an encrypted SSH session. SSH forwards a port through the session: with ssh -L a port of the server comes to your computer, with ssh -R a port of your computer appears on the server, and with ssh -D the session becomes a SOCKS proxy. Only the SSH port has to be open in the firewall.
What is the difference between ssh -L and ssh -R?
ssh -L opens the port on your computer and connects to the destination from the server's side — the way to reach a database or an admin panel that listens only on the server. ssh -R opens the port on the server and connects back to your side — the way to reach a machine behind NAT or to show someone an app running on your laptop.
Is an SSH tunnel a VPN?
No. A tunnel forwards the ports you name, and ssh -D proxies only the applications you point at it. A VPN such as WireGuard routes all of a device's traffic. For one database or one admin panel a tunnel is simpler; for everything a device sends, use a VPN.
Why does my SSH tunnel close after a few minutes?
A router or firewall on the way drops the idle connection. Add -o ServerAliveInterval=30 -o ServerAliveCountMax=3 so that SSH sends keep-alives, and run a tunnel that must come back on its own under autossh or a systemd service.