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.
On this page
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
localhostdirectly.
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| Flag | Meaning |
|---|---|
-L 5432:localhost:5432 | Forward local port 5432 to port 5432 on the VPS |
<username>@<server-ip> | SSH user and IP of the VPS |
-i ~/.ssh/id_ed25519 | Path to the SSH private key |
-N | Do 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| Flag | Meaning |
|---|---|
-f | Go to the background after authenticating |
-N | Do 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 -NThen 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
| Symptom | Cause | Fix |
|---|---|---|
Connection refused on local | The SSH tunnel is not running or was closed | Check the terminal holding the tunnel and run the SSH command again |
Address already in use | Local 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 VPS | Check the path after -i and make sure the public key is in ~/.ssh/authorized_keys on the VPS |
Network is unreachable | The VPS is offline or its IP changed | Ping the VPS and confirm the IP |
| Tunnel drops after a while | The server times out idle connections | Add -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