Secure PostgreSQL in Docker: SSH Tunneling to Cloud VMs (Step-by-Step Guide)
Securely access PostgreSQL in Docker on cloud VMs via SSH tunnels—no open ports needed. Step-by-step guide for encrypted connections, static IPs, and local access.
Introduction
Connecting to remote services—like databases, web apps, or internal tools—often means dealing with firewalls, exposed ports, and security risks. But what if you could access them as if they were local, without opening them to the internet?
Enter SSH tunneling: a powerful, encrypted "shortcut" that forwards remote services securely through your SSH connection. It’s like a private underground tunnel—convenient, hidden from attackers, and locked tight with encryption.
In this guide, you’ll learn:
✅ How SSH tunneling works (plain English)
✅ Secure setup for Dockerized PostgreSQL
✅ Security best practices—convenience without compromise
✅ Common pitfalls (and how to avoid them), Pro tips: Static IPs, IPv4 binding, and troubleshooting
Whether you're a developer, admin, or security-conscious user, SSH tunnels can simplify remote access while keeping it locked down. Let’s dive in. 🔐
Containers Are Great—Until You Need to Run a Simple Command
Once you embrace Docker/Podman and the power of containerization, it’s tempting to run everything in containers—and for good reason. The benefits are undeniable:
✅ Effortless installation – No dependency hell, no conflicting system packages.
✅ Clean isolation – Keep services neatly compartmentalized without cluttering your host machine.
✅ Simplified lifecycle – Start, stop, and rebuild with a single command.
But this convenience comes at a cost: added complexity for simple tasks. What used to be a straightforward command, like:
psql -h localhost -U myuser -d mydb
Now becomes a multi-flag invocation just to access a Postgres instance running in a container:
docker exec -it postgres_container psql -U myuser -d mydb
Or worse—if the database is on a remote host, you might need an SSH tunnel + container exec combo, turning a one-liner into a chore.
Does this mean containers are overkill? Not at all. But it’s worth knowing when to use them—and when a simpler approach might save you time.
How SSH Tunneling Works (Simply Explained)
SSH (Secure Shell) is like a locked, encrypted pipeline between two computers—it lets you securely run commands or transfer files over an untrusted network (like the internet). An SSH tunnel takes this a step further: instead of just running commands, it creates a private "road" that securely forwards data from one place to another.
Imagine you need to access a database (like PostgreSQL) on a remote server, but it’s not exposed to the internet. Instead of opening a risky public port, you can use an SSH tunnel to "punch through" securely. Your local machine connects to the remote server via SSH, then forwards traffic—like database queries—through that encrypted connection.
In short:
- 🔒 SSH = Secure remote login
- 🚇 SSH Tunnel = A hidden, encrypted pathway for other services (databases, web apps, etc.)
- No open ports, no exposure—just safe, direct access.
This way, you get convenience without compromising security. Next, let’s set one up.
Setting Up Your SSH Tunnel
Now that you understand how SSH tunneling works, let’s create one. We’ll use PostgreSQL as an example, but this method works for any service (MySQL, Redis, web apps, etc.).
Basic SSH Tunnel Syntax
ssh -L [LOCAL_PORT]:[REMOTE_HOST]:[REMOTE_PORT] [USER]@[SSH_SERVER] -N
-L
= Forward a local port to the remote host
[LOCAL_PORT]
= Port on your machine (e.g.,5433
)[REMOTE_HOST]:[REMOTE_PORT]
= Service you want to access (e.g.,localhost:5432
for Postgres)-N
= Run without executing remote commands (just forward traffic)
Example: Connect to Remote PostgreSQL Securely
ssh -L 5433:localhost:5432 user@remote-server.com -N
This means:
- Locally, you connect to
localhost:5433
- Securely, traffic flows through SSH to the remote server’s Postgres (
localhost:5432
)
Testing the Connection
While the tunnel runs (in a separate terminal), connect using:
psql -h localhost -p 5433 -U dbuser -d mydb
✅ Boom! You’re querying a remote database as if it’s local—without exposing it to the internet.
Making It Persistent (Optional)
- Use
tmux
orscreen
to keep the tunnel running in the background:bashCopyDownloadtmux new -s postgres-tunnel
ssh -L 5433:localhost:5432 user@remote-server.com -N(Detach withCtrl+B D
, reattach withtmux attach -t postgres-tunnel
) - For automation, set up SSH keys (
ssh-keygen
) to avoid password prompts.
Security Note
- 🔐 Always use SSH keys instead of passwords.
- 🔒 Restrict access with firewall rules (
ufw
) on the remote server. - ❌ Never expose sensitive ports publicly—always tunnel.
Advanced SSH Tunneling Tricks
Once you’ve mastered basic port forwarding, these power moves unlock even more flexibility:
1. Reverse Tunnels (Expose Local Services Remotely)
Need to share a local dev server with a colleague? A reverse tunnel (-R
) makes your localhost
accessible from the remote machine:
ssh -R 8080:localhost:3000 user@remote-server.com
Now, anyone on remote-server.com
can access your local app (port 3000
) via remote-server.com:8080
.
2. Jump Hosts (Tunnel Through Intermediate Servers)
If your target machine is locked behind a bastion host, "jump" through it:
ssh -J jump-user@bastion-host.com final-user@private-db-host.com
Or chain tunnels manually:
ssh -L 5433:private-db-host.com:5432 jump-user@bastion-host.com -N
3. Dynamic SOCKS Proxy (For Full Traffic Routing)
Route all your traffic through a remote server (great for public Wi-Fi):
ssh -D 1080 user@remote-server.com -N
Configure your browser/OS to use localhost:1080
as a SOCKS proxy.
4. Multiplexing (Reuse SSH Connections)
Speed up multiple tunnels by sharing one SSH connection:
# ~/.ssh/config
Host remote-server.com
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
First connection establishes the master, subsequent ones attach instantly.
5. Auto-Reconnect with Systemd (For Reliability)
Keep tunnels alive across reboots:
# ~/.config/systemd/user/ssh-tunnel.service
[Unit]
Description=Postgres SSH Tunnel
[Service]
ExecStart=ssh -L 5433:localhost:5432 user@remote-server.com -N
Restart=always
[Install]
WantedBy=default.target
Run:
systemctl --user enable --now ssh-tunnel
Pro Tip: Combine these! Example:
ssh -J bastion-host.com -L 5433:private-db-host:5432 -R 8080:localhost:3000 -D 1080 user@remote-server.com -N
This single command:
✅ Jumps through a bastion
✅ Forwards Postgres
✅ Exposes your local app
✅ Creates a SOCKS proxy
Connecting to a Dockerized PostgreSQL Service via SSH Tunnel
When your PostgreSQL database runs inside a Docker container on a cloud VM, you can’t just expose its port publicly—that’s a security risk. Instead, use an SSH tunnel to securely forward the port to your local machine. Here’s how:
Step 1: Configure Docker Networking (On the Remote VM)
By default, Docker containers use dynamic IPs. To ensure consistency:
1. Create a Custom Docker Network with a Fixed Subnet
bashCopyDownloaddocker network create --subnet=172.20.0.0/24 pg-network
2. Launch PostgreSQL with a Static IP
docker run -d \
--name postgres-db \
--net pg-network \
--ip 172.20.0.10 \
-e POSTGRES_PASSWORD=yourpassword \
-p 127.0.0.1:5432:5432 \
postgres:15
--ip 172.20.0.10
assigns a fixed IP.-p 127.0.0.1:5432:5432
binds Postgres only to localhost on the VM.
3. Verify the Container’s IP
docker inspect postgres-db | grep IPAddress
Should return:
"IPAddress": "172.20.0.10"
Step 2: Set Up the SSH Tunnel (From Your Local Machine)
Since Postgres is bound to 127.0.0.1
inside the VM, we’ll forward it securely:
Basic Forwarding
ssh -N -L 5433:localhost:5432 user@your-cloud-vm-ip
-L 5433:localhost:5432
forwards your local port 5433 to the VM’slocalhost:5432
.
Alternative: Directly Target the Docker IP
If you skipped -p 127.0.0.1:5432:5432
, use the container’s IP instead:
ssh -N -L 5433:172.20.0.10:5432 user@your-cloud-vm-ip
Step 3: Connect from Local Machine
Now, treat it like a local database:
psql -h localhost -p 5433 -U postgres
Security Considerations
✅ No Public Ports – Postgres is only accessible via SSH.
✅ Encrypted Traffic – All data passes through the secure tunnel.
✅ Docker Isolation – The container’s network is private.
Important Note: Binding to IPv4 vs. IPv6
For the SSH tunnel command:
ssh -N -L 5433:172.20.0.10:5432 user@your-cloud-vm-ip
⚠️ By default, this binds to IPv6 only on most systems!
To explicitly bind to your local IPv4 address, use:
ssh -N -L 127.0.0.1:5433:172.20.0.10:5432 user@your-cloud-vm-ip
Why this matters:
- Some applications (especially older ones) may fail to connect to
localhost:5433
if the tunnel is IPv6-only - Explicitly specifying
127.0.0.1
ensures:- IPv4 binding
- No conflicts with IPv6 services
- Consistent behavior across different systems
Pro Tip: You can bind to all interfaces (including external ones - use with caution!) with:
ssh -N -L 0.0.0.0:5433:172.20.0.10:5432 user@your-cloud-vm-ip
(Only recommended for development on trusted networks)
Troubleshooting
- "Connection refused"?
- Verify Postgres is running:
docker logs postgres-db
- Check port binding:
ss -tulnp | grep 5432
(on VM)
- Verify Postgres is running:
- SSH issues?
- Ensure
AllowTcpForwarding yes
is set in/etc/ssh/sshd_config
(on VM).
- Ensure
Final Tip: Simplify with ~/.ssh/config
Add this to avoid retyping:
Host pg-tunnel
HostName your-cloud-vm-ip
User user
LocalForward 5433 localhost:5432
Then just run:
ssh -N pg-tunnel
Now you have secure, reliable access to your Dockerized Postgres—no open ports required! 🚀
Outro: Secure Access, Simplified
SSH tunneling turns a risky, exposed database into a private, encrypted channel—without complex VPNs or public ports. Now you can:
✅ Safely access Dockerized Postgres from anywhere
✅ Keep cloud databases hidden from the internet
✅ Avoid dependency clashes with clean container isolation
Next time you’re tempted to expose a port, remember the tunnel. A single ssh -L
command might be all you need.