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 or screen to keep the tunnel running in the background:bashCopyDownloadtmux new -s postgres-tunnel
    ssh -L 5433:localhost:5432 user@remote-server.com -N(Detach with Ctrl+B D, reattach with tmux 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’s localhost: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)
  • SSH issues?
    • Ensure AllowTcpForwarding yes is set in /etc/ssh/sshd_config (on VM).

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.