Your database listens on localhost:5432 on a server, which is exactly how it should be. Now you need to open it in a GUI client on your laptop, and the options look bad: open port 5432 in the firewall, install a VPN for one query, or copy a dump around. SSH port forwarding is the fourth option, and it's usually the right one. You already have an SSH connection to the server; port forwarding carries other TCP traffic through it.
This guide covers the three kinds of SSH port forwarding (local -L, remote -R, dynamic -D), how to keep tunnels in ~/.ssh/config so you stop retyping them, how to combine them with a jump host, and the failure modes that make a tunnel silently not work. Every command and error message below was run on OpenSSH 9.6 against a throwaway sshd on localhost while writing this.
Quick Summary: What You'll Learn
- What SSH port forwarding is, and which of
-L,-Rand-Dyou need. - A local forward to reach a database or admin panel that only listens on the server.
- A remote forward to expose a port from your machine on the server, and why it binds to loopback by default.
- A SOCKS proxy with
-Dfor reaching many internal hosts at once. -
LocalForward,RemoteForwardandExitOnForwardFailurein~/.ssh/config, including through a jump host. - The exact messages you see when a port is taken or the server forbids forwarding.
What is SSH port forwarding?
SSH port forwarding makes one end of an SSH connection listen on a TCP port and send whatever connects to it through the encrypted connection, where the other end opens a new connection to a destination you choose. The application on either side talks plain TCP and doesn't know SSH is involved.
There are three forms, and the letter tells you where the listening port lives:
-
Local forward,
-L: the port opens on your machine (the client). Connections go to the server, and the server connects onward. Use it to reach something that only the server can reach. -
Remote forward,
-R: the port opens on the server. Connections come back to your machine, and your machine connects onward. Use it to let the server reach something only you can reach. -
Dynamic forward,
-D: the port opens on your machine as a SOCKS proxy. Each connection says where it wants to go, and the server connects there. Use it when you need many destinations behind the server, not one.
The flag everyone pairs with these is -N: "Do not execute a remote command. This is useful for just forwarding ports." Without it, you also get a shell, which is fine for one-off use but awkward for a tunnel you want to leave running.
How do I forward a remote port to my local machine with -L?
This is the "ssh port forwarding from remote to local" case: a service listens on the server's loopback interface, and you want it on a port on your laptop.
ssh -N -L 5433:localhost:5432 deploy@db.example.com
Read the argument as local_port:destination_host:destination_port. Your machine listens on 5433. When something connects to it, the connection is carried to db.example.com, and the server connects to localhost:5432. In a second terminal:
psql -h localhost -p 5433 -U app appdb
Two details catch people out.
localhost in the middle is resolved on the server, not on your laptop. The destination is looked up from the server's side. That's what makes this useful: you can also point it at a host only the server can see, for example a managed database on a private network:
ssh -N -L 5433:10.0.12.7:5432 deploy@bastion.example.com
The local port binds to loopback by default. In our test, -L 9000:localhost:8080 produced listeners on 127.0.0.1:9000 and [::1]:9000 only, so other machines on your network can't use your tunnel. The man page says the local port is bound according to GatewayPorts, which defaults to no. You can override it with a bind address (-L 0.0.0.0:5433:localhost:5432 or -g), but think twice: anyone who can reach your laptop then reaches the server's database.
Ports below 1024 on your side need root, so pick something like 5433, 8443 or 9000 instead of 443.
How do I forward multiple ports at once?
Repeat the flag. One SSH connection can carry as many forwards as you like:
ssh -N \
-L 5433:localhost:5432 \
-L 6380:localhost:6379 \
-L 9090:localhost:9090 \
deploy@app.example.com
That gives you Postgres, Redis and a Prometheus UI on three local ports over a single authenticated connection.
How does remote port forwarding (-R) work?
A remote forward is the mirror image. The server listens, and connections travel back to your machine:
ssh -N -R 8080:localhost:3000 deploy@server.example.com
Now anything on the server that connects to port 8080 reaches localhost:3000 on your laptop: a dev server, a webhook receiver you're debugging, a service you want a teammate on the box to try. In our test, a curl on the server side to the forwarded port returned the page served on the client.
Why can't anyone else reach my -R port?
By default, sshd binds remote forwards to the server's loopback address. The -R documentation is explicit: "By default, TCP listening sockets on the server will be bound to the loopback interface only," and specifying a remote bind address "will only succeed if the server's GatewayPorts option is enabled."
What "will only succeed" looks like in practice is worth knowing. We ran ssh -N -R 0.0.0.0:9201:localhost:8080 against an sshd with default settings. There was no error and no warning. The listener came up on 127.0.0.1:9201 and [::1]:9201 only. If you're wondering why your public URL doesn't work while the tunnel looks healthy, this is the first thing to check.
To let other hosts connect, the server needs GatewayPorts in sshd_config:
-
no(the default): remote forwards are available to the server itself only. -
yes: remote forwards always bind to the wildcard address, reachable from anywhere the firewall allows. -
clientspecified: the client picks the bind address, so-R 0.0.0.0:9201:...actually means 0.0.0.0.
Use clientspecified over yes if you need this at all, so a plain -R 8080:... stays private. A remote forward on a public interface is a hole into your laptop, so put a reverse proxy with auth in front of it, or keep it on loopback and let a proxy on the server reach it there.
If you leave out the destination (-R 1080), ssh acts as a SOCKS proxy for clients on the server side and connects out from your machine. That's niche, but it's in the man page and occasionally handy for giving a locked-down server temporary outbound access through your connection.
When should I use dynamic port forwarding (-D)?
A local forward maps one port to one destination. When you need a dozen internal dashboards behind the same server, a SOCKS proxy is simpler:
ssh -N -D 1080 deploy@bastion.example.com
Your machine now runs a SOCKS4/SOCKS5 server on port 1080. Point a browser profile or a tool at it, and each connection is opened from the server's side:
curl --socks5-hostname localhost:1080 http://grafana.internal:3000/
Use --socks5-hostname rather than --socks5 so the name is resolved on the server too. With --socks5, curl resolves grafana.internal locally, which fails for internal-only DNS names. In our test the SOCKS proxy returned the page from a service that only listened on the server's loopback.
The tradeoff: SOCKS only helps applications that speak SOCKS. Browsers and curl do; most database clients and CLIs don't, so for those a plain -L is still the answer.
How do I keep SSH port forwarding in my ssh config?
Retyping -L flags gets old fast. ~/.ssh/config has a directive for each kind of forward, and you can give a tunnel its own host alias:
Host db-tunnel
HostName db.example.com
User deploy
LocalForward 5433 localhost:5432
LocalForward 6380 localhost:6379
ExitOnForwardFailure yes
SessionType none
Now ssh db-tunnel opens both forwards and nothing else. The directives map to the flags like this:
-
LocalForward 5433 localhost:5432is-L 5433:localhost:5432. Note the space between the listen port and the destination instead of a colon. -
RemoteForward 8080 localhost:3000is-R 8080:localhost:3000. -
DynamicForward 1080is-D 1080. -
SessionType noneis the config form of-N("same as the-Noption"). -
ExitOnForwardFailure yesmakes ssh quit if it can't set up a forward. More on why that matters below.
You can confirm what ssh will actually use with ssh -G, which prints the resolved config without connecting:
ssh -G db-tunnel | grep -iE 'localforward|exitonforward|sessiontype'
On our test host that printed localforward 5433 [localhost]:5432, exitonforwardfailure yes and sessiontype none.
Keep a tunnel alias separate from the alias you use for a shell on the same box. That way ssh db gives you a prompt and ssh db-tunnel gives you ports, and neither surprises you. If your config is getting long, our guide to organizing your ssh config covers splitting it with Include and ordering Host blocks so the specific ones win.
How do I run a tunnel in the background?
Add -f, which sends ssh to the background after authentication:
ssh -f -N -o ExitOnForwardFailure=yes -L 5433:localhost:5432 deploy@db.example.com
Pairing -f with ExitOnForwardFailure is the documented way to make it reliable: with that option set, "a client started with -f will wait for all remote port forwards to be successfully established before placing itself in the background." In other words, a backgrounded tunnel that couldn't bind its ports exits with an error instead of sitting there doing nothing. In our test, the command above returned 0 and the forwarded port answered right away. Stop the tunnel later with kill on that ssh process, or use connection multiplexing (next section) so you can control it by name.
Can I add a forward to a connection that's already open?
Yes, if you use connection multiplexing. With a control socket (ControlMaster and ControlPath in your config), ssh -O sends commands to the running master connection:
ssh -O forward -L 9500:localhost:8080 db-tunnel
ssh -O cancel -L 9500:localhost:8080 db-tunnel
In our test, -O forward added the port to a live tunnel without re-authenticating, the page was reachable on 9500, and after -O cancel the same curl got "connection refused". That's handy when you realize mid-debugging that you need one more port. In an interactive session without multiplexing, the ~C escape opens a command line where you can add a forward too, but current OpenSSH disables it by default: you need EnableEscapeCommandline yes in your config first.
How do I forward a port through a jump host?
Combine the forward with ProxyJump. The jump host carries the SSH connection; the forward is set up on the final host:
Host bastion
HostName bastion.example.com
User deploy
Host app-db
HostName 10.0.12.20
User deploy
ProxyJump bastion
LocalForward 5433 localhost:5432
SessionType none
ssh app-db now connects through the bastion to the private host and gives you its Postgres on local port 5433. On the command line, the same thing is:
ssh -N -J bastion -L 5433:localhost:5432 app-db
Both versions worked in our test. One gotcha from the man page: options given on the command line generally apply to the destination, not the jump host, so put the jump host's user, key and port in its own Host block rather than in -o flags. Our bastion host and ProxyJump guide covers the bastion side in detail.
There's a second way to do this: forward from the bastion to the private host directly (ssh -N -L 5433:10.0.12.20:5432 bastion). That only needs SSH access to the bastion, but the last hop from the bastion to the database travels unencrypted inside your network, and the database sees the bastion as the client. ProxyJump keeps SSH end to end.
Why is my SSH port forwarding not working?
Most broken tunnels fall into one of these, and the messages are specific enough to tell them apart.
"Address already in use"
Something on your machine already listens on the local port, often a previous tunnel you forgot about. With an explicit bind address, ssh printed:
bind [127.0.0.1]:9100: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 9100
Could not request local forwarding.
Here's the trap: without ExitOnForwardFailure, ssh prints that and keeps running. You have a connected session and no tunnel. With ExitOnForwardFailure yes, the same command exited with status 255, which is what you want in scripts. Find the old listener with ss -ltnp | grep 9100 (Linux) or lsof -iTCP:9100 -sTCP:LISTEN (macOS) and stop it, or pick another port.
One subtlety we hit while testing: with no bind address (-L 9100:...), ssh tries both 127.0.0.1 and ::1. When only the IPv4 side was taken, the IPv6 listener still came up, so ssh didn't count it as a failure even with ExitOnForwardFailure yes. Clients that connect to 127.0.0.1 still hit the other program. If you see the bind warning, don't trust the tunnel until you've checked which process owns the port.
"administratively prohibited: open failed"
The server refuses forwarding. With AllowTcpForwarding no in sshd_config, the tunnel connected fine, and the error only appeared when something used the port:
channel 2: open failed: administratively prohibited: open failed
The server log recorded refused local port forward with the target host and port. A remote forward against the same server failed up front instead: Error: remote port forwarding failed for listen port 9601. AllowTcpForwarding can also be local or remote to allow only one direction, and PermitOpen / PermitListen restrict which destinations and listen ports are allowed, so a forward to one port can work while another is refused. If it's not your server, ask the admin; don't route around the policy.
For admins, the sshd_config man page adds an honest caveat: "disabling TCP forwarding does not improve security unless users are also denied shell access, as they can always install their own forwarders." Turning it off is a policy signal and a speed bump, not a wall. Agent forwarding is a separate setting with its own risks; see SSH agent forwarding security risks.
The tunnel is up but the destination refuses
If your client's connection closes immediately and the ssh window prints this, ssh did its part and the server couldn't reach the destination:
channel 2: open failed: connect failed: Connection refused
Check that the service listens where you pointed it (ss -ltn on the server), and remember the destination is resolved from the server: localhost there is the server itself, and a service bound to a Docker bridge or a private IP won't answer on localhost. ExitOnForwardFailure doesn't cover this case; the man page notes it "does not apply to connections made over port forwardings."
The tunnel dies when idle
A tunnel that carries no traffic looks idle to NAT gateways and firewalls, which drop idle connections. Set ServerAliveInterval on the client so ssh sends keepalives, or use autossh if the tunnel must come back on its own.
Is SSH port forwarding secure?
The traffic between your machine and the SSH server is encrypted and authenticated like any SSH session, so a local forward to a database is a reasonable replacement for exposing the database port. The risks come from what you open on either end:
- A forward bound to
0.0.0.0(via-g,GatewayPorts, or an explicit bind address) is reachable by anyone who can reach that interface. Keep forwards on loopback unless you mean otherwise. - A remote forward with
GatewayPorts yesexposes a service running on your laptop to the server's network. - On a multi-user server, any local user can connect to a loopback port you forwarded there.
- Forwarding is allowed by default in OpenSSH. If your hardening checklist doesn't mention it, decide on purpose; our complete guide to SSH security covers where it fits.
Where termique fits
If you jump between a few servers every day, the tunnels pile up in your shell history. termique lists port forwarding as part of its Free plan, alongside SSH sessions, SFTP and snippets (see pricing). Hosts and groups sync across your devices, and credentials and private keys sync end-to-end encrypted, so the keys behind your tunnels aren't stored in plaintext on our servers. The ssh commands in this guide stay useful either way: they're what you reach for on a box where nothing is installed.
termique is a free, native SSH manager we build, with end-to-end encrypted credentials.
FAQ
What is the difference between -L and -R in SSH?
-L opens the listening port on your machine and connects onward from the server. -R opens the listening port on the server and connects onward from your machine. Ask "where does the port I connect to live?" and that's your answer: local means -L, remote means -R.
What does -N do in ssh port forwarding?
It tells ssh not to run a remote command or shell, so the connection exists only to carry your forwards. In ~/.ssh/config the same setting is SessionType none.
How do I set up SSH port forwarding in the ssh config file?
Use LocalForward 5433 localhost:5432, RemoteForward 8080 localhost:3000 or DynamicForward 1080 inside a Host block. Add ExitOnForwardFailure yes so ssh exits instead of running without the tunnel, and check the result with ssh -G <host>.
Can I forward multiple ports over one SSH connection?
Yes. Repeat -L (or LocalForward) once per port. All forwards share the one authenticated connection.
Why does my remote forward only listen on 127.0.0.1?
Because the server's GatewayPorts setting defaults to no, which binds remote forwards to loopback even if you ask for 0.0.0.0. Set GatewayPorts clientspecified in sshd_config if other hosts must reach it, and protect what you expose.
Try termique
Tunnels are one part of working across servers. termique keeps the rest in one place: your host list on every device, end-to-end encrypted credentials, SFTP and saved snippets. Download termique and add your first hosts.
Top comments (0)