TL;DR - An SSH tunnel creates an encrypted channel between your local machine and a remote server, forwarding database traffic through it. It's the most common way developers access remote MySQL, PostgreSQL, and other databases. - The basic command is
ssh -L 3306:localhost:3306 user@bastion-host, but getting it right involves understanding port ordering, flags like-Nand-f, and common failure modes. - SSH tunnels are necessary when your database sits on a private network with no public endpoint — behind a VPC, internal subnet, or bastion host. - SSH tunnels are unnecessary when your database has a public endpoint. In that case, IP whitelisting + TLS is simpler, more reliable, and works from any device. - DBEverywhere provides browser-based database access with a static IP for whitelisting. For databases that do require SSH tunnels, the paid tier ($5/mo) handles tunnels for you — no terminal required.
Table of Contents
- What Is an SSH Tunnel?
- How SSH Tunnels Work for Database Access
- Step-by-Step: SSH Tunnel Commands for MySQL
- SSH Tunnel Flags Explained
- Common SSH Tunnel Mistakes
- When You Actually Need an SSH Tunnel
- When You Don't Need an SSH Tunnel
- Alternatives Compared: SSH Tunnel vs VPN vs IP Whitelisting
- FAQ
- Conclusion
SSH Tunnels for Database Access Explained (And Why You Shouldn't Need Them)
If you've ever needed to connect to a remote MySQL or PostgreSQL database, someone has probably told you to "just set up an SSH tunnel." The advice is well-intentioned, and the technique is sound — SSH tunnel MySQL explained in one sentence: it forwards your local database client's traffic through an encrypted SSH connection to reach a remote server. But most developers use SSH tunnels without fully understanding what's happening, and many use them in situations where a simpler approach would work better.
This article breaks down exactly how SSH tunnels work for database access, walks through the commands step by step, covers the mistakes that trip up even experienced developers, and explains when you actually need a tunnel versus when you're adding complexity for no reason.
What Is an SSH Tunnel?
An SSH tunnel is a method of transporting arbitrary network data over an encrypted SSH (Secure Shell) connection. SSH was designed for remote terminal access — logging into servers — but its protocol supports port forwarding, which lets you route other types of traffic through the same encrypted channel.
When you create an SSH tunnel for database access, here's what happens at a high level:
- Your local machine opens an SSH connection to a remote server (often called a bastion host or jump box)
- The SSH client listens on a port on your local machine (e.g.,
3306) - Any traffic sent to that local port gets encrypted and forwarded through the SSH connection
- The remote server receives the traffic and forwards it to the database server
- Database responses travel back through the same encrypted channel
The network flow looks like this:
Your laptop (localhost:3306)
|
| [encrypted SSH connection over port 22]
|
Bastion host (server with SSH access)
|
| [plain connection on private network]
|
Database server (db-host.internal:3306)
The key concept: your local MySQL client thinks it's connecting to localhost:3306, but that traffic is actually being tunneled to a completely different server. The SSH tunnel is invisible to the database client.
How SSH Tunnels Work for Database Access
SSH supports three types of port forwarding. For ssh tunnel database access, you'll almost always use local port forwarding — the first and simplest type.
Local Port Forwarding
Local port forwarding binds a port on your local machine and forwards traffic to a destination reachable from the remote SSH server. This is what developers mean when they say "SSH tunnel for database access."
The syntax is:
ssh -L [local_port]:[remote_host]:[remote_port] [ssh_user]@[ssh_server]
Reading left to right:
- local_port — the port your database client connects to on localhost
- remote_host — the database server's hostname as seen from the SSH server (not from your laptop)
- remote_port — the port the database is listening on (3306 for MySQL, 5432 for PostgreSQL)
- ssh_user@ssh_server — your SSH credentials for the bastion/jump host
Remote Port Forwarding
Remote port forwarding works in the opposite direction — it exposes a port on the remote server that forwards back to your local machine. This is rarely used for database access. You'd use it if you needed the remote server to reach a database running on your laptop, which is an unusual scenario.
Dynamic Port Forwarding (SOCKS Proxy)
Dynamic forwarding creates a SOCKS proxy through the SSH connection, allowing any application that supports SOCKS to route traffic through the tunnel. Some GUI tools like DBeaver support SOCKS proxies, but for simple database access, local port forwarding is more straightforward.
Step-by-Step: SSH Tunnel Commands for MySQL
Let's walk through real commands, starting simple and building up.
Scenario 1: Database on the Same Host as SSH
Your MySQL server runs on the same machine you're SSH-ing into. This is common with small VPS setups.
ssh -L 3306:localhost:3306 deploy@203.0.113.50
Here, localhost in the -L flag refers to the remote server's localhost — it means "once the traffic arrives at 203.0.113.50, forward it to that same machine's port 3306." After running this, connect your MySQL client to 127.0.0.1:3306 on your laptop.
Scenario 2: Database on a Different Internal Host
Your database runs on a private host like db-primary.internal that's only reachable from within your VPC. You have SSH access to a bastion host on the same network.
ssh -L 3307:db-primary.internal:3306 deploy@bastion.example.com
This binds local port 3307 (not 3306 — see Common Mistakes below for why) and forwards traffic to db-primary.internal:3306 via the bastion host. Your MySQL client connects to 127.0.0.1:3307.
Important: The hostname db-primary.internal is resolved by the bastion host, not by your laptop. Your laptop doesn't need to know how to reach that hostname — the bastion does.
Scenario 3: Background Tunnel (Non-Interactive)
You don't want the SSH session occupying a terminal window. Add the -N and -f flags:
ssh -N -f -L 3307:db-primary.internal:3306 deploy@bastion.example.com
This forks the SSH process into the background and doesn't open a remote shell. The tunnel stays open until you kill the process or it times out.
Scenario 4: PostgreSQL
Same concept, different default port. PostgreSQL listens on 5432:
ssh -L 5433:pg-host.internal:5432 deploy@bastion.example.com
Connect your psql client to localhost:5433.
Scenario 5: Using an SSH Key File
If your bastion host requires a specific key:
ssh -i ~/.ssh/bastion_key -N -f -L 3307:db-primary.internal:3306 deploy@bastion.example.com
The -i flag specifies the private key file. This is common when the bastion host uses a different key than your default ~/.ssh/id_rsa or ~/.ssh/id_ed25519.
SSH Tunnel Flags Explained
Here's what each flag does — these five cover 95% of local port forwarding MySQL use cases:
| Flag | What It Does |
|---|---|
-L [local]:[host]:[remote] |
Sets up local port forwarding. Binds local port on your machine, forwards to host:remote via the SSH server. |
-N |
Don't execute a remote command. Without this, SSH opens an interactive shell on the remote server — you don't need that for a tunnel. |
-f |
Fork to background after authentication. Combines with -N so the tunnel runs silently. |
-i [keyfile] |
Specifies the private key for authentication. |
-v |
Verbose output. Useful for debugging connection issues — shows exactly what SSH is negotiating and forwarding. Use -vvv for maximum detail. |
A sixth flag worth knowing:
| Flag | What It Does |
|---|---|
-o ServerAliveInterval=60 |
Sends a keepalive packet every 60 seconds. Prevents the tunnel from dying silently when idle (see Common Mistakes). |
Common SSH Tunnel Mistakes
These are the issues developers hit repeatedly. Every one of these has burned hours of debugging time.
1. Port Conflicts on Localhost
If MySQL is already running on your laptop on port 3306, you can't bind the tunnel to the same port:
bind [127.0.0.1]:3306: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3306
Fix: Use a different local port. 3307, 3308, or any unused port works. Then connect your client to that port:
# Tunnel on 3307
ssh -L 3307:db-host.internal:3306 deploy@bastion.example.com
# Connect MySQL client to 3307
mysql -h 127.0.0.1 -P 3307 -u dbuser -p
2. Wrong Host Resolution
The middle part of the -L flag — the remote_host — is resolved by the SSH server, not by your machine. This command won't work:
# WRONG: your laptop can't resolve db-host.internal
ssh -L 3307:db-host.internal:3306 deploy@bastion.example.com
# This IS correct — db-host.internal is resolved by the bastion, not your laptop
Actually, that command is correct. The confusion goes the other way. This is wrong:
# WRONG: using an IP your laptop resolved for a host that has a different IP on the private network
ssh -L 3307:10.0.0.5:3306 deploy@bastion.example.com
# Only works if 10.0.0.5 is the correct IP as seen from the bastion
Always use the hostname or IP as it appears from the SSH server's network perspective.
3. Forgetting the -N Flag
Without -N, SSH opens an interactive shell. The tunnel still works, but:
- You have a shell session you don't need
- If the shell times out due to inactivity, the tunnel dies with it
- You might accidentally type something into a production server
Always use -N for tunnels.
4. Tunnel Dying Silently
SSH tunnels drop when the connection goes idle. Your database client will suddenly show "MySQL server has gone away" or "Connection reset by peer." The tunnel process is still running, but the underlying TCP connection has been torn down by a NAT gateway or firewall that reaps idle connections — typically after 300-900 seconds.
Fix: Add keepalive settings:
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -N -f -L 3307:db-host.internal:3306 deploy@bastion.example.com
This sends a keepalive packet every 60 seconds and kills the tunnel after 3 missed responses (180 seconds of total unresponsiveness). You can also set this globally in ~/.ssh/config:
Host bastion.example.com
ServerAliveInterval 60
ServerAliveCountMax 3
5. Connecting to localhost Instead of 127.0.0.1
On some systems, mysql -h localhost uses a Unix socket instead of TCP. Since SSH tunnels are TCP-based, you need to force a TCP connection:
# May use Unix socket — tunnel won't work
mysql -h localhost -P 3307 -u dbuser -p
# Forces TCP — tunnel works
mysql -h 127.0.0.1 -P 3307 -u dbuser -p
This one catches people off guard because localhost and 127.0.0.1 seem identical but behave differently for MySQL clients.
When You Actually Need an SSH Tunnel
SSH tunnels exist for a reason. Here are the cases where they're the right tool:
Your database has no public endpoint. This is the big one. If your database is inside a VPC, on a private subnet, or behind a firewall that only allows connections from within the network, you need a way in. An SSH tunnel through a bastion host on that network is the standard approach.
Your cloud provider doesn't support IP whitelisting. Some managed database services restrict access to specific VPC peers or private endpoints and don't offer public IP + allowlist as an option. An SSH tunnel through an EC2 instance or Droplet on the same network is your path in.
You need access from a restricted environment. If you're on a corporate network that blocks outbound connections on port 3306 or 5432 but allows port 22, an SSH tunnel routes your database traffic through the SSH port.
Compliance requires encrypted access and you can't use TLS. If your database doesn't support TLS (rare in 2026, but it happens with older self-managed instances), an SSH tunnel encrypts the connection. Though in this case, the real fix is enabling TLS.
When You Don't Need an SSH Tunnel
Here's the part most "how to SSH tunnel" articles leave out: most remote database connections don't need a tunnel.
Your Database Has a Public Endpoint
Every major managed database provider offers public endpoints with IP-based access control:
- DigitalOcean Managed Databases: Trusted Sources (IP allowlist), TLS enforced by default
- AWS RDS: Security Groups with IP allowlisting, TLS available
- PlanetScale: IP restrictions + TLS on all connections
- Google Cloud SQL: Authorized Networks (IP allowlist), TLS enforced
- Linode/Akamai: Trusted IPs + TLS
If your database has a public hostname and you can whitelist your IP, the connection is:
mysql -h db-host.example.com -P 3306 -u dbuser -p --ssl-mode=REQUIRED
No tunnel. No bastion host. No port forwarding. The connection is encrypted via TLS, and the firewall only allows your IP. This is simpler, more reliable, and works from any device — including a browser-based tool.
How to whitelist an IP on your database provider ->
The Tunnel Adds Complexity Without Security Benefit
A common justification for SSH tunnels is "encryption." But if your database connection already uses TLS (and in 2026, most managed databases enforce it), the SSH tunnel is encrypting traffic that's already encrypted. You're adding:
- A bastion host to maintain and patch
- SSH key management across your team
- A process that can die silently (see Common Mistakes)
- A setup that doesn't work from a tablet, phone, or someone else's computer
You're Working From Multiple Devices
SSH tunnels require a terminal and SSH client. If you work from a personal laptop, a work machine, and occasionally a tablet, you need SSH keys and tunnel scripts on all of them. IP whitelisting with a gateway service works from any device with a browser.
Alternatives Compared: SSH Tunnel vs VPN vs IP Whitelisting
Here's how the main approaches to remote database access compare:
| SSH Tunnel | VPN | IP Whitelisting + TLS | DBEverywhere | |
|---|---|---|---|---|
| Setup time | 2-5 min per device | 15-60 min (server + clients) | 1-2 min (one-time) | ~30 seconds |
| Works from any device | No (needs SSH client) | No (needs VPN client) | Yes (if IP is static) | Yes (browser only) |
| Handles private databases | Yes | Yes | No (needs public endpoint) | Yes (paid tier SSH tunnels) |
| Ongoing maintenance | SSH key rotation, bastion patching | VPN server updates, cert renewal | Firewall rule updates | None |
| Connection reliability | Drops on idle, manual restart | Generally stable | Very stable | Very stable |
| Team scaling | Key distribution per person | VPN account per person | IP per person/office | Account per person |
| Cost | Free (if bastion exists) | $5-50/mo for VPN server | Free | Free tier or $5/mo |
| Encryption | SSH (always) | VPN protocol (always) | TLS (database-level) | TLS + session isolation |
The pattern: SSH tunnels and VPNs are necessary when the database is on a private network. When the database has a public endpoint, IP whitelisting is simpler and more reliable. DBEverywhere handles both cases — direct connections via static IP whitelisting, and SSH tunnels for private databases on the paid tier.
FAQ
Can I use an SSH tunnel with phpMyAdmin?
Yes, but it's awkward. You'd run phpMyAdmin locally (or in Docker), set up an SSH tunnel on your machine, and point phpMyAdmin at 127.0.0.1:3307. It works, but you're managing both phpMyAdmin and the tunnel. DBEverywhere runs phpMyAdmin for you and handles SSH tunnels on the paid tier — no local setup.
Is an SSH tunnel more secure than a direct TLS connection?
Not meaningfully. Both encrypt traffic in transit. An SSH tunnel adds a second encryption layer, but TLS is already sufficient for protecting data between client and server. The OpenSSH documentation and database TLS implementations both use strong cipher suites. The security advantage of SSH tunnels is network access control (routing through a bastion), not encryption.
How do I keep an SSH tunnel running permanently?
Use autossh, which monitors the SSH connection and restarts it automatically:
autossh -M 0 -o "ServerAliveInterval 60" -o "ServerAliveCountMax 3" -N -f -L 3307:db-host.internal:3306 deploy@bastion.example.com
The -M 0 flag disables autossh's own monitoring port and relies on OpenSSH's built-in keepalive instead. For production use, run this as a systemd service so it survives reboots.
What's the difference between -L and -R in SSH?
-L (local forwarding) forwards traffic from your machine to the remote network — this is what you use for connect to remote database SSH scenarios. -R (remote forwarding) does the opposite: it forwards traffic from the remote server back to your machine. You'd use -R if you wanted a remote server to reach a database running on your laptop, which is uncommon.
My SSH tunnel connects but the database connection times out. What's wrong?
Three common causes: (1) The database hostname in your -L flag is wrong — remember it's resolved by the SSH server, not your laptop. (2) A firewall on the database server is blocking connections from the bastion host — the tunnel gets you to the bastion, but the bastion still needs network access to the database. (3) The database is listening on a different port or only on 127.0.0.1 and the bastion is trying to reach it on its network IP.
Conclusion
SSH tunnel MySQL explained in a sentence: it forwards local traffic through an encrypted SSH connection to reach a database on a remote network. It's a powerful technique, and when your database is locked inside a private network, it's often the only option short of a VPN.
But for the majority of database access — managed databases with public endpoints, cloud databases with IP allowlisting, anything with a hostname you can connect to directly — SSH tunnels add setup time, maintenance burden, and fragility without meaningful security benefit over TLS.
If you need to access a database from a browser without managing SSH tunnels, local installations, or VPN clients, DBEverywhere gives you phpMyAdmin and Adminer with a static IP for whitelisting. For databases that genuinely require SSH tunnels, the paid tier ($5/mo) handles tunnel setup for you — you provide the SSH credentials, and the connection is managed server-side.
Try DBEverywhere Free
Access your database from any browser. No installation, no Docker, no SSH tunnels.
Get Started