How to Set Up an SSH Tunnel for Local Database Access
Writing
DEVOPS & INFRASTRUCTURE
May 28, 202612 min read

How to Set Up an SSH Tunnel for Local Database Access

Set up an SSH tunnel to reach a remote Postgres, MySQL, or Redis from your laptop without exposing the database port. With autossh and a systemd unit.

ssh-tunneldatabasedevopspostgressecurityhow-to

The single fastest way to wake up to a ransom note is to expose your Postgres port directly to the public internet. The second fastest is to think you can secure it with a strong password and call it a day.

I have run point on database access for production systems at three different companies. Every single one of them had at least one engineer at some point ask if we could "just open 5432 to my IP" so they could pull a quick report from DBeaver. The answer is always no. The right answer is an SSH tunnel, and once you have done it twice, it takes about 15 seconds to set up.

This post is the practical guide I wish I had given that engineer the first time. We will cover the command, the GUI client setup for the four databases I touch most often, the autossh recipe that keeps the tunnel alive through laptop sleeps and network changes, and the small set of mistakes that bite people in production. If you want the deeper context on why exposing database ports is bad even with strong auth, I covered that in my zero-trust microservices post, and the connection pooling tradeoffs once you are inside the tunnel show up in my pgbouncer survival guide.

What is an SSH tunnel and why use it for database access?

An SSH tunnel for database access forwards a local TCP port on your laptop through an authenticated SSH session to a remote host, which then opens a connection to the actual database. Your database client connects to localhost and never knows anything else exists. All traffic rides inside the encrypted SSH channel.

The shape of it looks like this.

SSH tunnel flow from laptop to bastion to database

This pattern wins on five things at once.

No exposed database port. The database listens only on its private network. The bastion is the only thing on the public internet, and it only speaks SSH.

No firewall holes per developer. Every engineer goes through the same bastion. You add or remove access by adding or removing SSH keys, not by editing security group rules.

Audit trail. Every connection logs through SSH and through the bastion's auth.log. You know who connected, from where, when.

Works with every client. psql, DBeaver, TablePlus, DataGrip, mysql, redis-cli, mongosh. They all just see localhost. No driver-level config needed.

Encrypted in transit by default. Even if your database speaks plaintext on the wire (looking at you, Redis), the tunnel carries it under SSH.

The one thing it does not give you is high availability. The tunnel is a long-lived TCP connection between exactly two hosts. If your bastion goes down, your tunnel goes with it. That is fine for ad-hoc developer access. It is not fine for application traffic. Application traffic belongs on a private network or a managed bastion service like AWS Session Manager.

How do you set up a local port forward to a remote Postgres?

The full command is one line.

ssh -L 5433:dbhost.internal:5432 ec2-user@bastion.example.com

Read it left to right.

  • -L 5433:dbhost.internal:5432 says forward local port 5433 on this laptop, through the SSH connection, to dbhost.internal:5432 resolved from the bastion's perspective.
  • ec2-user@bastion.example.com is the SSH connection itself.

While that command is running, anything on your laptop that connects to localhost:5433 ends up talking to the Postgres on dbhost.internal:5432. Close the terminal or hit Ctrl+C and the tunnel dies.

I deliberately use 5433 on the local side instead of 5432. If you happen to have Postgres running locally for development, you do not want to clobber it. Pick a high port that does not collide.

To test the tunnel works without firing up a GUI, use psql from another terminal.

psql -h localhost -p 5433 -U app_user -d production

That -h localhost is the key. Without it, psql tries to connect via Unix socket and skips the tunnel entirely. I have lost 20 minutes to this twice.

Running the tunnel in the background

If you do not want a terminal sitting open, add -f -N and the command returns immediately while the tunnel keeps running.

ssh -f -N -L 5433:dbhost.internal:5432 ec2-user@bastion.example.com
  • -N means do not execute a remote command (we only want the forward).
  • -f means fork into the background after authentication.

To kill it later, find and stop the process.

pgrep -af "ssh.*5433:dbhost.internal"
kill <pid>

A tidier setup with ~/.ssh/config

Typing that command every time gets old. Stash the whole thing in your SSH config.

# ~/.ssh/config
Host pg-prod
    HostName bastion.example.com
    User ec2-user
    IdentityFile ~/.ssh/keys/prod-bastion.pem
    LocalForward 5433 dbhost.internal:5432
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ExitOnForwardFailure yes

Now the command is just ssh pg-prod, and you get a few useful behaviors for free.

  • ServerAliveInterval 30 sends a keepalive every 30 seconds so the tunnel does not die when your home router decides to drop idle connections.
  • ExitOnForwardFailure yes makes ssh fail fast if the forward cannot bind, instead of leaving you with a dead tunnel and no error.

For the background form, ssh -f -N pg-prod still works. The config entry is purely additive.

How do you connect from your GUI client through the tunnel?

With the tunnel running, every GUI client connects to localhost on the forwarded port. The only field that matters is the host. Everything else (username, password, database name) is the same as you would use against the real database.

In DBeaver, create a new Postgres connection and fill in:

Host:     localhost
Port:     5433
Database: production
Username: app_user
Password: <your db password>

Hit Test Connection. If it works, you are good.

In TablePlus, same idea. Host is localhost, port is 5433.

In DataGrip, same. The driver does not know the tunnel exists, which is exactly the point.

DBeaver and DataGrip both have a built-in SSH tunnel option in their connection dialog. That works too. The advantage of using ssh on the command line instead is that you can share one tunnel across psql, DBeaver, a script, and a notebook at the same time. The advantage of the GUI option is that it dies cleanly when you close the client.

I default to the command line approach because I almost always have multiple things hitting the same database. Pick what fits your workflow.

How do you do the same for MySQL, Redis, and MongoDB?

The flag is identical. Only the port changes.

# MySQL
ssh -L 3307:dbhost.internal:3306 user@bastion
 
# Redis
ssh -L 6380:cache.internal:6379 user@bastion
 
# MongoDB
ssh -L 27018:mongohost.internal:27017 user@bastion

Then connect each client to localhost on the forwarded port.

mysql -h 127.0.0.1 -P 3307 -u app_user -p
redis-cli -h 127.0.0.1 -p 6380
mongosh "mongodb://app_user:secret@127.0.0.1:27018/production"

A few client-specific notes that catch people.

MySQL needs 127.0.0.1, not localhost. The mysql client tries to connect via Unix socket when you say localhost, just like psql does. Use the IP literal and you skip that.

Redis tunnels work great for debugging, badly for sustained throughput. Every command round-trips through the SSH session. Latency goes from 0.2 ms to 8-30 ms depending on your link. Fine for redis-cli MONITOR or one-off lookups. Wrong tool for ETL.

Mongo replica sets need extra care. A standalone mongod tunnels fine. A replica set will hand you back the internal hostnames of the other replicas during connection negotiation, and your client will then try to connect to those names directly. Either tunnel each replica on its own port and add them to your connection string, or set directConnection=true in the URI to disable replica discovery.

How do you keep the tunnel alive with autossh and systemd?

ssh -f -N works until your laptop goes to sleep, your wifi switches, or the bastion restarts. Then the tunnel dies silently and your next connection just hangs. The fix is autossh, which is a tiny wrapper that monitors the SSH process and restarts it when it drops.

Install it.

# macOS
brew install autossh
 
# Ubuntu / Debian
sudo apt install autossh
 
# Fedora / Rocky
sudo dnf install autossh

Run it the same way you ran ssh, with one extra port for autossh's own health check.

AUTOSSH_GATETIME=0 \
autossh -M 0 -f -N \
  -o "ServerAliveInterval 30" \
  -o "ServerAliveCountMax 3" \
  -o "ExitOnForwardFailure yes" \
  -L 5433:dbhost.internal:5432 \
  ec2-user@bastion.example.com

A few details that matter.

  • AUTOSSH_GATETIME=0 makes autossh restart immediately even if the first connection failed. Without it, autossh waits 30 seconds before retrying, which is annoying when you mis-typed the host.
  • -M 0 disables autossh's old monitoring port mechanism. We use the ServerAlive* SSH options instead, which work better through NATs and modern firewalls.

That command stays up through sleep, wake, and network changes. But it does not survive a reboot. For that, wrap it in a systemd user service.

The systemd user service

Create ~/.config/systemd/user/pg-tunnel.service.

[Unit]
Description=Autossh tunnel to production Postgres
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
Environment=AUTOSSH_GATETIME=0
ExecStart=/usr/bin/autossh -M 0 -N \
  -o "ServerAliveInterval 30" \
  -o "ServerAliveCountMax 3" \
  -o "ExitOnForwardFailure yes" \
  -o "ConnectTimeout 10" \
  -i /home/rabi/.ssh/keys/prod-bastion.pem \
  -L 5433:dbhost.internal:5432 \
  ec2-user@bastion.example.com
Restart=always
RestartSec=10
 
[Install]
WantedBy=default.target

Notice the ExecStart does not use -f here. Systemd wants the process in the foreground so it can supervise it. The Restart=always line takes over from the -f background behavior.

Enable and start.

systemctl --user daemon-reload
systemctl --user enable --now pg-tunnel.service
systemctl --user status pg-tunnel.service

That last command should show active (running). If you want the service to start at boot (not just at login), enable user lingering once.

sudo loginctl enable-linger $USER

Logs are in journalctl.

journalctl --user -u pg-tunnel.service -f

On macOS, the same job goes into a launchd plist at ~/Library/LaunchAgents/com.rabi.pg-tunnel.plist. The shape is similar enough that I will skip the full XML, but the key entries are KeepAlive set to true and a ProgramArguments array that mirrors the autossh command above.

Autossh under systemd keeps the tunnel alive across reboots

What are the common SSH tunnel mistakes to avoid?

I have made every one of these. So has everyone I have onboarded.

Binding the forward to 0.0.0.0 instead of 127.0.0.1. The default -L 5433:dbhost:5432 binds to localhost only. If you write -L *:5433:dbhost:5432, you have just opened your laptop's port 5433 to anyone on the same wifi network as you. They can now talk to your production Postgres. Do not do this. The local bind defaults to localhost for a reason.

Hardcoding production credentials in ~/.ssh/config. SSH config has no concept of secrets management. If you check your config into a dotfiles repo, that key path goes with it. Keep production keys outside the config-tracked area and reference them by absolute path from a directory that is gitignored.

Forgetting that ServerAliveInterval is client-side only. This tells your laptop to send keepalives. The bastion can still close the connection on its own idle timeout. If your bastion is AWS Systems Manager based or sits behind a corporate load balancer with a short idle, you also need to set keepalives at the server level (ClientAliveInterval in sshd_config). Otherwise your tunnel drops every five minutes and you do not know why.

Trusting the tunnel to give you encryption end-to-end. SSH encrypts the laptop-to-bastion segment. The bastion-to-database segment is in cleartext on your private network. For most production setups, that is fine because the private network is trusted. For sensitive workloads with stricter compliance requirements (PCI, HIPAA), insist on TLS on the database side too, even inside the VPC.

Tunneling through your jump host as root. The bastion is a public-facing box. The account that holds your tunnel should not also be the one with sudo rights. Use a dedicated low-privilege user for tunneling. Restrict it to command="false",no-shell in authorized_keys if you want to be paranoid, with a PermitOpen directive that limits which host:port combos this key can forward to.

The hardened authorized_keys line looks like this.

command="echo 'no shell',no-pty,no-agent-forwarding,no-X11-forwarding,permitopen="dbhost.internal:5432" ssh-ed25519 AAAA... rabi@laptop

That key can open exactly one forward, to exactly one host:port, and cannot execute a shell. If the key leaks, the attacker gets a tunnel to one database, not a shell on your bastion.

Letting the tunnel persist across job changes. This one is process, not technical. When an engineer leaves the team, their SSH keys come out of every bastion's authorized_keys. The keys are in your secrets manager and config-managed. Right? Right.

Using the same bastion for staging and production. I have seen this break twice. A misconfigured LocalForward ends up pointing your TablePlus at production while you think you are on staging. Use separate bastions, separate config entries with distinct host names like pg-prod and pg-stage, and color-code the connections in your client so you cannot confuse them visually.

What does the whole setup look like in 30 seconds?

For the reader who skipped to the end, here is the compressed version.

# One-off
ssh -f -N -L 5433:dbhost.internal:5432 user@bastion
psql -h localhost -p 5433 -U app_user -d production
 
# Persistent, survives sleep and network changes
brew install autossh
AUTOSSH_GATETIME=0 autossh -M 0 -f -N \
  -o "ServerAliveInterval 30" \
  -L 5433:dbhost.internal:5432 user@bastion
 
# Persistent, survives reboot (Linux)
# Drop the autossh command into ~/.config/systemd/user/pg-tunnel.service
# and run: systemctl --user enable --now pg-tunnel.service

That is genuinely all you need for 95% of the database access situations a developer hits.

The whole reason this pattern works is that SSH is older than most of the protocols we layer on top of it, and the port-forwarding feature has been battle-tested for three decades. Use it. Stop opening database ports to the internet. Your future self will thank you the next time a Shodan-driven exploit campaign sweeps the internet looking for misconfigured Postgres instances, which is approximately every Tuesday.

For more on the protocol, see the OpenSSH manual page for ssh(1), the autossh project page, and RFC 4254 for the SSH connection protocol spec that defines port forwarding.

Keep Reading

Frequently Asked Questions

What is an SSH tunnel for database access?

An SSH tunnel for database access forwards a local port on your laptop through an authenticated SSH connection to a remote host, which then connects to the database. Your database client talks to localhost while the actual traffic rides inside the SSH channel. The database never needs to expose its port to the public internet.

How do I create an SSH tunnel from the command line?

Use the -L flag with ssh. The command ssh -L 5433:dbhost:5432 user@bastion forwards local port 5433 to the database host on its standard Postgres port, through the bastion. Connect your client to localhost:5433 and you are talking to the remote database.

How do I keep an SSH tunnel running after I close my terminal?

Use autossh with a systemd user service on Linux or launchd on macOS. Autossh monitors the SSH connection, restarts it if the link drops, and survives network changes. The systemd unit handles boot-time startup and clean shutdown. The full recipe is in the autossh section of this post.

Is using an SSH tunnel safe for production database access?

For read-only debugging and one-off operational tasks, yes. For routine application traffic, no, use a private network or a managed bastion service like AWS Session Manager. SSH tunnels rely on long-lived TCP connections and the security of every machine in the chain, which is fine for a developer but not for a hot path.

Rabinarayan Patra

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend and backend development, and emerging tech trends, straight to your inbox. No spam.