An SSH tunnel carries a TCP connection inside an SSH session you already trust. ssh -L 3307:127.0.0.1:3306 deploy@203.0.113.10 makes port 3307 on your laptop lead to port 3306 on the server's loopback interface, so a database that listens only on 127.0.0.1 is reachable from your desk without opening a single firewall port. -R does the reverse: a port on the server leads back to something on your machine. -D turns the session into a SOCKS proxy. Those three flags, plus -N to skip the shell, cover nearly everything people use tunnels for.
What the one-liners leave out is the part that bites later: which address a forward binds to, what the server is allowed to refuse, why a tunnel dies silently overnight, and why a forward bound to the wrong interface quietly hands your database to the internet. This post covers all of that, and assumes you can already log in with a key - SSH keys and sshd_config hardening is the place to start if you cannot.
The three kinds of forwarding at a glance#
| Flag | Listens on | Leads to |
|---|---|---|
-L local | Your machine | A host and port as seen from the server |
-R remote | The server | A host and port as seen from your machine |
-D dynamic | Your machine | Anywhere, chosen per connection (SOCKS) |
The rule that makes the syntax readable: the side where the port listens is the side named by the flag, and the destination host in the spec is resolved by the other end. In -L 3307:db.internal:3306, the name db.internal is looked up by the SSH server, not by your laptop, which is exactly why a tunnel can reach names and addresses your own machine has never heard of.
Every forward rides the one encrypted TCP connection to port 22 (or wherever sshd listens). Nothing about the destination service needs to know it is being tunnelled; it sees an ordinary TCP connection from the server it runs on, or from whatever host the server connected out to.
Two limits are worth knowing before you plan anything around tunnels. SSH forwards TCP only - there is no UDP forwarding, so game traffic, DNS over UDP and most voice protocols cannot go through -L or -R. And all channels share one TCP connection, so a single large transfer slows every other forward in the same session, and packet loss on the path stalls all of them at once.
Local forwarding: reaching a database that listens on localhost#
This is the case most people arrive with. A MySQL or PostgreSQL server on a VDS is correctly configured to listen only on loopback - bind-address = 127.0.0.1 in MySQL, listen_addresses = 'localhost' in PostgreSQL - and you want to open it in a GUI client on your own computer.
$ ssh -N -L 3307:127.0.0.1:3306 deploy@203.0.113.10Read it as: listen on local port 3307, and send each connection to 127.0.0.1:3306 from the server's point of view. -N tells SSH not to start a remote shell, so the command sits there holding the tunnel open until you press Ctrl+C. Then, in a second terminal:
$ mysql -h 127.0.0.1 -P 3307 -u app -p appdb$ psql "host=127.0.0.1 port=5433 user=app dbname=appdb" # for a -L 5433:...:5432Three details decide whether this works the first time.
- Use `127.0.0.1`, not `localhost`, on the client side. The MySQL client treats
localhostas "use the local Unix socket" and ignores the port, somysql -h localhost -P 3307connects to whatever MySQL runs on your laptop, or fails, but never touches the tunnel. MySQL remote connections explains this behaviour in full. - Pick a local port that is free. Forwarding
3306to3306fails withbind [127.0.0.1]:3306: Address already in usethe moment you have a local MySQL running. Using the real port plus one (3307,5433) is a habit that avoids it. On Linux, binding a local port below1024needs root, which is another reason to stay high. - The database sees the connection coming from the server itself. For MySQL that means the account must exist for
127.0.0.1orlocalhost(the latter only matches when name resolution is on, which it is by default). For PostgreSQL the connection arrives over TCP from127.0.0.1, sopg_hba.confneeds ahostline for127.0.0.1/32; most distribution packages ship one. A tunnel does not bypass database authentication - it only gets you to the door.
Forwarding to another machine, or to a Unix socket
The destination does not have to be the SSH server. If the database lives on a private address behind the machine you can log in to, name it:
$ ssh -N -L 5433:10.0.0.20:5432 deploy@203.0.113.10Now the SSH server opens the connection to 10.0.0.20:5432 on your behalf. That hop between the server and the database is not encrypted by SSH; it is a plain TCP connection on the private network. On a network you control that is usually fine, and it is worth saying out loud when it is not.
OpenSSH 6.7 and later can also forward to a Unix socket on the server, which lets you reach a database that has no TCP listener at all:
$ ssh -N -L 5433:/var/run/postgresql/.s.PGSQL.5432 deploy@203.0.113.10PostgreSQL then sees a local socket connection, and pg_hba.conf applies its local rules rather than its host rules. Socket forwarding is controlled separately on the server by AllowStreamLocalForwarding, which defaults to yes.
Making tunnels permanent with ~/.ssh/config#
Typing the spec every time gets old, and the config file accepts the same thing under a name:
Host db-tunnel HostName 203.0.113.10 User deploy IdentityFile ~/.ssh/id_ed25519 LocalForward 3307 127.0.0.1:3306 LocalForward 5433 127.0.0.1:5432 ExitOnForwardFailure yes ServerAliveInterval 30 ServerAliveCountMax 3ssh -N db-tunnel now opens both forwards. Note that LocalForward separates the listening port and the destination with a space, not a colon. RemoteForward and DynamicForward exist with the same shape.
ExitOnForwardFailure yes is the one to always add. Without it, if a port is already taken SSH prints a warning, logs you in anyway, and leaves you with a session that has no tunnel - which looks identical to a working one until the client fails to connect. With it, SSH exits with an error, and anything supervising it can try again.
If the database host is only reachable through a bastion, combine forwarding with ProxyJump rather than chaining two tunnels by hand. ssh -J bastion -N -L 5433:127.0.0.1:5432 deploy@10.0.0.20 connects through the bastion to the inner machine and forwards from there, with authentication happening on your laptop at each hop.
Two more flags are worth knowing. -f sends SSH to the background after authentication, which is convenient for scripts: ssh -f -N db-tunnel. And you can add a forward to a session that is already open with the escape sequence ~C typed at the start of a line, followed by -L 8080:127.0.0.1:80. Since OpenSSH 9.2 that command line is disabled by default and needs EnableEscapeCommandline yes in your client config.
Remote forwarding: exposing something on your machine#
-R opens a listening port on the server and sends connections back through the session to your side.
$ ssh -N -R 8080:127.0.0.1:3000 deploy@203.0.113.10Connections to port 8080 on the server now reach 127.0.0.1:3000 on your laptop - a development web server, a webhook receiver you are debugging, or a service on a home network with no public address. It is the same idea that commercial tunnelling services package up, without the third party.
Here is the surprise almost everybody meets: by default, the remote port listens only on the server's loopback. Run ss -tlnp on the server and you will see 127.0.0.1:8080, not 0.0.0.0:8080, so nothing on the internet can reach it. That default is controlled by GatewayPorts in the server's sshd_config:
GatewayPorts | Remote forwards bind to | When to use it |
|---|---|---|
no (default) | Loopback only | Almost always |
yes | All interfaces | Rarely; every -R becomes public |
clientspecified | What the client asks for | When some forwards must be public |
With clientspecified, the client chooses the address in the spec: -R 0.0.0.0:8080:127.0.0.1:3000 asks for every interface, and -R 8080:... still gets loopback. That is the safer of the two non-default values, because it makes exposure a deliberate act per tunnel rather than a side effect of every one.
The more common and safer pattern is to leave GatewayPorts no and put a reverse proxy on the server in front of the loopback port. Nginx listening on 443 with a certificate, proxying to 127.0.0.1:8080, gives the forwarded service a proper name and TLS, and the raw tunnel port is never public. If you open the port in a firewall instead, the ufw guide covers how to allow it only from the addresses that need it.
A remote port of 0 asks the server to choose a free one and prints it, which is useful in scripts. And since OpenSSH 7.6, -R with only a port and no destination (-R 1080) creates a SOCKS proxy on the server that exits through your machine - the mirror image of -D.
Dynamic forwarding: a SOCKS proxy in one flag#
$ ssh -N -D 1080 deploy@203.0.113.10This starts a SOCKS4 and SOCKS5 proxy on 127.0.0.1:1080. Any application pointed at it has each connection opened from the server instead of from your machine, to whatever destination it asks for. Point a browser at it and you browse from the server's address; point a database tool at it and you can reach every private host the server can.
$ curl --socks5-hostname 127.0.0.1:1080 http://10.0.0.20:9000/health--socks5-hostname matters: it sends the name to the proxy to resolve, rather than resolving it locally. Plain --socks5 looks up the name on your machine first, which fails for internal names and leaks the lookup to your local resolver. Browsers have the same switch - in Firefox it is the option to proxy DNS when using SOCKS v5.
A SOCKS proxy over SSH is a reasonable way to reach an admin interface on a private network, or to check how a site responds from a server in another country. It is not a VPN: only applications configured to use the proxy go through it, UDP does not, and anything you forget to point at it leaves directly. If what you actually want is all traffic from a device going through a server, a real VPN is the right tool - what a VPN protects and what it does not draws the line.
Keeping a tunnel up: autossh and systemd#
A tunnel opened by hand dies with the laptop's wifi, with a router that drops idle sessions, and with every server reboot. For a tunnel something depends on - a reporting job reading a remote database, a home service exposed with -R - it needs supervising.
The first defence is the keepalive pair, ServerAliveInterval 30 and ServerAliveCountMax 3. They make the client send a probe every 30 seconds through the encrypted channel and give up after three unanswered, so a dead connection is noticed in about 90 seconds instead of hanging forever. Giving up is what you want: a process that exits can be restarted.
autossh is the classic restarter. It runs ssh and starts it again whenever it exits.
$ autossh -M 0 -f -N \ -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \ -o "ExitOnForwardFailure yes" \ -L 3307:127.0.0.1:3306 deploy@203.0.113.10-M 0 turns off autossh's own monitoring port and relies on SSH's keepalives instead, which is the usual modern setup. One behaviour to know: by default autossh treats an ssh that exits within its first 30 seconds as a fatal error and stops (the AUTOSSH_GATETIME environment variable controls this), which catches a wrong password or host key rather than retrying forever. When started with -f it sets that gate time to zero and keeps retrying.
On a Linux machine with systemd you do not need autossh at all. A unit with Restart=always does the same job and puts the logs in the journal:
[Unit]Description=SSH tunnel to the databaseAfter=network-online.targetWants=network-online.target[Service]User=tunnelExecStart=/usr/bin/ssh -N -o ExitOnForwardFailure=yes \ -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \ -i /home/tunnel/.ssh/id_ed25519 -L 3307:127.0.0.1:3306 tunnel@203.0.113.10Restart=alwaysRestartSec=10[Install]WantedBy=multi-user.target$ sudo systemctl daemon-reload$ sudo systemctl enable --now db-tunnel$ journalctl -u db-tunnel -fTwo things make this safe to leave running. The key it uses has no passphrase - it has to, nobody is there to type one - so it must be a dedicated key for a dedicated account, restricted on the server to the one forward it needs, as the next section shows. And the first connection has to be made by hand once as that user, to accept the host key into its known_hosts; a service cannot answer the fingerprint question, and it should never be configured with StrictHostKeyChecking no to avoid it.
What the server allows: AllowTcpForwarding and friends#
Everything above works because OpenSSH's server allows forwarding by default. That is the right default for an administrator's own account and the wrong one for most others. These are the controls, all in sshd_config:
| Directive | Default | What it does |
|---|---|---|
AllowTcpForwarding | yes | no, local, remote or all |
GatewayPorts | no | Whether -R ports bind beyond loopback |
PermitOpen | any | Destinations a -L may reach |
PermitListen | any | Ports a -R may listen on (OpenSSH 7.8+) |
AllowStreamLocalForwarding | yes | Unix socket forwarding |
PermitTunnel | no | Layer 3 tun devices with ssh -w |
DisableForwarding | no | Switches off every kind at once |
The thing people miss is that forwarding does not need a shell. A user whose account is locked to SFTP with ForceCommand internal-sftp, or whose shell is /usr/sbin/nologin, can still run ssh -N -L ... and use your server as a stepping stone into its private network, unless forwarding is switched off for them. That is why a Match block for restricted accounts should say so explicitly:
Match Group sftp-only AllowTcpForwarding no AllowStreamLocalForwarding no X11Forwarding noMatch User tunnel AllowTcpForwarding local PermitOpen 127.0.0.1:3306 X11Forwarding noThe same limits can be set per key in authorized_keys, which is the neater option for a service account with one job:
restrict,port-forwarding,permitopen="127.0.0.1:3306",command="/bin/false" ssh-ed25519 AAAAC3Nza... tunnel@reportingrestrict turns everything optional off, port-forwarding turns forwarding back on, permitopen limits it to the database port, and the forced command means the key cannot run anything even if someone asks for a shell - -N never runs a command, so the tunnel is unaffected. permitlisten is the equivalent for a key that should only create one -R port. A stolen copy of that key is then worth exactly one database connection, which still has to authenticate to the database.
As always with sshd, test with sshd -t and keep an existing session open before restarting it.
Security caveats worth taking seriously#
A tunnel is encrypted, authenticated and convenient, and every one of those properties makes it easy to forget that it is also a hole in whatever boundary you drew.
- A local forward is open to everyone on your machine.
-L 3307:...binds127.0.0.1, so any process and any user on that computer can connect to it. On a laptop only you use, that is fine. On a shared jump box or a build server, it hands your database connection to everyone with a login. The database password still applies; plan as if the port were public. - Never bind a forward to all interfaces by accident.
-g,GatewayPorts yesin your client config, or a spec like-L 0.0.0.0:3307:...makes the forward listen on your network too. On a café network, that is your database offered to the room.-D 0.0.0.0:1080is worse: an open proxy into the server's private network. - Remote forwards can defeat your own firewall. A
-Rfrom a laptop inside an office network to a server outside it publishes an internal service to anything that can reach that server port. Organisations block outbound SSH for exactly this reason. Do not tunnel out of a network whose owner would object. - The last hop may be plain text.
-L 5433:10.0.0.20:5432encrypts as far as the SSH server and no further. Use the database's own TLS for anything that crosses a network you do not trust. - Agent forwarding is not port forwarding.
-Aexposes your keys to root on the remote host and is not needed for any of the above. The SSH keys post explains whyProxyJumpreplaces it.
The honest comparison is with simply exposing the service. A database open to the internet with TLS and a strong password, reached directly, is a legitimate design - the database security checklist covers what it takes. A database on loopback, reachable only through SSH, removes the database's login from the attack surface entirely. The tunnel is usually the better choice for administrators and occasional tools, and the direct connection is usually the better choice for applications that need to connect all day without a supervisor process to babysit.
Troubleshooting the errors people hit#
`bind [127.0.0.1]:3306: Address already in use` and `Could not request local forwarding.` Something on your machine already listens on that port - often a local database, or yesterday's tunnel still running in the background. Pick another local port, or find the old process with ss -tlnp on Linux, lsof -iTCP:3306 -sTCP:LISTEN on macOS, or netstat -ano | findstr 3306 on Windows.
`channel 2: open failed: connect failed: Connection refused`. The tunnel is fine; the destination refused. The SSH server tried to connect to the host and port in your spec and nothing listened there. Check the service is running and on the address you named: a database bound to a private address will refuse 127.0.0.1, and one bound to loopback will refuse the private address.
`channel 2: open failed: administratively prohibited: open failed`. The server's policy said no: AllowTcpForwarding no, a PermitOpen that does not include your destination, or a restrict key without port-forwarding. It is a configuration answer, not a network one.
`Warning: remote port forwarding failed for listen port 8080`. The server could not open the port for your -R: it is in use (often by a previous session that has not timed out yet), it is below 1024 and you are not root, or PermitListen excludes it. A dead session holding the port is common with supervised tunnels; ClientAliveInterval on the server reaps it.
The tunnel connects but the client hangs. Usually the client is not using the tunnel at all - localhost taking the socket path, or a GUI tool that has its own SSH tunnel setting configured as well as yours. Test with nc -vz 127.0.0.1 3307 first: if that connects, the tunnel works and the problem is the client.
The tunnel dies after a while of idleness. A router or firewall between you and the server dropped the idle TCP connection. Add ServerAliveInterval 30, and supervise anything that has to stay up.
Where tunnels fit on RE:NODE#
Every technique in this post needs a machine running sshd that you administer, which on RE:NODE means a VDS: full root access, so sshd_config, GatewayPorts and the forwarding restrictions are yours to set. The first hour on a new VDS covers getting that machine to the point where a tunnel is the next step.
The database plans work differently, and do not need a tunnel at all. A PostgreSQL, MySQL, MongoDB, SQL Server or Valkey server on database hosting is reached directly on the host and port shown in the panel, with credentials generated for that server, so a client connects to it as it would to any remote database - with TLS and a strong password doing the work a tunnel does elsewhere. Game, app and web servers come with SFTP on every server and a console in the panel, so the everyday reasons people open tunnels to their own machines are handled there instead.
FAQ#
Is an SSH tunnel the same as a VPN?
No. A tunnel forwards specific TCP ports, or proxies applications configured to use -D, over one SSH connection. A VPN routes all traffic from a device at the IP level, including UDP and DNS. SSH can do layer 3 tunnelling with ssh -w and PermitTunnel, but it is awkward, and a purpose-built VPN does it better.
Can I forward UDP through SSH?
Not with OpenSSH's port forwarding, which is TCP only. Workarounds that wrap UDP in TCP with socat at each end exist, but they add latency and break the timing that real-time protocols such as game traffic rely on. For a game server, use a proper tunnel or a hosted server rather than SSH.
Does a tunnel slow my database queries down?
Slightly, and rarely enough to matter. Encryption costs very little on modern CPUs; the real costs are the extra latency of the path and that every forward shares one TCP connection. For an admin session or a nightly report, it is invisible. For an application making thousands of small queries a second, connect directly with TLS instead.
Which port should I use on my side?
Any free port above 1024. The real service port plus one (3307 for MySQL, 5433 for PostgreSQL) is easy to remember and avoids clashing with anything running locally. Port numbers on your side and the server side are independent.
Can I use SSH tunnels on Windows?
Yes. Windows 10 and 11 include the OpenSSH client, and every command here works in PowerShell or Command Prompt. PuTTY has the same feature under Connection, SSH, Tunnels, with Local, Remote and Dynamic options. Many GUI database tools, including DBeaver and MySQL Workbench, also have an SSH tunnel tab built in.


