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
sudoprivileges on the server, calledyour_userin the examples. - SSH key authentication already working from your local machine (
ssh your_user@your_server_iplogs you in without a password prompt). - A local machine with the OpenSSH client: any Linux distribution, macOS or Windows 10/11 (the built-in
sshin 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
| Mode | Option | Listens on | Traffic goes to | Typical use |
|---|---|---|---|---|
| Local | -L | Your local machine | A host and port reachable from the server | Reach a private database or panel |
| Remote | -R | The server | A host and port reachable from your local machine | Expose a local dev app or a host behind NAT |
| Dynamic | -D | Your local machine (SOCKS proxy) | Any destination, chosen per connection | Browse 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:3306listens on local port 3307 and forwards to port 3306 on the server's loopback interface.-Ntells 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 withss -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 withsudo ss -tlnpon 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:
GatewayPortsis not active. Runsudo sshd -T | grep -i gatewayportsto see the effective value. administratively prohibited: open failed: the server forbids the forward throughAllowTcpForwarding,PermitOpenorauthorized_keysoptions.- Add
-v(or-vvv) to anysshcommand 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.
