You’ve got a database on a server somewhere, and you want to reach it from your laptop — run a query, point a GUI at it, let a migration tool connect. It listens on 5432 (Postgres) or 3306 (MySQL), and right now your laptop can’t get to it, because the database sits on a private network with no route from the outside. So you go looking for the fastest bridge, and the internet offers a shortcut that works immediately and quietly costs you the thing the private network was protecting.
The mistake: opening the database port to the world
The shortcut is to make the database reachable: bind it to 0.0.0.0 instead of 127.0.0.1, punch a hole in the security group for port 5432, and now you can connect from anywhere. It works on the first try, which is exactly why it’s dangerous.
A database port on the public internet is a standing target. Postgres and MySQL speak on well-known ports, and the whole internet is scanned for them continuously — a listener on 5432 starts collecting login attempts within minutes of going up. The only thing between the internet and your data is now the database’s own password, one protocol quirk, or one CVE. Managed databases don’t let you do this by accident: RDS, Cloud SQL and the rest default to a private subnet with no public IP, precisely because a database is the last thing you want facing the internet. Opening it up anyway — a 0.0.0.0/0 rule “just for now” — trades the whole design away for a few minutes saved. The fix costs about as little and gives up nothing.
What an SSH tunnel to a database actually does
You almost certainly already have a way onto that network: an SSH login to a server that can see the database — the app server, a bastion, any box on the same subnet. SSH port forwarding lets you borrow that reach without exposing anything new.
ssh -L 5432:db.internal:5432 [email protected]
Read it left to right: open port 5432 on my laptop, and forward anything that arrives there to db.internal:5432 as resolved from the bastion. Then you point your client at localhost:
psql -h 127.0.0.1 -p 5432 -U app appdb
psql connects to your own machine; SSH carries the bytes, encrypted, over the connection you already had; the bastion opens a plain connection to db.internal from inside the private network, where it’s allowed to. From the database’s point of view nothing changed — it still only accepts connections from its own network. And no new port was opened anywhere on the internet. You got reachability without exposure.
The one detail that trips everyone up: db.internal is resolved on the far side, by the bastion, not by your laptop. It can be a name only the private network knows, or a raw address your laptop can’t route to:
# the database only the bastion can see, by private IP
ssh -L 5432:10.0.0.9:5432 [email protected]
That’s the whole trick, covered in depth — local, remote and dynamic — in SSH port forwarding explained. Here we only need the local (-L) case.
The bastion is where the tunnel starts
The box you tunnel through is usually the same gateway you already jump through to get a shell on the private network. If you reach your internal servers with ProxyJump, you already have the piece a database tunnel needs. The two are complementary: ProxyJump gets your shell onto a host behind the gateway, and -L brings a service’s port from the far side back to your localhost. If you have to hop through two gateways, chain them:
ssh -J bastion.example.com -L 5432:db.internal:5432 [email protected]
Why people still get the tunnel wrong
The mechanism is clean. The day-to-day of running tunnels by hand is where it frays:
- The command rots in your shell history. Next week you’re reconstructing which local port mapped to which database, on which bastion, in which environment. Staging and production both want
5432; you get one at a time on your laptop. - The local port is open to every process on your machine.
-L 5432listens on127.0.0.1:5432— any program on your laptop can now reach the production database through it, no SSH auth required, for as long as the tunnel is up. (And-L 0.0.0.0:5432:...quietly exposes it to your whole LAN.) - The tunnel dies mid-session and takes the query with it. A laptop sleep, a Wi-Fi change, an idle timeout, and the forward drops silently — usually solved with
-N,-fandServerAliveInterval 30, none of which you remembered the first time. - One
ssh -Lper database. A primary, a read replica, a MySQL box, a Redis — that’s four tunnel commands on four local ports, each re-run after every disconnect, none telling you at a glance whether it’s still up.
A saved ssh_config block — LocalForward, a ProxyJump, keepalives — smooths most of this over. But you’re still starting it by hand, still watching it, still pointing your client at the right loopback port yourself.
How Termalin’s Data client does it
Termalin is a cross-platform SSH client that recently grew a Data client — a database client in the desktop app and in the web cabinet at termal.in/account. You add a database the way you’d add a server: pick the engine, give it a host, port and credentials, optionally a default database. It speaks a wide spread of engines — PostgreSQL, MySQL, SQL Server, ClickHouse, MongoDB, Redis, Elasticsearch, Neo4j and more. The part this article is about is what happens when that database is behind a firewall — you don’t open a port and you don’t hand-run ssh -L:
- A database on a network you can already route to, you reach directly. Termalin dials the host and port you gave it.
- A database behind a firewall, you reach through a tunnel. On the desktop, attach a saved SSH host — your bastion, jump host and all — to the connection; Termalin opens a local forward on that session and points the driver at the resulting
127.0.0.1:<port>for you, so a database that only listens on localhost or sits behind a VPN comes through as if it were local. In the web cabinet, the same job is done by a Connector: Termalin opens the Connector’s SSH session, forwards a loopback port through it, and runs the query over that. Either way the encrypted SSH session carries the traffic to a box that can see the database, and the database itself never faces the internet.
So you keep what the private subnet was there to give you — a database reachable only from inside its own network — while getting a working query editor on your laptop. The tunnel comes up with the connection and goes down when you close it, along with the loopback port it opened. There’s no command to remember, no forward to babysit, and no 0.0.0.0/0 rule left behind in a security group.
A tunnel is reachability, not authorization
Worth stating plainly, because it’s the one thing a tunnel doesn’t do: getting the connection to the database isn’t the same as deciding what it may do once it’s there. A tunnel is a wire, and a wire is neutral — whoever’s on the other end still connects as whatever database user you handed over. Point the connection at a least-privilege role, and if an agent is going to be on the other end of it, keep that a separate decision — the subject of giving an AI agent read-only database access, not this one.
Where Termalin fits
Every developer should know ssh -L cold — it’s the honest way to reach a database without opening it to the world, and the mental model (open a port on my side, forward it to something the far side can see) is the whole game. The friction was never the concept; it was the bookkeeping: the commands in your history, the ports you juggle, the tunnels that drop, the one forward per database.
Termalin keeps the tunnel and the database connection as one saved thing. Attach a jump host or a Connector, and reaching a database on a private subnet is a click — the forward opens with the connection, the driver points at the loopback port for you, and the database stays exactly where it belongs: behind the firewall, listening only to its own network.
Termalin is a free, cross-platform SSH and database client with built-in tunnels, jump hosts and a Data client — download it, or see how it handles your keys and credentials safely.