Security

How to Create SSH Tunnels on Linux (Local, Remote, Dynamic)

An SSH tunnel moves a network port from one machine to another inside an encrypted SSH session. No extra daemon, no VPN client, nothing to install. If you can SSH to a host, you can already tunnel through it. That is why tunnels are the fastest way to reach a database bound to localhost, browse out through a remote network, or hand a colleague access to an app running only on your laptop.

Original content from computingforgeeks.com - post 4301

This guide covers the three kinds of SSH tunnel on Linux from the command line: local forwarding (ssh -L), remote forwarding (ssh -R), and dynamic SOCKS forwarding (ssh -D). It then shows how to background tunnels properly, define them once in ~/.ssh/config, chain through a jump host with ProxyJump, keep them alive with autossh and systemd, and where the dedicated tunneling tools (sshuttle, Cloudflare Tunnel, Rathole) fit. Everything uses the OpenSSH client that already ships with every Linux distribution.

Every command below was run on Ubuntu 24.04 with OpenSSH 9.6 in June 2026, across a three-host lab (a client, a bastion, and a firewalled internal host). The same commands work on any current Linux with a stock OpenSSH client.

The three kinds of SSH tunnel

SSH forwards ports in one of three directions. Pick the one that matches where the service lives and where you want to reach it from.

TypeFlagTraffic flowsUse it to
Local-Lyour machine to the remotereach a service that the remote host can see (often one bound to its localhost)
Remote-Rthe remote to your machineexpose a service running on your machine to the remote side
Dynamic-Dyour machine to anywhere, via the remotea SOCKS proxy that routes arbitrary traffic out through the remote host

The flags share a grammar. A forward is a local port, a destination host, and a destination port, and SSH carries the bytes between them over the existing encrypted channel. The official ssh(1) manual documents every option referenced here.

What you need

  • A Linux machine with the OpenSSH client (run ssh -V to confirm)
  • SSH access to a remote host, ideally with SSH key authentication so tunnels reconnect without a password prompt
  • On the remote host, the default sshd setting AllowTcpForwarding yes (it is on by default on most distributions)

If the remote does not yet accept SSH at all, start with a basic SSH server install first, then come back here.

Set reusable shell variables

The remote user and host appear in almost every command on this page. Export them once so you change one block and paste the rest as-is. Swap the values for your real login:

export SSH_USER="jmutai"
export SSH_HOST="bastion.example.com"

Confirm they are set before running anything else:

echo "Tunnelling through ${SSH_USER}@${SSH_HOST}"

The values hold for the current shell session only. Re-run the export lines if you reconnect or open a new terminal.

Local port forwarding with ssh -L

Local forwarding pulls a remote service onto a port on your own machine. The classic case is a database or cache that listens only on the server’s 127.0.0.1, so it is invisible from the network. You forward a local port to that loopback address through SSH and connect as if the service were local.

The syntax is -L [bind_address:]local_port:destination_host:destination_port. The destination is resolved on the server side, which is the part that trips people up: localhost in the command means the remote host’s localhost, not yours. Forward your local 6379 to the remote’s Redis:

ssh -L 6379:localhost:6379 "${SSH_USER}@${SSH_HOST}"

That opens a normal interactive shell with the tunnel attached, which is fine for a quick check. Leave the session open and, in another terminal, the remote Redis now answers on your own localhost:

redis-cli -p 6379 ping

The cache replies through the encrypted channel, even though it never listened on the network:

PONG

The screenshot below shows the full sequence: a direct hit is refused because Redis only listens on the server’s loopback, then the same query succeeds once the tunnel is up.

SSH local port forwarding with ssh -L reaching a localhost-only Redis on Linux

The same pattern reaches any localhost-bound service. For PostgreSQL use -L 5432:localhost:5432, for MySQL -L 3306:localhost:3306, for a private admin panel -L 8080:localhost:80. You can also forward to a third host the server can route to but you cannot, for example a database server sitting behind the bastion:

ssh -L 5432:db1.internal:5432 "${SSH_USER}@${SSH_HOST}"

Here SSH connects to the bastion, then opens a connection from the bastion onward to db1.internal:5432 and ties it back to your local 5432.

Remote port forwarding with ssh -R

Remote forwarding is the mirror image. It takes a service running on your machine and exposes it on a port of the remote host. This is how you share a local development app with a server that has a public address, or punch a service out from behind NAT without touching the router.

Say a dev app is running on your laptop, bound to localhost:8000. Expose it on the server’s port 8080:

ssh -R 8080:localhost:8000 "${SSH_USER}@${SSH_HOST}"

With the session open, anyone logged in to the server can now reach your laptop’s app on its localhost:

curl http://localhost:8080

The request travels back down the SSH connection to your machine and returns the page your laptop served. By default sshd binds remote forwards to the server’s loopback only, so the exposed port is reachable from the server itself, not the whole internet. To make a remote forward listen on the server’s public interface, set GatewayPorts clientspecified (or yes) in the server’s sshd_config and prefix the bind address, for example -R 0.0.0.0:8080:localhost:8000. Do that deliberately, because it opens your local app to anything that can reach the server.

Dynamic forwarding: a SOCKS proxy with ssh -D

Dynamic forwarding turns SSH into a SOCKS5 proxy. Instead of forwarding one fixed destination, it accepts connections to many hosts and ports and sends them all out through the remote. Point a browser or any SOCKS-aware client at it and your traffic exits from the server’s network, which is how people reach internal sites or browse from a remote vantage point.

Open a SOCKS proxy on local port 1080:

ssh -D 1080 "${SSH_USER}@${SSH_HOST}"

Now send a request through the proxy with curl. Using --socks5-hostname (rather than --socks5) resolves DNS on the server side too, so internal hostnames the server knows about resolve correctly:

curl --socks5-hostname localhost:1080 http://internal-app.local/

In the test below, the internal host is firewalled off from the client and a direct request times out. The same request through the SOCKS proxy reaches it, because the traffic exits from the bastion, which is allowed in.

SSH dynamic SOCKS proxy with ssh -D reaching a firewalled host on Linux

For a browser, set the SOCKS host to 127.0.0.1 and the port to 1080 in the network settings, and enable remote DNS so lookups happen at the server. Firefox exposes this directly; Chrome takes the proxy from the system or a launch flag.

Run tunnels in the background and keep them alive

An interactive shell is wrong for a tunnel you just want running. Two flags fix that. -N says do not run a remote command (no shell, just the forward), and -f sends SSH to the background after authentication. Combine them with any forward:

ssh -f -N -L 6379:localhost:6379 "${SSH_USER}@${SSH_HOST}"

The command returns immediately and the tunnel keeps running. Find it and close it later by matching the forward:

pkill -f "ssh -f -N -L 6379:localhost:6379"

A background tunnel still dies on a flaky link or an idle timeout. Three options make it sturdier, all of which also belong in ~/.ssh/config covered next:

  • -o ServerAliveInterval=30 sends a keepalive every 30 seconds so a NAT or firewall does not drop an idle connection
  • -o ServerAliveCountMax=3 tears the connection down after three missed keepalives instead of hanging
  • -o ExitOnForwardFailure=yes makes SSH exit if the forward cannot bind, instead of sitting there connected but useless

Put together, a background tunnel that survives idle links and binds cleanly looks like this:

ssh -f -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    -L 6379:localhost:6379 "${SSH_USER}@${SSH_HOST}"

Define tunnels once in ~/.ssh/config

Typing flag soup every time is error-prone. Move the forwards into ~/.ssh/config and they come up with a single short command. Each Host block can carry one or more LocalForward, RemoteForward, and DynamicForward lines:

Host bastion
    HostName bastion.example.com
    User jmutai
    LocalForward 6379 localhost:6379
    DynamicForward 1080
    ServerAliveInterval 30
    ExitOnForwardFailure yes

Now both the Redis forward and the SOCKS proxy come up with one word, no flags:

ssh -f -N bastion

The same config file is where you wire up a jump host. To reach an internal box that only the bastion can talk to, give it a ProxyJump:

Host db1
    HostName db1.internal
    User jmutai
    ProxyJump bastion

A plain ssh db1 now hops through the bastion automatically, and any forwards you add to the db1 block tunnel all the way to the internal host. The ssh_config options used here are described in the ssh_config(5) manual.

You do not need a config entry for a one-off jump. The -J flag does the same thing inline:

ssh -J "${SSH_USER}@${SSH_HOST}" [email protected]

Persistent tunnels with autossh and systemd

Plain SSH does not come back on its own. When the connection drops, the tunnel is gone until you restart it. autossh wraps ssh, watches the connection, and relaunches it after a failure. Install it from your distribution’s repositories:

sudo apt install autossh                  # Debian / Ubuntu
sudo dnf install autossh                  # Fedora
sudo dnf install epel-release autossh     # Rocky / AlmaLinux (autossh lives in EPEL)

Run the same forward through autossh. The -M 0 disables its legacy monitoring port and relies on SSH’s own keepalives instead, which is the recommended setup:

autossh -M 0 -f -N \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -L 6379:localhost:6379 "${SSH_USER}@${SSH_HOST}"

For a tunnel that must survive reboots, hand it to systemd. Create a unit at /etc/systemd/system/redis-tunnel.service:

[Unit]
Description=Persistent SSH tunnel to redis on the bastion
After=network-online.target
Wants=network-online.target

[Service]
User=jmutai
Environment=AUTOSSH_GATETIME=0
ExecStart=/usr/bin/autossh -M 0 -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -L 6379:localhost:6379 [email protected]
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

The AUTOSSH_GATETIME=0 tells autossh to keep retrying even if the first connection fails, which matters when the unit starts before the network is fully up. Reload systemd, then enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now redis-tunnel.service

Confirm it is running and that the forward answers. The service stays up across reboots and reconnects on its own after a network blip:

Persistent SSH tunnel with autossh under systemd active on Linux

The unit runs as a dedicated user, so make sure that user has a key-based login to the remote host. A password prompt has nowhere to go inside a systemd service and the tunnel will fail silently.

Tunneling tools beyond raw SSH

Native SSH forwarding handles a port or two cleanly. When you need a whole subnet, a public endpoint, or many services at once, a dedicated tool is less fiddly. Most still ride on SSH or solve the same problem from a different angle.

ToolWhat it adds over ssh -L/-R/-DReach for it when
autosshauto-reconnect around a plain SSH tunnela forward must stay up unattended
sshuttleroutes whole subnets through SSH, VPN-style, no per-port flagsyou need many hosts behind one server, not single ports
Cloudflare Tunneloutbound-only tunnel to Cloudflare’s edge, no inbound port openedexposing a service publicly without touching the firewall
Rathole / frphigh-throughput reverse tunnel through a public relaypublishing many services from behind strict NAT
WireGuard / Tailscalea real mesh VPN, not SSH-basedpersistent networking across many machines
Molea thin wrapper over ssh -L (unmaintained since 2021)legacy setups only; new work should use native ssh

sshuttle is the standout for daily use. It builds a poor-man’s VPN over an ordinary SSH login, needing only Python on the server and no extra package there. Route an entire subnet through the bastion in one command:

sshuttle -r "${SSH_USER}@${SSH_HOST}" 10.10.0.0/24

While it runs, every address in 10.10.0.0/24 is reachable from your machine as if you were on that network, no SOCKS settings and no per-service forwards. It is the right tool when “I need to reach the internal network”, not “I need one port”.

For exposing a service to the public internet, the calculus is different. A reverse SSH tunnel works, but a relay-based tool is sturdier: see the Rathole reverse proxy for NAT traversal, or Cloudflare Tunnel when you want a hostname and TLS without opening a port. And if the real goal is persistent connectivity between machines rather than a single forward, a mesh VPN like Tailscale or a classic WireGuard VPN is a better fit than keeping tunnels alive by hand. For locked-down multi-user access to a fleet, a managed gateway such as an SSH bastion with Warpgate adds audit and policy on top.

Troubleshooting common tunnel errors

bind: Address already in use

The local port you asked SSH to open is already taken, usually by an earlier tunnel that is still running. The full error reads:

bind [127.0.0.1]:6379: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 6379
Could not request local forwarding.

Find what holds the port, then kill the stale tunnel or pick a different local port:

ss -tlnp | grep 6379
pkill -f "ssh.*6379:localhost:6379"

channel N: open failed: administratively prohibited

The connection succeeds but the forward refuses to carry traffic, and clients on the forwarded port see Connection reset by peer. On the SSH side you get:

channel 2: open failed: administratively prohibited: open failed

The server forbids forwarding. Its sshd_config has AllowTcpForwarding no, or a PermitOpen line that does not allow your destination. Fix it on the server by setting AllowTcpForwarding yes (or the more restrictive local / remote value you actually need) and reloading sshd. This is a server policy, not something the client can override.

The tunnel drops after a few minutes

An idle SSH connection gets reaped by a NAT or firewall along the path. Add ServerAliveInterval 30 to the client (or the host’s config block) so the connection never sits silent long enough to be dropped, and use autossh for anything that must stay up regardless.

For everyday work the decision is simple. Reach for ssh -L, -R, or -D when you want one or two ports right now, move the forwards into ~/.ssh/config once they stick around, wrap them in autossh and systemd when they must never go down, and step up to sshuttle, Cloudflare Tunnel, or a mesh VPN when a single forward is no longer enough. Keep the SSH commands cheat sheet handy for the flags around all of this.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Security UFW Firewall Commands with Examples on Ubuntu 24.04 / 22.04 Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Debian Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Install Kali Linux 2026.1 Step by Step [Full Guide] Security Install Kali Linux 2026.1 Step by Step [Full Guide] How Security Awareness Training for Employees Reduces Human Cyber Risk Security How Security Awareness Training for Employees Reduces Human Cyber Risk How Organizations Can Turn Security Findings Into Actionable Improvements Security How Organizations Can Turn Security Findings Into Actionable Improvements Secure Plex and Kodi Server using Let’s Encrypt SSL Security Secure Plex and Kodi Server using Let’s Encrypt SSL

Leave a Comment

Press ESC to close