SSH Tunnels for Remote Database Access: ssh -L, Explained
Posted by Kyle Hankinson
A production database with port 3306 or 5432 open to the internet is a breach waiting for a scanner to find it, so sane deployments keep the database on a private network and expose exactly one hardened thing: an SSH server, usually called a bastion or jump host. To query the database from your laptop, you tunnel through it. The command is one line, but most explanations skip which machine each part refers to, and that gap is where all the confusing failures live.
ssh -N -L 15432:db.internal:5432 you@bastion.example.com
Reading left to right, the -L argument is local_port:target_host:target_port, and the three parts belong to three different machines:
| Piece | Where it lives | What it means |
|---|---|---|
15432 |
your laptop | ssh opens this port on 127.0.0.1 and listens |
db.internal:5432 |
resolved by the bastion | where the bastion forwards each connection |
you@bastion.example.com |
the bastion | the only machine you need SSH access to |
The part people miss: db.internal is resolved by the bastion, not by your laptop. It can be a private DNS name or a 10.x address that means nothing on your side; it only has to be reachable from the bastion. By the same logic, writing 127.0.0.1 there means the bastion itself, which is what you want when the database runs on the same box as the SSH server. -N just means "forward ports, do not run a remote shell".
Once the tunnel is up, you point your database client at your own machine:
psql -h 127.0.0.1 -p 15432 -U app_user mydb
Your normal database credentials still apply. SSH authentication gets you a path to the database; it does not log you into it. Two locks, two keys.
Proving it works end to end
Claims about networking deserve a demonstration, so I built the whole topology locally with Docker: a postgres:16 container with no published ports (verified: docker port shows nothing, so nothing on my Mac can reach it directly) and an OpenSSH bastion container on the same private network, publishing only its SSH port. Then, from the Mac:
ssh -i tunnel_key -p 2222 -N -L 15555:tun-pg:5432 tunneluser@127.0.0.1
Note that tun-pg is a container name only the bastion's network can resolve, exactly like db.internal above. With the tunnel up, psql connected through the forwarded port 15555 on my machine using the ordinary Postgres password, and:
SELECT version();
PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1) on aarch64-unknown-linux-gnu ...
One more query is worth running, because it exposes a detail that bites later:
SELECT inet_server_addr(), inet_client_addr();
inet_server_addr | inet_client_addr
------------------+------------------
172.18.0.2 | 172.18.0.3
The client address is the bastion's IP, not my machine's. As far as the database can tell, the bastion connected to it. That is why a pg_hba.conf rule scoped to your office IP will not match a tunneled connection, and why a MySQL grant like 'app'@'203.0.113.%' fails through a tunnel: the source the server sees is the bastion (or, when the daemon does the forwarding differently, its loopback). PostgreSQL's manual has a short chapter on tunnels that makes the same point.
The MySQL localhost trap
MySQL adds one gotcha of its own: to the MySQL client, the hostname localhost does not mean 127.0.0.1. It means "use the Unix socket", which skips TCP entirely and therefore skips your tunnel. I verified against a MySQL 8 server by running status after connecting both ways:
mysql -h localhost -> Connection: Localhost via UNIX socket
mysql -h 127.0.0.1 -> Connection: 127.0.0.1 via TCP/IP
So a tunneled MySQL connection must use -h 127.0.0.1 -P 13306, never -h localhost. If you have ever had a tunnel "work" while clearly hitting the wrong server, or fail with a socket error mentioning /tmp/mysql.sock, this was it. The behavior is documented on MySQL's connecting page.
The other engines each have one note worth knowing. SQL Server tunnels fine to its fixed port 1433, but named instances use dynamic ports assigned at startup; pin the instance to a static port before trying to forward to it. Oracle tunnels to the listener on 1521, and the tunnel solves only the network layer: you still need the correct service name on the other side, or you trade your firewall problem for an ORA-12514 (decoded in SID vs service name vs TNS). And SQLite is the reminder that not everything needs a tunnel: it is a file, so copy the file.
When the tunnel fails: three errors, decoded
Every message below is one I captured from a real failing tunnel, and each points at a different culprit.
channel 2: open failed: administratively prohibited: open failed
The SSH server is refusing to forward. My bastion image ships with AllowTcpForwarding no in sshd_config, and this is exactly what the client printed until I changed it. If you see this, the fix is on the server (or in your hosting provider's policy), not in your command.
bind [0.0.0.0]:15555: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 15555
Something on your laptop already owns that local port. Nine times out of ten it is a previous tunnel you forgot about; find it with lsof -i :15555 or just pick another port.
channel 2: open failed: connect failed: Connection refused
The subtle one. SSH is connected and forwarding happily, but the bastion could not reach the target host and port. The tunnel itself accepts your client's connection, then fails only when traffic flows, so your database client reports a dropped connection rather than anything mentioning SSH. I produced this by pointing the tunnel at port 5433 instead of 5432. Wrong target port, wrong target host, or a database that is down all land here.
For tunnels you keep open all day, three flags earn their keep: -N as above, -f to background ssh after authentication, and -o ServerAliveInterval=30 so idle tunnels survive aggressive NAT timeouts. If the database is two hops away, -J first-bastion chains jump hosts without nesting tunnels by hand. The full option reference is the OpenSSH manual.
If you would rather not babysit terminal windows, this is one of the few things a GUI can genuinely automate rather than merely wrap: SQLPro Studio lets you attach an SSH tunnel to a connection (bastion host, port, user, key or password), then opens the forwarding and points the client at it every time you connect, which is precisely the -L mechanics above minus the shell. Whether you script it or click it, the mental model stays the same: one listener on your machine, one hop through the bastion, and a database that still thinks it never talked to the outside world.
About the author - Kyle Hankinson is the founder and sole developer of SQLPro for MySQL and the Hankinsoft Development suite of database tools. He has been building native macOS and iOS applications since 2010.
Try SQLPro for MySQL - A native MySQL and MariaDB client for macOS and iOS. No Java required.
Download Free Trial View Pricing Compare