Vũ Văn HảiFull-stack · AI-native
GuidesBlog
Discuss a project

© 2026 Vu Van Hai · Written from real deployment experience.

HomeGuidesBlogRSS
  1. Guides
  2. /Database
  3. /Connect to PostgreSQL on a VPS through an SSH tunnel

Connect to PostgreSQL on a VPS through an SSH tunnel

Use an SSH tunnel so your dev machine can reach PostgreSQL on a VPS without exposing port 5432 to the internet, plus the DATABASE_URL setup for local and production.

Updated: Sep 21, 20264 min read
PostgreSQLSSHVPSNetworking
On this page
  • Quick reference
  • 1. Understand the connection model
  • 2. Open the SSH tunnel for local dev
  • 3. Configure .env.local for local dev
  • 4. Configure .env.production for a same-VPS deploy
  • 5. Advanced: run the tunnel in the background
  • 6. Advanced: handle a port conflict
  • Troubleshooting

Port 5432 on a VPS is usually blocked by the firewall (as it should be), so your dev machine cannot connect to the database directly. An SSH tunnel solves that: it maps a port on your local machine to port 5432 on the VPS over an encrypted SSH connection. This guide shows how to open the tunnel and how to set up .env for each environment (local / production).

Throughout, replace <username> with your SSH user on the VPS, <server-ip> with the VPS IP, and <db_user>, <password>, <database_name> with your database details. ~/.ssh/id_ed25519 is the path to your SSH private key - change it if yours has a different name.

Quick reference

Local - SSH tunnel:

# Run in a separate terminal and keep it open for the whole work session
ssh -L 5432:localhost:5432 <username>@<server-ip> -i ~/.ssh/id_ed25519 -N
# .env.local
DATABASE_URL=postgresql://<db_user>:<password>@localhost:5432/<database_name>

Production (deployed on the same VPS as the database):

# .env.production
DATABASE_URL=postgresql://<db_user>:<password>@localhost:5432/<database_name>

In production the backend runs on the same VPS as PostgreSQL, so it uses localhost directly - no SSH tunnel needed.

1. Understand the connection model

+------------------+          SSH Tunnel           +------------------+
|   Local machine  | ---- port 5432 -------------> |   VPS            |
|   (dev machine)  |  ssh -L 5432:localhost:5432   |   PostgreSQL     |
|                  |                               |   :5432          |
+------------------+                               +------------------+
  • Local: the dev machine cannot reach the database on the VPS directly (port 5432 is normally firewalled) -> use an SSH tunnel to map local port 5432 to port 5432 on the VPS.
  • Production: the backend is deployed on the same VPS as the database -> use localhost directly.

2. Open the SSH tunnel for local dev

Open a separate terminal and run:

ssh -L 5432:localhost:5432 <username>@<server-ip> -i ~/.ssh/id_ed25519 -N
FlagMeaning
-L 5432:localhost:5432Forward local port 5432 to port 5432 on the VPS
<username>@<server-ip>SSH user and IP of the VPS
-i ~/.ssh/id_ed25519Path to the SSH private key
-NDo not open a shell, just hold the tunnel

This terminal must stay open for the whole work session. Closing it drops the database connection.

3. Configure .env.local for local dev

DATABASE_URL=postgresql://<db_user>:<password>@localhost:5432/<database_name>

Because the tunnel maps localhost:5432 to the VPS, the backend connects as if the database were running on your local machine.

4. Configure .env.production for a same-VPS deploy

DATABASE_URL=postgresql://<db_user>:<password>@localhost:5432/<database_name>

When the backend is deployed on the same VPS as PostgreSQL, localhost points straight at the database on that machine - no tunnel and no public IP required.

5. Advanced: run the tunnel in the background

If you would rather not keep a terminal open, run the tunnel in the background:

ssh -f -N -L 5432:localhost:5432 <username>@<server-ip> -i ~/.ssh/id_ed25519
FlagMeaning
-fGo to the background after authenticating
-NDo not open a shell

To stop a background tunnel (macOS / Linux):

# Find the PID
ps aux | grep "ssh -f -N -L"

# Kill the process
kill <PID>

On Windows (PowerShell):

# Find the PID
Get-Process ssh | Where-Object { $_.CommandLine -like "*5432*" }

# Kill the process
Stop-Process -Id <PID>

6. Advanced: handle a port conflict

If a local PostgreSQL is already running on port 5432, give the tunnel a different local port:

ssh -L 5433:localhost:5432 <username>@<server-ip> -i ~/.ssh/id_ed25519 -N

Then update .env.local:

DATABASE_URL=postgresql://<db_user>:<password>@localhost:5433/<database_name>

The opposite approach - keep the tunnel on 5432 and move the local PostgreSQL to another port - is covered in Clone a PostgreSQL database from a VPS to local Windows.

Troubleshooting

SymptomCauseFix
Connection refused on localThe SSH tunnel is not running or was closedCheck the terminal holding the tunnel and run the SSH command again
Address already in useLocal port 5432 is taken (a local PostgreSQL is running)Use another port: -L 5433:localhost:5432
Permission denied (publickey)Wrong key path, or the key was never added to the VPSCheck the path after -i and make sure the public key is in ~/.ssh/authorized_keys on the VPS
Network is unreachableThe VPS is offline or its IP changedPing the VPS and confirm the IP
Tunnel drops after a whileThe server times out idle connectionsAdd -o ServerAliveInterval=60 to the SSH command

The full command for a stable tunnel:

ssh -L 5432:localhost:5432 <username>@<server-ip> \
  -i ~/.ssh/id_ed25519 \
  -N \
  -o ServerAliveInterval=60 \
  -o ServerAliveCountMax=3
PreviousTune VPS performance: swap, PostgreSQL and the DB poolNextClone a PostgreSQL database from a VPS to local Windows

Related articles

  • Migrate a PostgreSQL database between two VPS with pg_dump

    Move an entire PostgreSQL database (schema and data) from a source VPS to a target VPS through your local machine: dump with pg_dump, restore in a single transaction, verify row counts, then cut over and roll back safely.

    Database

    Database
  • Fix Docker containers that cannot reach PostgreSQL on a VPS

    Docker picks a new network range every time you run docker-compose down and up, so PostgreSQL on the host rejects the container. Diagnose the subnet, open UFW and pg_hba.conf, then allow the whole 172.16.0.0/12 range to fix it once.

    Database

    Database
  • Set up PostgreSQL on a VPS securely

    Install and configure PostgreSQL on an Ubuntu/Debian VPS for production: create a database and user, open remote access safely (SSL + pg_hba + UFW), and understand transaction isolation.

    Database

    Database

Written by Vu Van Hai

I'm Hai, a full-stack developer based in Ho Chi Minh City. These guides come from systems I built and run myself. Need to build or untangle something similar? Get in touch.

Discuss a projectMore guides

Spot a mistake or a command that no longer works? Let me know

On this page

  • Quick reference
  • 1. Understand the connection model
  • 2. Open the SSH tunnel for local dev
  • 3. Configure .env.local for local dev
  • 4. Configure .env.production for a same-VPS deploy
  • 5. Advanced: run the tunnel in the background
  • 6. Advanced: handle a port conflict
  • Troubleshooting