SSH Port Forwarding — Secure Server Access
How to access pgAdmin, Grafana, and admin panels via SSH tunnel without exposing ports to the internet. Practical examples and automation.
SSH Port Forwarding (tunneling) routes network traffic through an encrypted SSH connection. It lets you access pgAdmin, Grafana, RabbitMQ, and other services on your server without opening their ports to the internet. A single command replaces VPN setup, firewall rules, and additional authentication layers.
If SSH keys aren't configured on your server yet — start with the SSH keys setup guide. It also covers ~/.ssh/config, which you'll need for tunnel automation later in this article.
When SSH Tunnels Beat Alternatives
SSH tunnels aren't the only way to keep admin panels off the internet. But in most cases they're optimal:
| Solution | When it fits | When it's overkill |
|---|---|---|
| SSH Port Forwarding | 1–3 developers, one server | Large team, permanent access |
| VPN (WireGuard) | Entire team, many services | One-time access for one person |
| Bastion Host | Dozens of servers, corporate infra | Single VPS |
| Cloudflare Tunnel | Web access without VPN for team | CLI tools and databases |
In practice it's simpler than it sounds: for a single VPS with a Docker stack, an SSH tunnel is up in 10 seconds, requires no additional software, and works with any protocol — HTTP, PostgreSQL, Redis, AMQP.
Types of SSH Port Forwarding
Local Port Forwarding (-L) — Primary Use Case
Local forwarding maps a port from the remote server to your local machine. Used in 90% of cases — when you need to open a browser or connect a client to a service running on the server.
Syntax:
ssh -L [local_port]:[destination_host]:[destination_port] user@server
Traffic flow:
Your computer (localhost:8080) → SSH tunnel → Server (localhost:3001)
Remote Port Forwarding (-R) — For Demos and Webhooks
Remote forwarding works in the opposite direction: maps a port from your local machine to the server. Use cases — showing a client your local dev server or receiving a webhook from an external service.
ssh -R [remote_port]:[local_host]:[local_port] user@server
Dynamic Port Forwarding (-D) — SOCKS Proxy
Creates a SOCKS5 proxy on your local machine. All traffic routed through the proxy goes through the SSH connection. Used for accessing the server's entire private network rather than a specific port.
ssh -D 1080 user@server
Practical Examples
Accessing pgAdmin
pgAdmin is deployed in a Docker container on server port 5050. The port isn't open in the firewall — and shouldn't be:
ssh -L 5050:localhost:5050 deployer@YOUR_SERVER_IP
After connecting, http://localhost:5050 in the browser opens pgAdmin. Traffic is encrypted, port is closed from the internet.
Direct PostgreSQL Connection
For working via DBeaver, DataGrip, or psql — forward port 5432:
ssh -L 5432:localhost:5432 deployer@YOUR_SERVER_IP
Connection parameters in the client:
Host: localhost
Port: 5432
Database: your_database
User: your_user
I once worked on a project where a colleague connected to the database through public port 5432, open in the firewall, for an entire week. PostgreSQL logs accumulated over 12,000 brute-force attempts from bots during that time. An SSH tunnel closed the issue in a minute.
Multiple Services Simultaneously
Admin panel on port 3001, pgAdmin on 5050, Grafana on 3000 — all in one command:
ssh -L 8080:localhost:3001 \
-L 5050:localhost:5050 \
-L 3000:localhost:3000 \
deployer@YOUR_SERVER_IP
After connecting:
| Service | Local address |
|---|---|
| Admin panel | http://localhost:8080 |
| pgAdmin | http://localhost:5050 |
| Grafana | http://localhost:3000 |
Forwarding Through a Jump Server (Bastion)
Application in a private network (10.0.0.5:3001), accessible only through an intermediate server:
Your PC → Jump Server (public IP) → Private Server (10.0.0.5:3001)
ssh -L 8080:10.0.0.5:3001 deployer@jump-server.com
The SSH tunnel is built to jump-server, and from there traffic goes to 10.0.0.5:3001. Locally everything is available at http://localhost:8080.
Background Mode and Automation
Tunnel Without an Interactive Session
Flags -f and -N start the tunnel in the background without executing commands on the server:
ssh -f -N -L 8080:localhost:3001 deployer@YOUR_SERVER_IP
To terminate:
# Find PID
ps aux | grep "ssh -f"
# Terminate
kill <PID>
autossh — Automatic Reconnection
SSH tunnels break on unstable connections. autossh monitors the state and reconnects:
sudo apt install autossh
autossh -M 0 -f -N -L 8080:localhost:3001 deployer@YOUR_SERVER_IP
The -M 0 flag disables the separate monitoring port — instead it uses ServerAliveInterval / ServerAliveCountMax, which are already configured in ~/.ssh/config from the SSH keys guide.
~/.ssh/config — Tunnels With One Command
Instead of long commands — a config on the local machine:
Host myproject-admin
HostName YOUR_SERVER_IP
User deployer
IdentityFile ~/.ssh/id_ed25519
LocalForward 8080 localhost:3001
LocalForward 5050 localhost:5050
LocalForward 3000 localhost:3000
ServerAliveInterval 60
ServerAliveCountMax 3
Now:
# Was
ssh -L 8080:localhost:3001 -L 5050:localhost:5050 -L 3000:localhost:3000 deployer@YOUR_SERVER_IP
# Became
ssh myproject-admin
More on ~/.ssh/config, wildcard rules, and managing multiple servers — in the SSH keys article.
SSH Tunnel Security
Binding to localhost
By default -L binds the forwarded port to 127.0.0.1 — accessible only from your machine. Explicitly specifying 0.0.0.0 opens the port to everyone on the local network:
# Bad — accessible to all devices on the network
ssh -L 0.0.0.0:8080:localhost:3001 user@server
# Correct — local only
ssh -L 8080:localhost:3001 user@server
Few people pay attention to this flag, but it turns an SSH tunnel from a secure tool into a vulnerability — anyone on your Wi-Fi network gains access to the forwarded service.
Don't Leave Tunnels Open
Background tunnels (ssh -f -N) run until the process is explicitly terminated. Check active tunnels:
ps aux | grep "ssh -f"
For production servers there's a nuance worth keeping in mind: an unused tunnel is an open SSH connection which, if the local machine is compromised, gives an attacker a direct path to server services.
Additional Measures
An SSH tunnel is only as secure as the SSH access itself. Basic measures — disabling passwords, ed25519 keys, fail2ban, changing the default port — are covered in SSH keys setup and production server preparation.
Troubleshooting
"Address already in use"
The local port is already occupied by another process:
# Find what's using the port
sudo lsof -i :8080
# Terminate the process
kill <PID>
# Or use a different local port
ssh -L 8081:localhost:3001 deployer@YOUR_SERVER_IP
"Connection refused"
Three causes and diagnostics:
- Application not running on the server
- Application listening on a specific IP, not
localhost - Docker container bound to
172.x.x.xinstead of0.0.0.0
# On server — check that service is listening
sudo ss -tlnp | grep :3001
# If listening on 172.17.0.2:3001 — forward through that IP
ssh -L 8080:172.17.0.2:3001 deployer@YOUR_SERVER_IP
Tunnel Disconnects
Add to ~/.ssh/config:
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
TCPKeepAlive yes
If disconnects are frequent — autossh with automatic reconnection (described above).
FAQ
How is an SSH tunnel different from a VPN?
An SSH tunnel forwards specific ports through an encrypted SSH connection. A VPN creates a virtual network interface and routes all traffic (or a subnet) through a secure channel. For accessing 2–3 services as a single developer, an SSH tunnel is up with one command. For the whole team with a dozen services — WireGuard VPN is more convenient.
Is it safe to forward the PostgreSQL port?
Yes, if the tunnel is bound to 127.0.0.1 (the default). Traffic goes through an encrypted SSH connection, port 5432 isn't opened in the firewall, external connections are impossible. This is safer than opening 5432 in UFW and hoping for a strong password.
How to forward a port to a Docker container?
If the container publishes a port to the host (-p 5050:5050 in docker-compose), forward localhost:5050. If the container is in an isolated Docker network without published ports, forward the container IP: ssh -L 5050:172.17.0.3:5050 user@server. Container IP — docker inspect container_name | grep IPAddress.
Can tunnel startup be automated?
Yes. Two approaches: LocalForward in ~/.ssh/config (tunnel is created on ssh host-alias) and autossh for background tunnels with auto-reconnection. For a systemd server you can create a unit file that starts autossh at system boot.
Useful Links
- SSH Keys on Ubuntu — Generation, Configuration, Security — ed25519, config, agent, fail2ban
- Ubuntu VPS Production Setup — 8 Steps — firewall, Docker, Nginx, SSL, backups
- OpenSSH Manual: ssh
- autossh — monitor and restart SSH sessions