QoreDB LogoQoreDB
Back to blog
ProduitTechnical journal

How to connect to PostgreSQL through an SSH tunnel: a complete guide

A production PostgreSQL database is almost never directly reachable from the Internet. Port 5432 stays accessible only from the cluster's private network, and human access goes through an SSH bastion. The tunnel forwards a local port to the database through the encrypted SSH…

Raphaël – Creator of QoreDBRaphaël – Creator of QoreDB
5 min readUpdated Jul 18, 2026
How to connect to PostgreSQL through an SSH tunnel: a complete guide

A production PostgreSQL database is almost never directly reachable from the Internet. Port 5432 stays accessible only from the cluster's private network, and human access goes through an SSH bastion. The tunnel forwards a local port to the database through the encrypted SSH session, without exposing the database to the outside world.

This guide covers the three approaches used in 2026: the ssh command by hand, the ~/.ssh/config file to reuse a named config, and the desktop clients that embed the tunnel themselves (DBeaver, DataGrip, QoreDB). We finish with the concrete pitfalls: timeout, ProxyJump, host key and passphrase.

The ssh -L command on the command line

Local port forwarding is always written the same way:

ssh -L 5433:postgres.internal:5432 user@bastion.example.com -p 22 -N

The -L 5433:postgres.internal:5432 option means: listen on 127.0.0.1:5433 on the client side, and forward to postgres.internal:5432 as seen from the SSH server. The -N option asks not to run any remote command; we just want the tunnel.

Once the tunnel is open, you connect to PostgreSQL through the local port:

psql -h 127.0.0.1 -p 5433 -U appuser -d appdb

Classic pitfall: the remote_host is resolved on the SSH server side, never on the client side. If you put localhost, it's the bastion machine that tries to reach its own database on 5432. You need the internal name of the database as the bastion sees it: private IP, internal FQDN, or Docker service name.

The ~/.ssh/config file

Rewriting the full command every time is tedious. The good practice is to declare everything in ~/.ssh/config, once and for all.

Host prod-pg / HostName bastion.example.com / User myuser / Port 22 / IdentityFile ~/.ssh/prod_ed25519 / LocalForward 5433 postgres.internal:5432 / ServerAliveInterval 30 / ServerAliveCountMax 3

A simple ssh -N prod-pg command then opens the tunnel with the whole config attached. A significant advantage: clients that don't integrate the SSH tunnel can still benefit from the mechanism. You start the tunnel separately, and they connect to 127.0.0.1:5433 as if the database were local.

Desktop clients that integrate the tunnel

Several GUI clients offer an SSH configuration directly in the connection definition. You enter the host, the port, the private key and the SSH user once, alongside the PostgreSQL credentials. The client opens the tunnel itself when the connection is established and closes it when you disconnect.

DBeaver embeds its own SSH stack (JSch historically, Apache MINA SSHD on recent versions). DataGrip relies on the SSH stack of the JetBrains IDEs. QoreDB delegates to the system's native ssh binary and launches a subprocess. Each approach has different consequences: supported algorithms, passphrase handling, integration with ssh-agent.

The practical criterion: if you care about using the same algorithms and the same crypto configuration as your system OpenSSH, choose a client that calls the native binary. If you want a fully self-contained configuration, independent of the system, a client with an embedded SSH stack does the job.

The pitfalls you actually run into

Silent timeout. Without ServerAliveInterval, a NAT, a firewall or a load balancer can cut the session after a few minutes of inactivity. Setting a 30-second interval and ServerAliveCountMax=3 fixes 90% of the drops.

Chain of bastions. When you have to cross a public bastion then an internal bastion, ssh -J user@public-bastion user@inner-bastion handles the chain. ~/.ssh/config supports ProxyJump as a directive. On the GUI client side, integration is uneven: QoreDB exposes it as a dedicated field, DBeaver goes through the advanced OpenSSH config.

Host key validation. The first connection asks you to validate the server's fingerprint. In automated mode, StrictHostKeyChecking=accept-new accepts a new unknown key but refuses a key that has changed. It's the right compromise for team setups: no friction when adding a new bastion, an immediate alert if an existing key is modified (a potential sign of a MITM attack).

Private key with a passphrase. Some clients can't decrypt the key themselves and will refuse any protected key. The clean solution is ssh-agent: you load the key once with ssh-add, the agent keeps the key in memory, and all the tools that call OpenSSH (psql through the tunnel, QoreDB, scripts) retrieve it automatically.

Local port already in use. If 5433 is taken, the tunnel fails silently or blocks the previous port. To avoid the problem, some clients (including QoreDB) bind an ephemeral local port chosen by the OS, rather than pinning an arbitrary number.

The QoreDB approach: native OpenSSH

QoreDB doesn't embed an SSH stack. Every time a connection with a tunnel is opened, the app launches ssh or ssh.exe as a subprocess, with the options BatchMode=yes, ExitOnForwardFailure=yes, and a dedicated UserKnownHostsFile so as not to pollute the personal ~/.ssh/known_hosts.

Three host key policies are exposed in the UI: accept-new by default, strict for sensitive environments, and insecure for local development (disables verification, to be used only inside Docker). ProxyJump is supported with format validation to prevent argument injection into the SSH command line.

Direct consequence: if OpenSSH works on the command line on the machine, QoreDB works with exactly the same configuration. The supported algorithms, the global options in /etc/ssh/ssh_config, and the keys loaded in ssh-agent are shared between the CLI and the app. A team that has already standardized its OpenSSH config has nothing to relearn.

Which approach to choose

Three levels of integration, from the most raw to the most fluid. The ssh -L command line remains unbeatable for understanding what's going on and for one-off setups. The ~/.ssh/config file is the bare minimum as soon as you regularly access several bastions. The desktop client with an integrated tunnel wins when you access ten environments daily and want to avoid the mental double step of open the tunnel, then open the client.

The three approaches stack. You can perfectly well configure ~/.ssh/config with ProxyJump and keys in the agent, and let QoreDB or DataGrip call that setup without duplicating a single option. This is probably the sweet spot for a backend dev in 2026: a centralized SSH configuration, and a database client that plugs into it without friction.

Newsletter

Stay updated on new releases

Subscribe to get product releases, new drivers notifications, and technical tutorials.

🎁 Bonus: Get our free SQL Performance Cheat Sheet — 9 pages, PostgreSQL / MySQL / SQLite (PDF)!
Share
How to connect to PostgreSQL through an SSH tunnel: a complete guide - Blog - QoreDB