An SSH tunnel carries a network flow through an encrypted SSH connection. As a result, you reach an internal database as if it were local, you expose a development service on a public server, or you send all your traffic through a remote server. No need for a VPN, no extra port to open.
It extends the LFCS objective "Configure the OpenSSH server and client" (Networking domain). The three kinds of tunnel were replayed on Debian 13 with OpenSSH 10.0: -L and -D serve an internal web page, -R does listen on the loopback by default.
What you will learn
- Create a local tunnel (
-L) to reach a service behind a bastion - Create a remote tunnel (
-R) to make a local service reachable from outside - Create a dynamic tunnel (
-D) as a SOCKS proxy for all traffic - Use ProxyJump to go through one or several intermediate hosts
- Persist tunnels in
~/.ssh/config - Diagnose a blocked tunnel
In which context?
SSH tunnels help when the network prevents direct access: a database on a private network unreachable from outside, an internal web service only visible from a bastion, a local development environment to share temporarily with a colleague. In one command, SSH creates an encrypted channel that gets around these restrictions without changing the target server's firewall rules.
Local tunnel (-L): reach a remote service
A local tunnel forwards a port of your machine to a port on (or reachable from) the SSH server.
Syntax:
ssh -L [local_address:]local_port:target_host:target_port user@bastionExample: reach MySQL behind a bastion
You want to connect to a MySQL server on 192.168.1.100:3306, only reachable from the bastion bastion.example.com:
ssh -L 3307:192.168.1.100:3306 bob@bastion.example.comThen, from your local machine:
mysql -h 127.0.0.1 -P 3307 -u root -pMySQL answers as if it were local. The traffic travels in clear text from the MySQL client
to localhost:3307, then encrypted by SSH up to the bastion, and
in clear text again from the bastion to 192.168.1.100:3306.
Tunnel in the background
A tunnel opened in a terminal disappears as soon as that terminal closes. The
-N and -f options detach the ssh process from the current shell: control
returns immediately and the tunnel survives closing the window.
The downside: no error message is shown any more, so establish
the tunnel in the foreground first to validate that it works, then
start it again detached.
ssh -N -f -L 3307:192.168.1.100:3306 bob@bastion.example.com-N: do not open a shell, just the tunnel-f: go to the background before running
To close the tunnel:
# Find the PID, without the search counting itselfpgrep -af "ssh -N"kill <PID>Persistence in ~/.ssh/config
Typing the full forward at every connection ends up producing
port mistakes. The LocalForward directive in ~/.ssh/config freezes the
forward in the host's profile: the syntax there separates the local port and
the target with a space, where the command line joins them with a
colon. The file must be writable by no one but its
owner, otherwise OpenSSH refuses to use it; 600 is the usual
value.
Host bastion-mysql HostName bastion.example.com User bob LocalForward 3307 192.168.1.100:3306Then simply:
ssh -N bastion-mysqlRemote tunnel (-R): expose a local service
A remote tunnel forwards a port on the SSH server to a port of your local machine. It is the reverse of the local tunnel.
Syntax:
ssh -R [remote_address:]remote_port:local_host:local_port user@serverExample: share a local development server
You have a development server on localhost:8080 and want to make it reachable through server.example.com:8080:
ssh -R 8080:localhost:8080 bob@server.example.comAnyone who opens http://server.example.com:8080 sees your local server.
Dynamic tunnel (-D): SOCKS proxy
A dynamic tunnel turns the SSH connection into a SOCKS proxy. Instead of forwarding a single port to a fixed target, all the traffic sent to the proxy is routed through the SSH server, which decides the destination.
Syntax:
ssh -D 1080 bob@bastion.example.comThen configure your application to use localhost:1080 as a SOCKS5 proxy.
Chromium from the command line:
chromium --proxy-server="socks5://localhost:1080"Firefox accepts no proxy option on the command line: the setting
goes through about:preferences, Network Settings section, choosing
"Manual proxy configuration" then filling in the SOCKS host
localhost and port 1080. On that same screen, the option that delegates
DNS resolution to the SOCKS v5 proxy (preference network.proxy.socks_remote_dns)
is essential: without it, internal names are resolved by your local resolver,
which does not know them.
curl with SOCKS:
curl --socks5 localhost:1080 http://internal-service.example.com/api/statusProxyJump: go through several hosts
ProxyJump (-J) establishes the connection through one or several intermediate hosts. It is the modern method, simpler to write than ProxyCommand (which it replaces with a single option), and it is an implicit tunnel.
Simple case: bastion → target
The important point is that the final authentication happens against the target,
not against the bastion. The bastion is only a TCP relay: it only sees
end-to-end encrypted traffic between your workstation and 10.0.1.15. An
administrator of the bastion therefore cannot read your session, unlike
a chain of two ssh where the second starts from the bastion.
ssh -J bob@bastion.example.com alice@10.0.1.15Multiple hops
Hosts are declared in traversal order, separated by commas, and the last argument remains the final destination. Each hop opens a new nested session: the latency adds up at each level, and each host must know your public key. If an intermediate hop fails, the error message names the faulty host, which lets you locate the break without testing each machine by hand.
ssh -J bob@bastion.example.com,carol@10.0.1.5 alice@10.0.2.20Combine ProxyJump and a local tunnel
-J and -L combine without conflict because they act at two levels:
-J builds the access path, -L opens the forward once
there. The trap lies in the forward's localhost: it designates
10.0.2.20, the final host, not your workstation. The -N avoids opening a
useless shell on the production machine.
# Reach the database on 10.0.2.20 through two intermediate hostsssh -J bob@bastion.example.com,carol@10.0.1.5 \ -L 5432:localhost:5432 \ alice@10.0.2.20 -NIn ~/.ssh/config
The same chain written once in the configuration file replays with
ssh -N prod-db. The ProxyJump directive accepts the list of hops with the
same syntax as the command-line option, and LocalForward applies to the
final host. This form is the one to favour as soon as an access is repeated:
the tools that rely on SSH, such as scp, rsync or git, read the same
file and automatically inherit the path.
Host prod-db HostName 10.0.2.20 User alice ProxyJump bob@bastion.example.com,carol@10.0.1.5 LocalForward 5432 localhost:5432Persistent tunnels with autossh
For a tunnel that reconnects automatically if the connection is lost:
# Installationsudo apt install autossh # Debian/Ubuntusudo dnf install autossh # RHEL/Fedora
# Persistent local tunnelautossh -M 0 -N -f \ -o "ServerAliveInterval 30" \ -o "ServerAliveCountMax 3" \ -L 3307:192.168.1.100:3306 \ bob@bastion.example.com-M 0 turns off autossh's monitoring port and delegates break detection to the ServerAlive options.
Troubleshooting
The reflex that saves the most time: run the command again with -v,
or even -vvv. The output shows at which step the tunnel breaks, and the
distinction is decisive. A message emitted by the client signals a local
problem (port taken, key refused); a message prefixed channel N: comes from the
SSH server and points to a reachability problem on the remote side. Read the
table with this grid: the "Likely cause" column tells you on which side
of the tunnel to look.
| Symptom | Likely cause | Action |
|---|---|---|
bind [127.0.0.1]:9001: Address already in use | Local port already used | Change the local port or stop the process that holds it (ss -tlnp) |
| Tunnel up but connection refused | GatewayPorts no on the server side | Add GatewayPorts yes in an sshd_config.d/ drop-in (-R tunnel) |
-D tunnel: applications do not go through | SOCKS proxy wrongly configured in the app | Check that the app uses SOCKS5, not SOCKS4 or HTTP |
| Tunnel closes after a few minutes | SSH timeout on the server side | Add ServerAliveInterval 60 in ~/.ssh/config or use autossh |
channel 2: open failed: connect failed | The tunnel's target server is unreachable from the bastion | Check that 192.168.1.100:3306 is reachable from the bastion, not from your machine |
ProxyJump: Permission denied | Public key missing from the authorized_keys of a host of the chain | Deploy the public key on each host crossed; each hop authenticates from your workstation, ForwardAgent is useless |
Key points
-L local_port:target:target_port: reach a remote service as if it were local-R remote_port:target:target_port: expose a local service on the SSH server, on its loopback unlessGatewayPortssays otherwise-D port: SOCKS proxy for all of an application's traffic-N -f: tunnel without a shell, in the background- ProxyJump replaces
ProxyCommandand supports multiple hops, each authenticated from your workstation - For critical tunnels,
autosshensures automatic reconnection
Knowledge check
Check that the essentials of this guide stuck. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Write a Bash shell script: the complete guide
A new full quiz with random questions
Work again only on the questions you missed
FAQ: frequently asked questions
The most frequent questions about SSH tunnels, from choosing between -L, -R and
-D to persistence with autossh.
Three forms
| Option | Role |
|---|---|
-L |
Local tunnel: reach a remote service |
-R |
Remote tunnel: expose a local service |
-D |
Dynamic SOCKS proxy: all the traffic |
-L, local tunnel
ssh -L 3307:192.168.1.100:3306 bob@bastion.example.com
Forwards a local port to a target reachable from the SSH server. Typical use: reach a MySQL database behind a bastion.-R, remote tunnel
ssh -R 8080:localhost:8080 bob@server.example.com
Forwards a port of the server to your machine. Typical use: expose a local development server.-D, SOCKS proxy
ssh -D 1080 bob@bastion.example.com
Opens a SOCKS5 proxy on localhost:1080. Any application configured for this proxy goes out through the SSH server.-J) replaces ProxyCommand to go through a bastion.ssh -J bob@bastion.example.com alice@10.0.1.15
Why ProxyJump
- The final authentication happens against the target, not against the bastion.
- The bastion is only a TCP relay: it only sees end-to-end encrypted traffic.
- The syntax is shorter than
ProxyCommandand supports multiple hops (hosts separated by commas).
ProxyCommand remains useful for exotic cases (encapsulation through a third-party binary), but for a simple bounce, ProxyJump is the reference.The nuance that matters
With an-L tunnel, the traffic travels:- in clear text from the client to
localhost; - encrypted by SSH up to the bastion;
- in clear text again from the bastion to the target.
AllowTcpForwarding and PermitOpen.Automatic reconnection with autossh
autossh -M 0 -N -f \
-o "ServerAliveInterval 30" \
-o "ServerAliveCountMax 3" \
-L 3307:192.168.1.100:3306 \
bob@bastion.example.com
-M 0 turns off the monitoring port and delegates break detection to the ServerAlive options.Forward frozen in ~/.ssh/config
Host bastion-mysql
HostName bastion.example.com
User bob
LocalForward 3307 192.168.1.100:3306
The file must be in 600 (writable by its owner only), otherwise OpenSSH refuses it. Then: ssh -N bastion-mysql.Start detached
ssh -N -f -L 3307:192.168.1.100:3306 bob@bastion.example.com
-N: no shell, just the tunnel;-f: moved to the background.
-f hides error messages, establish the tunnel in the foreground first to check that it works, then start it again detached.Close
pgrep -af "ssh -N"
kill <PID>
The tunnel also disappears automatically if you started it in a terminal that you close (without -f).| Criterion | SSH tunnel | VPN |
|---|---|---|
| Setup | One command | Server + clients to configure |
| Scope | One or a few services | A whole network |
| Duration | Occasional | Permanent |
| Inbound port | None (existing port 22) | Often a dedicated port |