How-to

Connecting to a remote database through an SSH tunnel

7 min read

A correctly configured database server does not accept connections from the internet. Postgres listens on 127.0.0.1:5432, MySQL on 127.0.0.1:3306, and the firewall drops everything else. That is the right configuration, and it is also why TablePlus, DBeaver, Postico, psql and your application’s dev config cannot reach it.

An SSH tunnel is how you get in without weakening any of that. You already have SSH access to the machine; the tunnel borrows that connection to carry database traffic.

What a local forward does

ssh -L 15432:localhost:5432 deploy@db.example.com

This tells SSH: listen on port 15432 on my Mac, and anything that connects there gets forwarded, through the encrypted SSH connection, to localhost:5432 as seen from the server.

That last part is the piece worth internalising. The localhost in the middle is resolved on the far end, not on your Mac. From the database’s point of view, the connection arrives from 127.0.0.1 — which is exactly what its pg_hba.conf or its user grants already allow. Nothing on the server needs to change.

So the mapping is:

  • 15432 — a port on your Mac. Pick anything free above 1024.
  • localhost:5432 — where the database is, from the server’s perspective.

Then point your client at 127.0.0.1:15432 and it behaves like a local database.

Why 15432 and not 5432

Use a different local port than the real one, deliberately. If you also run Postgres locally, 5432 is taken and the tunnel fails to bind. Worse, if the tunnel silently dies and you keep working, you are now querying your local database while believing you are on production. Using 15432 makes the two impossible to confuse.

Ports below 1024 need root on macOS, so they are off the table for a tunnel anyway.

The full recipe

Postgres:

ssh -N -L 15432:localhost:5432 deploy@db.example.com

Then connect to host 127.0.0.1, port 15432, with the normal database username and password.

MySQL / MariaDB:

ssh -N -L 13306:localhost:3306 deploy@db.example.com

Then 127.0.0.1 on port 13306.

-N means “do not run a command” — you want the tunnel, not a shell. Add -f to push it into the background:

ssh -f -N -L 15432:localhost:5432 deploy@db.example.com

Backgrounded, it is also easy to forget about. lsof -i :15432 tells you whether it is still there, and pkill -f 'ssh -f -N -L 15432' stops it.

The MySQL trap: localhost is not 127.0.0.1

This one costs people an afternoon, reliably.

The mysql client treats localhost as an instruction to use a Unix socket, not TCP. So:

mysql -h localhost -P 13306 -u app -p     # ignores your tunnel entirely
mysql -h 127.0.0.1 -P 13306 -u app -p     # correct

The first command silently ignores both the port and the tunnel and tries a local socket that probably does not exist. You get “Can’t connect to local MySQL server through socket ‘/tmp/mysql.sock’”, which sends you looking in completely the wrong direction.

Always use 127.0.0.1 for a tunnelled MySQL connection. Some GUI clients do the right thing automatically; many do not.

Postgres does not have this behaviour — psql -h localhost uses TCP — but using 127.0.0.1 everywhere costs nothing and removes the question.

When the database is on a different machine than the SSH server

Common in real infrastructure: you SSH into an application server or a bastion, and the database is on its own host inside the private network. The middle part of the forward is just an address the server can reach, so it does not have to be localhost:

ssh -N -L 15432:10.0.4.12:5432 deploy@bastion.example.com

Your Mac → SSH to the bastion → bastion opens a connection to 10.0.4.12:5432 → traffic flows back through the tunnel. The database host never needs to be reachable from your machine at all.

If you have to cross two hops to get there, combine it with ProxyJump rather than nesting ssh commands — the setup is covered in connecting through an SSH bastion.

Making it permanent in ~/.ssh/config

Typing the forward every time is how you end up not using it. Put it in the config:

Host db-prod
    HostName db.example.com
    User deploy
    IdentityFile ~/.ssh/id_ed25519_prod
    LocalForward 15432 localhost:5432
    ServerAliveInterval 30
    ExitOnForwardFailure yes

Now ssh -N db-prod raises the tunnel with everything attached.

Two options there are doing real work:

ExitOnForwardFailure yes makes SSH refuse to connect at all if the forward cannot be established — usually because the local port is already in use. Without it, SSH connects happily, prints a warning you will not read, and leaves you with a session and no tunnel. Set this.

ServerAliveInterval 30 keeps the connection from being dropped by a NAT or firewall while you sit reading query results. A tunnel is idle exactly when you are thinking, which is when it is most likely to be reaped. See SSH keeps disconnecting.

Keeping it up while you work

A backgrounded ssh -f -N dies quietly the moment the network hiccups, and you find out when a query hangs. The options, in increasing order of comfort:

  • autossh (brew install autossh) — wraps SSH and restarts it when it dies. Reliable, no GUI, and you still have to remember which tunnels you started.
  • A launchd agent — survives reboots, correct, and a lot of ceremony for a database connection.
  • A client that manages the forwards for you. In Upshell (Pro), a forward is a rule saved on the host: local, remote or dynamic, with its ports, and a status panel that shows whether each one is actually listening rather than leaving you to guess. When the connection drops and reconnects, the forwards come back with it. The reason this is worth anything is precisely the failure mode above — a tunnel that has died without telling you is worse than no tunnel, because you keep acting as though it is there.

Step by step

  1. Confirm SSH works on its ownssh deploy@db.example.com. Fix that first; a tunnel cannot be more reliable than the connection under it.
  2. Find out where the database listens, from the server’s point of viewss -lntp | grep -E '5432|3306' on the server. That address and port are the middle of your forward.
  3. Open the tunnelssh -N -L 15432:localhost:5432 deploy@db.example.com, using a local port that is not the database’s real one.
  4. Connect your client to 127.0.0.1:15432 — never localhost for MySQL, or the client will bypass the tunnel and look for a Unix socket.
  5. Move it into ~/.ssh/config as a LocalForward with ExitOnForwardFailure yes and ServerAliveInterval 30, so it is one command and it fails loudly instead of silently.

Questions people ask

Do I need to change anything on the database server?

No, and that is the point. The connection arrives at the database from 127.0.0.1, which its existing configuration already permits. You do not open a firewall port and you do not touch postgresql.conf or my.cnf.

Why does my MySQL client ignore the tunnel?

Because you gave it localhost. The mysql client interprets that as “use a Unix socket” and skips TCP entirely, tunnel included. Use 127.0.0.1.

Can I tunnel to a database on a different host than the one I SSH into?

Yes — ssh -L 15432:10.0.4.12:5432 user@bastion. The middle address is resolved by the SSH server, so it only needs to be reachable from there, not from your Mac.

Is an SSH tunnel as secure as connecting directly with TLS?

The tunnel itself is strong — the traffic is inside an authenticated, encrypted SSH connection. The thing to be careful about is the local end: anything running on your Mac can connect to 127.0.0.1:15432 while the tunnel is up. On a single-user laptop that is a small concern; on a shared machine, bind it explicitly to the loopback interface and close the tunnel when you are done.

Upshell — the native SSH & SFTP client for macOS

Tabbed and split terminal, saved hosts with keys and 2FA, multi-hop bastions, port forwarding and SFTP on the very same connection. Free to download, no subscription required.