An SSH tunnel carries arbitrary TCP traffic inside an encrypted SSH connection. It is the simplest way to reach a database or admin panel that only listens on 127.0.0.1, to expose a service running on your laptop through a public server, or to route your browser through a server you trust. In this tutorial you will use the three forwarding modes of OpenSSH (local, remote and dynamic), store tunnels in ~/.ssh/config, make one permanent with a systemd unit and restrict what forwarding users can do on the server.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS (for example a CubePath VPS) with OpenSSH server, reachable at your_server_ip.
  • A non-root user with sudo privileges on the server, called your_user in the examples.
  • SSH key authentication already working from your local machine (ssh your_user@your_server_ip logs you in without a password prompt).
  • A local machine with the OpenSSH client: any Linux distribution, macOS or Windows 10/11 (the built-in ssh in PowerShell supports all the options used here).

The examples assume a MySQL or MariaDB server listening on 127.0.0.1:3306 on the server. Substitute any other TCP service (PostgreSQL on 5432, Redis on 6379, a web panel on 8080) and the commands work the same way.

How the three forwarding modes work

ModeOptionListens onTraffic goes toTypical use
Local-LYour local machineA host and port reachable from the serverReach a private database or panel
Remote-RThe serverA host and port reachable from your local machineExpose a local dev app or a host behind NAT
Dynamic-DYour local machine (SOCKS proxy)Any destination, chosen per connectionBrowse or run tools through the server

The general syntax of a forward is [bind_address:]port:destination_host:destination_port. The destination is resolved from the far end of the tunnel, so 127.0.0.1 in a -L forward means the server's loopback, not yours.

Step 1 - Creating a local port forward

A local forward opens a port on your machine and sends every connection through SSH to a destination the server can reach. Run this on your local machine:

ssh -N -L 3307:127.0.0.1:3306 your_user@your_server_ip
  • -L 3307:127.0.0.1:3306 listens on local port 3307 and forwards to port 3306 on the server's loopback interface.
  • -N tells SSH not to run a remote command, since you only want the tunnel.

Local port 3307 is used instead of 3306 so it does not clash with a database you may run locally. The command stays in the foreground with no output while the tunnel is open.

In a second local terminal, check that the port is listening:

ss -tln 'sport = :3307'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
LISTEN  0       128          127.0.0.1:3307        0.0.0.0:*

On macOS, use lsof -nP -iTCP:3307 -sTCP:LISTEN instead. Now connect to the remote database through the tunnel:

mysql -h 127.0.0.1 -P 3307 -u db_user -p

Use 127.0.0.1 rather than localhost: the MySQL client treats localhost as a Unix socket and would ignore the port.

By default the forwarded port binds only to 127.0.0.1, so nobody else on your network can use it. Keep it that way unless you have a reason to share it.

Forwarding to a third host

The destination does not have to be the server itself. If the server can reach a database at 10.0.0.5 on a private network, forward to that address:

ssh -N -L 5433:10.0.0.5:5432 your_user@your_server_ip

Only the hop between your machine and the server is encrypted by SSH. The last hop, from the server to 10.0.0.5, travels in the clear over the private network.

Running the tunnel in the background

To get your terminal back, add -f so SSH forks into the background after authenticating, and ExitOnForwardFailure so it exits with an error instead of running without a working forward if the local port is already taken:

ssh -f -N -o ExitOnForwardFailure=yes -L 3307:127.0.0.1:3306 your_user@your_server_ip

To stop it later, find the process and kill it:

pgrep -af 'ssh -f -N'
48211 ssh -f -N -o ExitOnForwardFailure=yes -L 3307:127.0.0.1:3306 your_user@your_server_ip
kill 48211

Step 2 - Saving tunnels in the SSH client configuration

Long command lines are easy to get wrong. Define the tunnel once in ~/.ssh/config on your local machine:

nano ~/.ssh/config

Add a host block:

Host db-tunnel
    HostName your_server_ip
    User your_user
    IdentityFile ~/.ssh/id_ed25519
    LocalForward 3307 127.0.0.1:3306
    LocalForward 8081 127.0.0.1:8080
    ExitOnForwardFailure yes
    ServerAliveInterval 30
    ServerAliveCountMax 3

ServerAliveInterval and ServerAliveCountMax make the client send a keepalive every 30 seconds and give up after three unanswered ones, so a dead connection is detected in about 90 seconds instead of hanging. The file must not be writable by other users:

chmod 600 ~/.ssh/config

Open both forwards with one short command:

ssh -N db-tunnel

Step 3 - Creating a remote port forward

A remote forward works in the opposite direction: the server listens on a port and sends connections back to your local machine. This is useful to show a web app running on your laptop, or to reach a machine behind NAT that can connect out but not accept connections in.

Start a test web server on your local machine (Python 3 is enough):

python3 -m http.server 8000 --bind 127.0.0.1

In another local terminal, open the reverse tunnel:

ssh -N -R 9000:127.0.0.1:8000 your_user@your_server_ip

The server now listens on port 9000 and forwards to port 8000 on your machine. Log in to the server in a separate session and test it:

curl -I http://127.0.0.1:9000
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.12.3
Content-type: text/html; charset=utf-8

Making a remote forward reachable from the internet

By default sshd binds remote forwards to the server's loopback only, even if you ask for another address. To let clients choose, enable GatewayPorts on the server. Ubuntu's sshd_config includes every file in /etc/ssh/sshd_config.d/, so create a drop-in file:

sudo nano /etc/ssh/sshd_config.d/10-gatewayports.conf
GatewayPorts clientspecified

With clientspecified, the bind address given in -R is honoured. Check the configuration and restart SSH:

sudo sshd -t
sudo systemctl restart ssh

sshd -t prints nothing when the syntax is correct. Open the port in UFW if the firewall is enabled:

sudo ufw allow 9000/tcp

From your local machine, request the forward on all interfaces:

ssh -N -R 0.0.0.0:9000:127.0.0.1:8000 your_user@your_server_ip

On the server, confirm the listener is no longer limited to loopback:

sudo ss -tlnp 'sport = :9000'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      128          0.0.0.0:9000      0.0.0.0:*     users:(("sshd",pid=51230,fd=9))

Anyone can now open http://your_server_ip:9000. Only do this for services that are safe to expose, and close the port with sudo ufw delete allow 9000/tcp when you finish.

Step 4 - Using dynamic forwarding as a SOCKS proxy

With -D, SSH runs a SOCKS5 proxy on your machine. Every application that supports SOCKS can send connections through it, and the server opens them on its behalf, so websites see the server's IP address:

ssh -N -D 1080 your_user@your_server_ip

Check it with curl. The --socks5-hostname option also sends DNS lookups through the tunnel, which avoids leaking them to your local resolver:

curl --socks5-hostname 127.0.0.1:1080 https://ifconfig.me

The output is the public IP address of your server, not the one of your local connection.

To use the proxy in Firefox, open Settings > Network Settings, choose Manual proxy configuration, set SOCKS Host to 127.0.0.1 and Port to 1080, select SOCKS v5 and enable Proxy DNS when using SOCKS v5.

Step 5 - Reaching private hosts through a jump server

When a machine is only reachable from a bastion host, use -J (ProxyJump) instead of chaining tunnels by hand. SSH connects to the bastion, then opens an encrypted session to the target through it:

ssh -J your_user@bastion_ip [email protected]

The same option combines with forwards. This command reaches PostgreSQL on a private database host through the bastion:

ssh -N -J your_user@bastion_ip -L 5433:127.0.0.1:5432 [email protected]

In ~/.ssh/config the equivalent is:

Host private-db
    HostName 10.0.0.20
    User your_user
    ProxyJump your_user@bastion_ip
    LocalForward 5433 127.0.0.1:5432

Your private key never leaves your machine: authentication to the target is performed end to end through the bastion.

Step 6 - Keeping a tunnel up with systemd

For a tunnel that must survive reboots and network drops (for example an application server that reads a remote database), let systemd supervise ssh. systemd restarts the process when it exits, and the keepalive options make it exit when the connection dies, so a separate tool like autossh is not needed.

Create a dedicated key without a passphrase on the machine that will run the tunnel, since the service starts unattended:

ssh-keygen -t ed25519 -f ~/.ssh/tunnel_ed25519 -N '' -C 'db tunnel'
ssh-copy-id -i ~/.ssh/tunnel_ed25519.pub your_user@your_server_ip

Connect once by hand so the server's host key is stored in ~/.ssh/known_hosts. systemd cannot answer the fingerprint prompt:

ssh -i ~/.ssh/tunnel_ed25519 your_user@your_server_ip exit

Create the unit file:

sudo nano /etc/systemd/system/ssh-tunnel-db.service
[Unit]
Description=SSH tunnel to the remote MySQL server
Wants=network-online.target
After=network-online.target

[Service]
User=your_user
ExecStart=/usr/bin/ssh -N -i /home/your_user/.ssh/tunnel_ed25519 \
    -o ExitOnForwardFailure=yes \
    -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 \
    -o BatchMode=yes \
    -L 127.0.0.1:3307:127.0.0.1:3306 \
    your_user@your_server_ip
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

BatchMode=yes makes SSH fail immediately instead of waiting for a password or confirmation that nobody will type. Load and start the unit:

sudo systemctl daemon-reload
sudo systemctl enable --now ssh-tunnel-db.service

Check its state:

systemctl status ssh-tunnel-db.service
● ssh-tunnel-db.service - SSH tunnel to the remote MySQL server
     Loaded: loaded (/etc/systemd/system/ssh-tunnel-db.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-24 10:14:02 UTC; 5s ago
   Main PID: 2291 (ssh)

If it keeps restarting, read the SSH error with journalctl -u ssh-tunnel-db.service -n 20.

Step 7 - Restricting port forwarding on the server

Any user who can log in over SSH can open tunnels by default. On a shared server, or for a key that exists only to run a tunnel, limit what is allowed.

To limit a single key, prefix its line in ~/.ssh/authorized_keys on the server with options. This key can only open connections to 127.0.0.1:3306 through the tunnel and cannot open a shell:

restrict,port-forwarding,permitopen="127.0.0.1:3306",command="/bin/false" ssh-ed25519 AAAAC3Nza... db tunnel

restrict disables every optional feature (forwarding, PTY allocation, agent and X11 forwarding), port-forwarding turns TCP forwarding back on and permitopen limits the destinations a forward may reach to a single host and port. Because the tunnel runs with -N, the forced command is never executed. To also stop that user from opening remote forwards on the server, set AllowTcpForwarding local for them in a Match User block, as shown next.

To disable forwarding for a group of users server-wide, add a Match block in a drop-in file:

sudo nano /etc/ssh/sshd_config.d/20-forwarding.conf
Match Group sftponly
    AllowTcpForwarding no
    PermitTunnel no
    X11Forwarding no

Validate and restart:

sudo sshd -t && sudo systemctl restart ssh

To see which tunnels are active on the server, list the sockets owned by sshd:

sudo ss -tnp | grep sshd

Troubleshooting

  • bind [127.0.0.1]:3307: Address already in use: another process (often a previous tunnel) is using the local port. Find it with ss -tlnp 'sport = :3307' and stop it, or pick another port.
  • channel 2: open failed: connect failed: Connection refused: the tunnel works but nothing listens at the destination as seen from the server. Check with sudo ss -tlnp on the server that the service listens on the address you forward to.
  • Warning: remote port forwarding failed for listen port 9000: the port is already in use on the server, or you tried a port below 1024 as a non-root user. Choose a port above 1024.
  • Remote forward only reachable from the server: GatewayPorts is not active. Run sudo sshd -T | grep -i gatewayports to see the effective value.
  • administratively prohibited: open failed: the server forbids the forward through AllowTcpForwarding, PermitOpen or authorized_keys options.
  • Add -v (or -vvv) to any ssh command to see exactly which step fails.

Conclusion

You created local, remote and dynamic SSH tunnels, saved them in ~/.ssh/config, reached a private host through a jump server and ran a permanent tunnel as a systemd service with restricted permissions on the server. As next steps, harden the SSH server itself (disable password logins, change PermitRootLogin), add Fail2ban to block brute-force attempts, and consider WireGuard when you need to route whole networks rather than individual ports.